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.
Both build steps (
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:
1
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).2
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).3
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.4
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).
