src/main.py is a flat, top-to-bottom composition root: importing it runs the entire boot sequence as a side effect, and the module-level app object it leaves behind is what gunicorn serves (main:app). Because the sequence is ordered import statements and bare function calls — not lazy callbacks — the order is load-bearing, and two distinct failure points raise RuntimeError before the server ever binds a port.
This page documents that boot order, the two fail-fast validators, the service-instantiation container in src/application/app.py, and the two independent environment switches (DEPLOYMENT_ENVIRONMENT and APP_ENVIRONMENT) that are easy to confuse.
How the service is launched
In production the container’sCMD runs gunicorn, which imports main:app:
PYTHONPATH=/app/src is set in the image, so main:app resolves to src/main.py. For standalone local runs, python main.py executes the same module top-to-bottom and then enters the if __name__ == "__main__": block (src/main.py:262), which gunicorn never triggers. That distinction is the crux of the dev-mode db.create_all() behaviour described below.
Boot sequence
Every step below runs at import time, in this exact order. Nothing is deferred.Logging is configured first, before any other import
src/main.py opens by importing and immediately calling configure_logging() (lines 1–3), before Flask, settings, or anything else is imported. This guarantees that every subsequent import — including config-validation failures — is captured by the root logger with the project’s CustomFormatter. configure_logging() (src/diagnostic/logs.py) sets the root level from LOG_LEVEL (default 20/INFO, with a try/except fallback to 20) and silences socketio/engineio loggers to WARNING.settings.py is evaluated (import-time JWT guard fires here)
from connector.db_connection import DBConnector, db (src/main.py:20), whose module imports settings — so src/application/settings.py runs its ~80 os.getenv() assignments here. If JWT_SECRET_KEY is empty and the deployment is non-local, settings.py raises RuntimeError immediately at import (settings.py:118-122), before validate_critical_config() is ever reached. See Failure mode 1.validate_critical_config() runs before blueprints import
validate_critical_config() is called at src/main.py:162, deliberately positioned before the route-blueprint imports on lines 163–174. It raises RuntimeError if REGION, DB_IP, or JWT_SECRET_KEY are empty, plus VPN_BUCKET when not local. Placing it first means a misconfigured deployment fails with a clear message instead of a confusing downstream import error. See Failure mode 2.Route blueprints import — which builds the whole service container
src.application.app (e.g. drone_routes.py:11-19 does from src.application.app import (...)), so this import transitively executes app.py and instantiates every *Service singleton (VideoService, UserService, DroneControlService, and so on). In other words, the dependency-injection container is wired as a side effect of importing the routes — not at the later import app as app_module on line 237, which merely re-references the already-loaded module.Flask app, DB, OpenTelemetry, Swagger
app = Flask(__name__) (line 177). DBConnector(app) (line 178) assembles SQLALCHEMY_DATABASE_URI from DB_USERNAME/PASSWORD/IP/NAME and wires Flask-Migrate. initialize_opentelemetry(app) (line 179) is a hard no-op unless OTEL_EXPORTER_OTLP_ENDPOINT is set. Then Swagger is mounted at /api/docs/ with the spec at /apispec.json.CORS, JWT, Bcrypt, SocketIO — environment-branched
IS_LOCAL_ENVIRONMENT (localhost + dev.skyhub.ai locally; skyhub.ai + api.skyhub.ai otherwise). JWTManager and Bcrypt are attached, then SocketIO is created with async_mode="gevent", ping_timeout=SOCKETIO_PING_TIMEOUT, ping_interval=SOCKETIO_PING_INTERVAL, and an optional Redis message_queue=SOCKETIO_MESSAGE_QUEUE.Register blueprints, wire Socket.IO, finalize JWT config
/api/v1/ (lines 222–233). socket_routes.init_socket_routes(socketio, drone_control_service) binds the telemetry handlers. JWT config is applied to app.config (line 245+), and the token_in_blocklist_loader checks the in-memory BLOCKLIST set.Assign the SocketIO instance onto DroneControlService
app_module.drone_control_service.socketio = socketio. This is done at module import — before any gunicorn worker handles a request — so that rosbridge connection callbacks can emit telemetry back to clients. If you refactor the boot order, this assignment must remain after socketio exists and before the first request.The two fail-fast validators
Two independent guards can stop the service at boot. They fire in this order and produce different messages.Failure mode 1 — import-time JWT guard
This lives insettings.py itself and runs the moment the module is imported:
Failure mode 2 — validate_critical_config()
| Variable | Required when | Default (settings.py) | Notes |
|---|---|---|---|
REGION | always | eu-central-1 | Has a default, so empty only if explicitly blanked. |
DB_IP | always | "" | No default → the most common startup failure. |
JWT_SECRET_KEY | always | local-only fallback | On non-local, already enforced at import (see above). |
VPN_BUCKET | non-local only | None | Holds per-user/per-drone WireGuard access.conf in S3. |
The composition root — src/application/app.py
src/application/app.py is the dependency-injection container. At import it constructs every service as a module-level singleton and stitches their dependencies together by hand:
get_service(drone_type) — SITL is built on first use only
get_service(drone_type) — SITL is built on first use only
physical is registered eagerly; sitl is not. get_service() builds SITLDroneService on the first sitl request and only if settings.ENABLE_SITL, otherwise it logs a warning. Unknown types raise UnknownDroneTypeException. This avoids paying Docker-client initialization cost when SITL is disabled.get_vpn_service() — defers the boto3 S3 client
get_vpn_service() — defers the boto3 S3 client
VPN_Service is created on first access, not at import, so its boto3 S3 client is bound only after settings.REGION is loaded — avoiding a client pinned to a stale default region. See VPN IP Authentication & Jumphost Routing.Two environment switches you must not confuse
The Gateway has two independent environment variables with overlapping-sounding names. They control different things and are read in different places.DEPLOYMENT_ENVIRONMENT
settings.py:11. Values: local vs anything else (prod uses server). local derives IS_LOCAL_ENVIRONMENT = true, which relaxes CORS, permits the insecure JWT fallback, makes VPN_BUCKET optional, and whitelists local IPs in the VPN middleware. This is the master security switch.APP_ENVIRONMENT
main.py’s __main__ block (src/main.py:265) via os.getenv, never surfaced in settings.py. Values: dev vs production. When dev, python main.py calls db.create_all() instead of relying on migrations.Values that live in code, not env vars
A future editor should know these are intentionally hardcoded in the composition root and cannot be tuned through the environment:| Setting | Where | Value |
|---|---|---|
JWT_ACCESS_TOKEN_EXPIRES | src/main.py:247 | timedelta(minutes=10) |
JWT_REFRESH_TOKEN_EXPIRES | src/main.py:248 | timedelta(hours=12) |
JWT_ALGORITHM | src/main.py:246 | HS256 |
| CORS origins | src/main.py:188-192 | per-environment allowlist |
Socket.IO cors_allowed_origins | src/main.py:210 | * locally, else CORS list |
| Server port | src/main.py:270 | 5000 |
CLAUDE.md text listing JWT_ACCESS_TOKEN_EXPIRES/JWT_REFRESH_TOKEN_EXPIRES as env vars, they are hardcoded timedeltas. Adding a new frontend origin requires a code change, not a new env var. The JWT BLOCKLIST is an in-memory set in auth_routes — token revocation does not survive a restart or scale across workers unless a shared store is added. See Authentication & JWT Lifecycle.Gotchas for future editors
Import order is a contract, not incidental
Import order is a contract, not incidental
configure_logging() must run before every other import; settings is imported (and its JWT guard fires) before validate_critical_config(); and route-blueprint imports build the entire service container as a side effect. Reordering these can move failures earlier/later or silently skip logging on a config error.OpenTelemetry is entirely skipped without an endpoint
OpenTelemetry is entirely skipped without an endpoint
initialize_opentelemetry is a no-op unless OTEL_EXPORTER_OTLP_ENDPOINT is set — there is no console fallback exporter, and the created tracer object is unused (all spans come from auto-instrumentation). OTEL_RESOURCE_ATTRIBUTES is defined in settings.py but never applied. Full detail in OpenTelemetry & SigNoz Observability.settings.py defaults ≠ what runs in production
settings.py defaults ≠ what runs in production
settings.py and the env templates — e.g. USER_SITL_MAX_COUNT (3 vs 5), SEND_QUEUE_SIZE (30 vs prod 200), SITL_VIDEO_STREAM_DRONE_STATE (ARMED vs CONNECTED), LOG_LEVEL (20 vs prod 10). Never assume the settings.py literal is the deployed value; check the actual environment. See Gateway Environment Variables.The middleware package init is misnamed
The middleware package init is misnamed
src/middleware/ contains ___init__.py (three underscores), so it is not a real package __init__. Imports still work because src is on sys.path and drone_vpn is imported by module path. Do not “fix” this blindly without checking the imports still resolve.MAIL_* and APP_ENVIRONMENT bypass settings.py
MAIL_* and APP_ENVIRONMENT bypass settings.py
MAIL_SERVER/PORT/USERNAME/PASSWORD are read directly in email_service.py, and APP_ENVIRONMENT directly in main.py — none are surfaced in settings.py, so a config audit that only reads settings.py will miss them.Related pages
Environment Variables
Migrations & DB Connection
db.create_all() path.Service Layer & get_service Factory
VPN Middleware & Jumphost
check_vpn_ip trust model and jumphost header routing.
