The Gateway has no framework-managed application factory. Instead, 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.
For the full catalogue of environment variables and their defaults, see Gateway Environment Variables. This page only covers the ones that gate startup.

How the service is launched

In production the container’s CMD runs gunicorn, which imports main:app:
Dockerfile
CMD ["gunicorn", "--workers", "1", "--threads", "8", \
     "-k", "geventwebsocket.gunicorn.workers.GeventWebSocketWorker", \
     "-b", "0.0.0.0:5000", "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.
1

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.
2

settings.py is evaluated (import-time JWT guard fires here)

The first import that touches config is 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.
3

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.
4

Route blueprints import — which builds the whole service container

Lines 163–174 import the 12 route blueprints. Each route module imports its dependencies from 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.
5

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.
6

CORS, JWT, Bcrypt, SocketIO — environment-branched

CORS origins are chosen by 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.
7

Register blueprints, wire Socket.IO, finalize JWT config

All 12 blueprints are registered under /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.
8

Assign the SocketIO instance onto DroneControlService

Line 259 sets 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 in settings.py itself and runs the moment the module is imported:
src/application/settings.py:112-123
_jwt_secret_env = os.getenv("JWT_SECRET_KEY", "").strip()
if IS_LOCAL_ENVIRONMENT:
    # Allow insecure default only for local development
    JWT_SECRET_KEY = _jwt_secret_env or "test-secret-key-for-development-only"
else:
    # Fail fast for non-local deployments (server, production, etc.)
    if not _jwt_secret_env:
        raise RuntimeError(
            "JWT_SECRET_KEY environment variable is required for non-local deployments. "
            "Set DEPLOYMENT_ENVIRONMENT=local to use test defaults, or provide a secure JWT_SECRET_KEY."
        )
    JWT_SECRET_KEY = _jwt_secret_env
Because this guard is at import time, it fires before validate_critical_config() and before any Flask setup. Anything that imports settings — a migration script, a unit test, a flask db command — triggers it. On a non-local deployment with no JWT_SECRET_KEY, the process dies during import with the message above, not with the validate_critical_config message.

Failure mode 2 — validate_critical_config()

src/main.py:137-154
def validate_critical_config():
    critical_vars = {
        "REGION": settings.REGION,
        "DB_IP": settings.DB_IP,
        "JWT_SECRET_KEY": settings.JWT_SECRET_KEY,
    }
    # VPN_BUCKET is optional for local development but required in production
    if not settings.IS_LOCAL_ENVIRONMENT:
        critical_vars["VPN_BUCKET"] = settings.VPN_BUCKET

    missing = [k for k, v in critical_vars.items() if not v]
    if missing:
        raise RuntimeError(f"Missing critical environment variables: {', '.join(missing)}")
VariableRequired whenDefault (settings.py)Notes
REGIONalwayseu-central-1Has a default, so empty only if explicitly blanked.
DB_IPalways""No default → the most common startup failure.
JWT_SECRET_KEYalwayslocal-only fallbackOn non-local, already enforced at import (see above).
VPN_BUCKETnon-local onlyNoneHolds per-user/per-drone WireGuard access.conf in S3.
If startup dies with Missing critical environment variables: DB_IP, the container was started without a database host. If it dies with ... JWT_SECRET_KEY on a non-local deployment, you’ll actually see the import-time message from Failure mode 1 first, since settings is imported before this function runs.

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:
src/application/app.py:35-64
video_service = VideoService()
email_service = EmailService()
user_service = UserService(db, Bcrypt(Flask(__name__)), email_service)
generic_drone_service = DroneService(db)
generic_mission_service = MissionService(db)
# ...
drone_control_service = DroneControlService(generic_drone_service, generic_mission_service)
asset_service = AssetService(db)
physical_drone_service = PhysicalDroneService(db, video_service, drone_control_service, asset_service)
# ...
drone_services = {DroneTypes.physical.value: physical_drone_service}
Two things are deliberately lazy — do not “clean this up” into eager construction:
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.
src/application/app.py:83-107
def get_service(drone_type):
    if drone_type == DroneTypes.sitl.value and DroneTypes.sitl.value not in drone_services:
        if settings.ENABLE_SITL:
            drone_services[DroneTypes.sitl.value] = SITLDroneService(
                db, video_service, drone_control_service, asset_service
            )
        else:
            logger.warning("SITL service requested but SITL is not enabled.")
    drone_service = drone_services.get(drone_type)
    if drone_service:
        return drone_service
    raise UnknownDroneTypeException(drone_type)
See SITL Drone Lifecycle and the get_service factory overview.
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.
src/application/app.py:70-80
def get_vpn_service():
    global _vpn_service_instance
    if _vpn_service_instance is None:
        logger.info("Initializing VPN service with REGION=%s", settings.REGION)
        _vpn_service_instance = VPN_Service()
    return _vpn_service_instance
A refactor that eagerly instantiates SITLDroneService or VPN_Service at import can reintroduce the exact bugs these lazy wrappers exist to prevent (Docker-init overhead when SITL is off; an S3 client bound to the wrong region). Preserve the lazy semantics.

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

Read in 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

Read only in 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.
src/main.py:262-270
if __name__ == "__main__":
    with app.app_context():
        # change the APP_ENVIRONMENT to dev in compose yaml to run in development mode for DB
        env = os.getenv("APP_ENVIRONMENT", "production").lower()
        if env == "dev":
            db.create_all()
    logging.info(f"Starting SocketIO server on {settings.SOCKET_IP}:5000")
    socketio.run(app, host=settings.SOCKET_IP, port=5000, debug=True)
db.create_all() runs only under python main.py with APP_ENVIRONMENT=dev. Under gunicorn (production), the __main__ block never executes, so the schema must be provisioned with Flask-Migrate. Setting APP_ENVIRONMENT=dev in a gunicorn deployment does nothing. For the schema-provisioning path, see Migrations, DB Connection & Dev Mode.

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:
SettingWhereValue
JWT_ACCESS_TOKEN_EXPIRESsrc/main.py:247timedelta(minutes=10)
JWT_REFRESH_TOKEN_EXPIRESsrc/main.py:248timedelta(hours=12)
JWT_ALGORITHMsrc/main.py:246HS256
CORS originssrc/main.py:188-192per-environment allowlist
Socket.IO cors_allowed_originssrc/main.py:210* locally, else CORS list
Server portsrc/main.py:2705000
Despite older 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

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.
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.
Several defaults disagree between 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.
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_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.

Environment Variables

The full ~80-variable reference with defaults and template mismatches.

Migrations & DB Connection

Flask-Migrate workflow and the dev-mode db.create_all() path.

Service Layer & get_service Factory

How the DI container’s singletons are consumed by routes.

VPN Middleware & Jumphost

The check_vpn_ip trust model and jumphost header routing.

OpenTelemetry & SigNoz

Trace/log export wiring and the OTLP endpoint footguns.

Gateway Service Overview

Where the composition root sits in the wider service.