Two Dockerfiles, one difference that matters
The repo ships two Dockerfiles. They are byte-for-byte identical except for the base-image registry and the final gunicorn module target.| Aspect | Dockerfile | Dockerfile.ecr |
|---|---|---|
| Base image | python:3.10.12 (Docker Hub) | public.ecr.aws/docker/library/python:3.10.13 |
| gunicorn target | main:app | src.main:app |
| Used by | docker-compose.yml (local dev) | AWS CodeBuild builds & pushes to private ECR (CI/CD) |
| Intended runtime | Developer laptop | AWS ECS Fargate |
Dockerfile:6-17, Dockerfile.ecr:6-17) do the same thing:
apt-get installdocker-ce-cli+openssh-client— the container must be able to talk to a Docker daemon and SSH to a remote host.COPY . /appthenpip install -r requirements.txt.ENV PYTHONPATH=/app/srcso bothmainandsrc.mainresolve as import roots.- Install
containers/docker-entrypoint.shas theENTRYPOINT, with the gunicorn line asCMD.
Dockerfile.ecr exists so production builds do not depend on Docker Hub pull limits. The private registry the finished image is pushed to is <aws-account-id>.dkr.ecr.eu-central-1.amazonaws.com (ECR_REPO in src/application/settings.py).
What is NOT in the image
.dockerignore strips .github, janus-gateway, sitl, venv/.venv, architecture.png, README.md, and — notably — docker-compose.yml itself. So the compose file is a dev artifact only; the image never contains it.
The image is built and pushed by the AWS CodeBuild project (
modules/api/api_build_deploy.tf), triggered from GitHub: it runs docker build -f Dockerfile.ecr, pushes to ECR, and force-redeploys the ECS service. The GitHub Actions in .github/workflows/ only run Ruff and pytest — they do not build or push the image. Full pipeline details are on CI/CD: CodeBuild, ECR & Frontend Deploy.The gunicorn runtime: one worker is deliberate
The production command (both Dockerfiles) is:src/main.py builds socketio = SocketIO(app, async_mode="gevent", ...) (src/main.py:208), which is why the worker class must be GeventWebSocketWorker (gevent-websocket==0.10.1, gunicorn==22.0.0). WebSocket upgrades for Socket.IO telemetry only work under this worker — a plain sync worker would break real-time streaming.
Container boot sequence
Once gunicorn importsmain:app, src/main.py runs its module-level boot: configure_logging() first (before any other import), then validate_critical_config() (raises RuntimeError if REGION/DB_IP/JWT_SECRET_KEY are missing, plus VPN_BUCKET when not local), then Flask + DBConnector, OpenTelemetry, Swagger, CORS/JWT/SocketIO, and 12 /api/v1 blueprints. The full boot and validation logic is documented in Startup, Validation & Composition Root. The if __name__ == "__main__" block at the bottom (socketio.run(..., debug=True)) is dev-only and never executes under gunicorn.
docker-entrypoint.sh: SSH keys and the remote-Docker tunnel
containers/docker-entrypoint.sh runs before gunicorn. Its entire job is to make the container able to reach a remote Docker daemon (the office server that hosts production SITL containers). It gates on SSH_PRIVATE_KEY:
If SSH_PRIVATE_KEY is unset (local dev)
The script logs
No SSH keys found and immediately exec "$@" — a no-op passthrough to gunicorn. Local dev never needs this path because it mounts the host Docker socket directly (see below).Materialize SSH keys from env (prod, sourced from AWS SSM)
Writes
SSH_PRIVATE_KEY/SSH_PUBLIC_KEY/SSH_KNOWN_HOSTS into /root/.ssh/{id_ed25519,id_ed25519.pub,known_hosts} with correct permissions (docker-entrypoint.sh:10-16).Write an SSH config with a jumphost ProxyCommand
Generates
/root/.ssh/config so that reaching the office Docker host <office-docker-host> (user nexus0) goes through ProxyCommand ssh -W %h:%p ubuntu@${JUMPHOST_PUBLIC_IP:-<prod-ingress-ip>} (docker-entrypoint.sh:30-33). The AWS security group allows SSH to the jumphost, which can reach the 10.69.x.x WireGuard network. See VPC, WireGuard Jumphost & nginx Routing.Optionally open the Docker-daemon tunnel
Only when both
REMOTE_DOCKER_ENABLED=true and REMOTE_DOCKER_SSH_TARGET are set, it opens ssh -f -N -L 2375:localhost:2375 nexus0@<ip> (docker-entrypoint.sh:47-67), forwarding the remote daemon to tcp://localhost:2375 inside the container. It extracts the IP from the ssh://user@IP form of REMOTE_DOCKER_SSH_TARGET and verifies the tunnel with nc -z localhost 2375.Local dev with docker-compose
docker build . -t skyhub_gateway_service && docker-compose up -d builds from Dockerfile and brings up the gateway plus skyhub-redis, skyhub-postgres, janus-gateway, simple-whip-server, and skyhub-ws-proxy (docker-compose.yml). Key gateway wiring:
- Ports
5000:5000and2053:5000— the second is a Cloudflare-tunnel-friendly port, both hitting the same app port (GATEWAY_PORT/GATEWAY_CLOUDFLARE_PORT). ./:/app— the whole working tree is bind-mounted over the image’s/app. In local dev the running code is your checkout, not whatCOPY . /appbaked in. Editing a file changes what runs (after a restart), which is convenient but means a stale-looking image can mask code changes./var/run/docker.sockmounted in soSITLDroneServicecan create sibling containers on the host without SSH.~/.ssh/id_ed25519{,.pub}and~/.ssh/known_hostsmounted read-only for the remote-Docker path.extra_hosts: host.docker.internal:host-gatewayso the container can reach services on the host.DEPLOYMENT_ENVIRONMENT: local(enables SITL, insecure JWT default, permissive CORS) andENABLE_SITL: true.
Database schema on boot
The image does not run migrations for you. Apply Alembic migrations against a running container:APP_ENVIRONMENT=dev, which uses db.create_all() instead of migrations (used by the dev __main__ block and by CI). Details in Migrations, DB Connection & Dev Mode.
Swagger: live UI vs static generator
The API spec is produced two different ways, and they can drift.Live flasgger UI
src/main.py:183 mounts Swagger(app, config=..., template=swagger_template). Browse it at /api/docs/; the raw spec is at /apispec.json. The live swagger_template (src/main.py:39-77) has 16 tags and advertises both http and https schemes with no fixed host — it reflects the actual routes registered at runtime.Static generate_swagger.py
Run
python3 generate_swagger.py to emit swagger.json + swagger.yaml (both gitignored). It regex-parses route decorators and docstring YAML across 9 route files (generate_swagger.py:56-66), converts <int:id> → {id}, and hardcodes host: api.skyhub.ai with schemes: [https] — 12 tags.Gotchas checklist
Renaming main.py breaks one image
Renaming main.py breaks one image
Dockerfile runs main:app, Dockerfile.ecr runs src.main:app. Both depend on PYTHONPATH=/app/src. Change the module path in only one place and that image fails to import at boot with no build-time error.Do not bump --workers past 1 without a backplane
Do not bump --workers past 1 without a backplane
Socket.IO rooms, the rosbridge connection pool, and the JWT
BLOCKLIST are per-process. Multi-worker requires SOCKETIO_MESSAGE_QUEUE=redis://... (and shared stores for the pool/blocklist) or telemetry, revocation, and command routing fragment.The entrypoint no-ops locally
The entrypoint no-ops locally
SSH key setup only runs when
SSH_PRIVATE_KEY is present (prod, via SSM). The -L 2375 tunnel additionally needs REMOTE_DOCKER_ENABLED=true AND REMOTE_DOCKER_SSH_TARGET. The latter is invisible to settings.py.Compose shadows the baked image
Compose shadows the baked image
./:/app mounts the working tree over /app, so in dev the container runs your checkout, not the COPY-ed snapshot. A rebuild is not enough to pick up code changes in compose — a restart is.Compose defaults are fake secrets
Compose defaults are fake secrets
JWT/mail/OTel/Redis defaults in
docker-compose.yml are dev placeholders. Production must supply real values via env; settings.py refuses to start non-local when JWT_SECRET_KEY is unset or empty (it checks presence, not strength).
