This page covers how the Gateway Service is packaged into a container image, what runs when that container boots, and the load-bearing constraints of its runtime — the single gevent-websocket worker, the SSH-injecting entrypoint, and the two ways Swagger is produced. For where the image is pushed and run see ECS Fargate Services and CI/CD: CodeBuild, ECR & Frontend Deploy; for the full env-var catalog see Gateway Environment Variables.

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.
AspectDockerfileDockerfile.ecr
Base imagepython:3.10.12 (Docker Hub)public.ecr.aws/docker/library/python:3.10.13
gunicorn targetmain:appsrc.main:app
Used bydocker-compose.yml (local dev)AWS CodeBuild builds & pushes to private ECR (CI/CD)
Intended runtimeDeveloper laptopAWS ECS Fargate
Both build steps (Dockerfile:6-17, Dockerfile.ecr:6-17) do the same thing:
  1. apt-get install docker-ce-cli + openssh-client — the container must be able to talk to a Docker daemon and SSH to a remote host.
  2. COPY . /app then pip install -r requirements.txt.
  3. ENV PYTHONPATH=/app/src so both main and src.main resolve as import roots.
  4. Install containers/docker-entrypoint.sh as the ENTRYPOINT, with the gunicorn line as CMD.
The main:app vs src.main:app split is a genuine footgun. Because PYTHONPATH=/app/src, main:app imports /app/src/main.py, while src.main:app imports it as a package member from /app. Both must keep working — a refactor that renames/moves src/main.py or changes PYTHONPATH has to update both CMDs, or one image silently fails to import at boot. src/main.py is the WSGI object gunicorn loads in either case; there is no separate wsgi.py.
The public-ECR base in 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:
gunicorn --workers 1 --threads 8 \
  -k geventwebsocket.gunicorn.workers.GeventWebSocketWorker \
  -b 0.0.0.0:5000 main:app
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.
--workers 1 is load-bearing, not a placeholder. Three critical pieces of state live in-process and are not shared across workers:
  • Socket.IO rooms — telemetry fan-out to drone_{id}_{stream} rooms (Socket.IO Telemetry Streaming).
  • The rosbridge connection pool in DroneControlService.drone_mapping (Connection Pool & Startup Wiring).
  • The JWT BLOCKLIST — an in-memory set() (src/routes/auth_routes.py:17) checked by the token_in_blocklist_loader (src/main.py:251) for logout/revocation.
Scaling to --workers 2+ without a shared backplane splits every drone’s telemetry, pool, and revoked-token set across processes, so subscriptions and logout become non-deterministic. To scale horizontally you must set SOCKETIO_MESSAGE_QUEUE to a Redis URL (src/main.py:214) — that fans Socket.IO events across processes — and the pool/BLOCKLIST would still need their own shared store. Today the service runs one worker with 8 threads.

Container boot sequence

Once gunicorn imports main: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.
REMOTE_DOCKER_SSH_TARGET is read only by this shell script, never by src/application/settings.py. Python’s SITLDroneService reads REMOTE_DOCKER_HOST instead (src/service/sitl_drone_service.py:41-44). The two must agree on the transport:
  • REMOTE_DOCKER_HOST=ssh://nexus0@<office-docker-host> → the Docker SDK uses the system SSH client + the ProxyCommand config directly (use_ssh_client=True); the -L 2375 tunnel is not used.
  • REMOTE_DOCKER_HOST=tcp://localhost:2375 → the SDK connects to the tunnel the entrypoint opened, so REMOTE_DOCKER_SSH_TARGET must be set to trigger that tunnel.
Getting only one of the pair right is a common source of “SITL creation hangs” — the SDK connects but there is nothing on 2375. The container also runs as root and (in compose) bind-mounts the host Docker socket and SSH private key read-only; that is effectively host-level privilege and is required for sibling-container control. How those containers are then launched is covered in SITL Drone Lifecycle.

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:5000 and 2053: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 what COPY . /app baked 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.sock mounted in so SITLDroneService can create sibling containers on the host without SSH.
  • ~/.ssh/id_ed25519{,.pub} and ~/.ssh/known_hosts mounted read-only for the remote-Docker path.
  • extra_hosts: host.docker.internal:host-gateway so the container can reach services on the host.
  • DEPLOYMENT_ENVIRONMENT: local (enables SITL, insecure JWT default, permissive CORS) and ENABLE_SITL: true.
docker-compose.yml hardcodes dev placeholder secrets in its :-default fallbacks — JWT_SECRET_KEY, MAIL_PASSWORD, an OTEL signoz-access-token, and REDIS_PASSWORD. These are dev conveniences only. In production, settings.py hard-fails when JWT_SECRET_KEY is unset or empty and DEPLOYMENT_ENVIRONMENT != local, preventing a fallback to the insecure default (src/application/settings.py:111-123). It checks only that the key is present — not its strength — so a present-but-weak key is still accepted. Never treat the compose defaults as real credentials.

Database schema on boot

The image does not run migrations for you. Apply Alembic migrations against a running container:
docker exec -it skyhub_gateway_service flask db upgrade
# create a new revision after model changes:
docker exec -it skyhub_gateway_service flask db migrate -m "add column X"
The one exception is 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.
The two specs are not kept in sync. generate_swagger.py only scans 9 route files (it misses execution_routes, billing_routes, and calendar_routes, which are registered live), has a different tag list, and pins a single https host. The static files are only refreshed when someone runs the script manually. Treat /api/docs/ as the source of truth for what the running service actually exposes, and regenerate the static files deliberately before publishing them. The full endpoint reference is at HTTP API Overview & Auth Models.

Gotchas checklist

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.
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.
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.
./:/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.
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).