Almost every environment variable the gateway reads flows through a single module — src/application/settings.py — which evaluates ~80 os.getenv() calls at import time, coerces types, and applies defaults. Routes, services, and the rosbridge layer all import these module-level constants, so the exact names, defaults, and coercion in settings.py are load-bearing: renaming or re-typing one is a breaking change across the whole codebase. A handful of variables bypass settings.py and are read directly with os.getenv() elsewhere (email, APP_ENVIRONMENT, AWS credentials), and a few values that look configurable are actually hardcoded. This page enumerates everything, groups it by concern, and calls out every place a default disagrees between settings.py, .env.example, and the exported production ECS task definition.
Deeper mechanics live on sibling pages — this page is the reference table. See Startup, Validation & Composition Root for boot ordering and the fail-fast guards, VPN IP Authentication & Jumphost Routing for the network-trust model, OpenTelemetry & SigNoz for the OTEL wiring, and Migrations, DB Connection & Dev Mode for the database URI and APP_ENVIRONMENT.

Where configuration comes from

settings.py raises RuntimeError at import time if JWT_SECRET_KEY is empty on any non-local deployment (src/application/settings.py:118) — this fires before validate_critical_config() runs, so anything that imports settings triggers it. Set DEPLOYMENT_ENVIRONMENT=local to fall back to an insecure test key, or provide a real key.

Default mismatches — read before deploying

settings.py defaults are what runs when a variable is unset. The .env.example template and the production ECS task definition (docs/aws_prod_env_vars.env) both override several of them, so the code default is frequently not what runs in production. Do not assume the settings.py value.
Variablesettings.py default.env.exampleProduction (ECS)
DEPLOYMENT_ENVIRONMENTserverlocalserver
USER_SITL_MAX_COUNT355
SITL_CPU_LIMIT2.02.01.0
SITL_VIDEO_STREAM_DRONE_STATEARMEDCONNECTEDCONNECTED
SEND_QUEUE_SIZE30(unset → 30)200
LOG_LEVEL20 (INFO)2010 (DEBUG)
REMOTE_DOCKER_HOSTssh://nexus0@<office-docker-host>ssh://nexus0@<office-docker-host>tcp://jumphost-private.skyhub-prod.internal:2375
OTEL_EXPORTER_OTLP_ENDPOINTunset (OTEL off)http://<office-docker-host>:4318/v1/traceshttp://jumphost-private.skyhub-prod.internal:4317
RESOURCE_TAGskyhub-dev(unset)skyhub-prod
ACCOUNT_ID123(unset)<aws-account-id>
OTLP endpoint footgun. docker-compose.yml defaults the base endpoint to http://<office-docker-host>:4318, and the exporters append /v1/traces and /v1/logs themselves. But .env.example bakes the signal path into the base var (.../v1/traces), which would make the log exporter POST to /v1/traces/v1/logs. Production points at port 4317 (the gRPC port) while the code uses the HTTP exporter. Keep this a bare host:4318 base URL. See OpenTelemetry & SigNoz.

Deployment & environment

VariableDefaultPurpose
DEPLOYMENT_ENVIRONMENTserverMaster switch. local derives IS_LOCAL_ENVIRONMENT=true, enabling permissive CORS, the insecure JWT fallback, local X-Drone-IP whitelisting, and optional VPN_BUCKET. Any other value = strict/non-local.
IS_LOCAL_ENVIRONMENTderivedNot a raw env var — computed as DEPLOYMENT_ENVIRONMENT == "local" (settings.py:12). Gates CORS, the JWT secret fallback, the VPN_BUCKET requirement, and middleware IP whitelisting.
APP_ENVIRONMENTproductionNot in settings.py — read directly in main.py:265 and only in the __main__ block. If dev, runs db.create_all() instead of Flask-Migrate. Distinct from DEPLOYMENT_ENVIRONMENT.
ENABLE_SITLtrueGates lazy SITLDroneService init (app.py:94) and allows SKYHUB_SITL_* names via X-Drone-IP in the VPN middleware.
ENABLE_REGISTRATIONtrueToggles public user registration (auth_routes.py:185,248).
ADMINS_ONLYfalseDead config — defined in settings.py:6 but referenced nowhere else in src.
FLASK_APPmain.pyFlask CLI entrypoint for flask db migrate / upgrade. Read by the Flask CLI, not settings.py.
SOCKET_IPNoneBind host for socketio.run() in the __main__ dev path (.env.example: 0.0.0.0). Ignored under gunicorn.
LOG_LEVEL20 (INFO)Numeric root logger level (configure_logging); also the level of the OTEL LoggingHandler. Prod ships 10 (DEBUG).

Database

The URI is assembled in DBConnector as postgresql://{DB_USERNAME}:{DB_PASSWORD}@{DB_IP}/{DB_NAME} (src/connector/db_connection.py:29). All four values are .strip()ped in settings.py.
VariableDefaultPurpose
DB_USERNAME""PostgreSQL user.
DB_PASSWORD""PostgreSQL password.
DB_IP""PostgreSQL host. Criticalvalidate_critical_config() raises if empty. Prod: database.skyhub-prod.internal.
DB_NAME""PostgreSQL database name (prod: skyhub).

Authentication & JWT

VariableDefaultPurpose
JWT_SECRET_KEYtest-secret-key-for-development-only (local only)HS256 signing key for access/refresh JWTs and the Socket.IO handshake. Required on non-local deployments — raises RuntimeError at import if empty (settings.py:118).
JWT_ACCESS_TOKEN_EXPIRES (10 min) and JWT_REFRESH_TOKEN_EXPIRES (12 h) are hardcoded timedeltas in main.py:247-248, not env-configurable — despite older docs listing them as env vars. The JWT algorithm is fixed to HS256, and the revocation BLOCKLIST is an in-memory set (does not survive restart or scale across workers).

VPN & jumphost routing

VariableDefaultPurpose
VPN_SERVICE_IPNoneHost of the external WireGuard status service queried by VPN_Service status endpoints.
VPN_SERVICE_PORT5050Port of the external VPN status service.
VPN_BUCKETNoneS3 bucket holding per-user/per-drone WireGuard access.conf. Critical only on non-local deployments (validated). Prod: skyhub-prod-user-vpn.
JUMPHOST_IP""If set, rosbridge Connections route through ws://JUMPHOST_IP:JUMPHOST_PORT and add x-drone-ip / x-drone-port headers for Nginx multiplexing. Empty string → direct connection (connection.py:27, self.direct = not self.jumphost_ip).
JUMPHOST_PORT9090Port on the jumphost that fronts rosbridge.
USER_NETWORK_CIDR10.70.0.0/16WireGuard user subnet; IPService allocates user IPs here.
DRONE_NETWORK_CIDR10.71.0.0/16WireGuard drone subnet. The middleware trusts any source whose IP starts with 10.71. and IPService allocates drone IPs here.
Jumphost mode is selected purely by whether JUMPHOST_IP is a non-empty string. Both the WebSocket Connection and its HTTP _test_connection() reachability probe must send identical x-drone-ip / x-drone-port headers, or the probe and the socket can diverge. Details in VPN Middleware & Jumphost.

SITL orchestration

SITL container lifecycle is documented in SITL Drone Lifecycle. Note the env-var-name → settings.py-constant remaps flagged inline below.
VariableDefaultPurpose
SITL_IMAGE_NAMEardupilotDocker image for SITL (ArduPilot) containers. Prod uses a full ECR image path.
USER_SITL_MAX_COUNT3Max SITL drones per user. Templates use 5.
SITL_CPU_LIMIT2.0CPUs per SITL container (prod 1.0).
SITL_MEMORY_LIMIT4gMemory per SITL container.
CORE_IMAGE_NAMEcore:latestImage for the SITL core container (MAVROS + rosbridge).
CORE_CPU_LIMIT2.0CPUs per core container.
CORE_MEMORY_LIMIT2gMemory per core container.
GAMEPAD_IMAGE_NAMEskyhub-gamepad:latestImage for gamepad control containers.
GAMEPAD_CPU_LIMIT0.5CPUs per gamepad container.
GAMEPAD_MEMORY_LIMIT512mMemory per gamepad container.
GAMEPAD_API_URLhttp://localhost:5000Backend URL the gamepad container calls for execution tracking.
SITL_REDIS_IMAGEredis:7-alpineRedis image for the shared SITL container group.
SITL_REDIS_HOSThost.docker.internalRedis host for SITL/gamepad containers.
REDIS_PASSWORDskyhub_redis_secretsettings.SITL_REDIS_PASSWORD. Redis auth for SITL containers.
SITL_VIDEO_STREAM_DRONE_STATEARMEDsettings.VIDEO_STREAM_DRONE_STATE. Drone state at which SITL video streaming starts. Templates/prod use CONNECTED.
LOG_ERASE_AFTER_DOWNLOADfalseIf true, erase logs from the flight controller after a successful S3 upload.
SITL_HOST<office-docker-host>Derived from DOCKER_HOST_IP (settings.py:77). IP used to order/represent SITL drones in IPService.
These are applied to each new SITL drone at creation time — geofence and simulated 3S-LiPo failsafe params.
VariableDefaultPurpose
SITL_FENCE_ENABLE1FENCE_ENABLE (1 = geofence on).
SITL_FENCE_TYPE7FENCE_TYPE bitmask (1=alt, 2=circle, 4=polygon → 7 = alt+circle+polygon).
SITL_FENCE_ACTION1FENCE_ACTION (1 = RTL on breach).
SITL_FENCE_ALT_MAX100Max fence altitude (m).
SITL_FENCE_RADIUS300Circle fence radius (m).
SITL_BATT_VOLTAGE12.587Simulated fully-charged 3S voltage.
SITL_BATT_CAPACITY3300Battery capacity (mAh).
SITL_BATT_LOW_VOLT10.5Low-voltage failsafe threshold.
SITL_BATT_CRT_VOLT9.6Critical-voltage failsafe threshold.
SITL_BATT_ARM_VOLT10.0Minimum voltage to allow arming.
SITL_BATT_FS_LOW_ACT2Low-battery failsafe action (2 = RTL).

Docker host

VariableDefaultPurpose
DOCKER_HOSTNoneDocker daemon socket/host string (settings.py:66).
DOCKER_HOST_IPNoneIP that SITL containers connect back to; SITL drones use this instead of drone.ip, and it also seeds SITL_HOST. Prod/template: <office-docker-host>.
REMOTE_DOCKER_ENABLEDfalseIf true, provision SITL containers on a remote Docker host rather than the local daemon. Prod: true.
REMOTE_DOCKER_HOSTssh://nexus0@<office-docker-host>Remote Docker connection string (ssh:// or tcp://). Prod uses tcp://jumphost-private.skyhub-prod.internal:2375.

Video streaming

Video-room mechanics live in Janus Video Rooms. (SITL_VIDEO_STREAM_DRONE_STATE is listed under SITL above.)
VariableDefaultPurpose
JANUS_URLNoneJanus Gateway REST base URL for WebRTC video-room management. Prod: http://janus.skyhub-prod.internal:8088/janus.
WHIP_SERVER_URLNoneWHIP server base URL for video ingest. Prod: http://whip.skyhub-prod.internal:7080.

Socket.IO

Streaming behavior is documented in Socket.IO Telemetry Streaming.
VariableDefaultPurpose
SOCKETIO_PING_TIMEOUT60Flask-SocketIO ping timeout (s).
SOCKETIO_PING_INTERVAL25Flask-SocketIO ping interval (s).
SOCKETIO_MESSAGE_QUEUENoneOptional Redis URL for multi-worker Socket.IO fan-out / horizontal scaling.
DO_UNSUBSCRIBEtrueIf true, unsubscribe_telemetry actually stops the underlying rosbridge topic subscription (socket_routes).

Rosbridge & connection

Connection/reconnect internals live in Rosbridge Connection & Reconnect.
VariableDefaultPurpose
MAX_RECONNECT_ATTEMPTS15SmartSocket max reconnect attempts before is_expired (socket.py:21).
MAX_RECONNECT_TIME60Max seconds disconnected before SmartSocket gives up (socket.py:22).
DRONE_REACHABILITY_TIMEOUT2Connect/read timeout (s) for the HTTP reachability probe before opening a rosbridge socket.
ROSBRIDGE_SERVICE_TIMEOUT30.0Timeout (s) for rosbridge service calls (mission upload, geofence, param) — raised to 30 for WireGuard latency.
REQUEUEfalseIf true, failed outgoing rosbridge messages are re-queued instead of dropped.
SEND_QUEUE_SIZE30Max size of the per-connection outgoing rosbridge message queue. Prod: 200.
TELEMETRY_THROTTLE_RATE200Min interval (ms) between telemetry messages via rosbridge throttle_rate; 0 disables throttling.

Observability (OpenTelemetry / SigNoz)

VariableDefaultPurpose
OTEL_EXPORTER_OTLP_ENDPOINTNoneGate. If unset, OpenTelemetry init is skipped entirely (main.py:98). Base OTLP-HTTP endpoint; exporters append /v1/traces and /v1/logs.
OTEL_EXPORTER_OTLP_HEADERSNoneComma-separated key=value headers (e.g. signoz-access-token=<redacted>) attached to both span and log exporters.
OTEL_SERVICE_NAMEskyhub_gateway_serviceservice.name resource attribute in traces/logs.
OTEL_RESOURCE_ATTRIBUTESdeployment.environment={DEPLOYMENT_ENVIRONMENT}Defined but never appliedmain.py builds Resource(attributes={...}) with hardcoded keys and does not read this var, so any extra attributes set here are silently dropped.

Billing (Stripe)

Billing logic lives in Stripe Billing & Vehicle Limits.
VariableDefaultPurpose
STRIPE_SECRET_KEY""Server-side Stripe API key. Redact (sk_live_xxx / sk_test_xxx).
STRIPE_PUBLISHABLE_KEY""Client-side Stripe key (pk_xxx).
STRIPE_WEBHOOK_SECRET""Webhook signature secret (whsec_xxx).
STRIPE_PRICE_ID""Price ID for the subscription product (EUR 120/vehicle/year).
STRIPE_SUCCESS_URLhttps://skyhub.ai/billing/successCheckout success redirect.
STRIPE_CANCEL_URLhttps://skyhub.ai/billing/cancelCheckout cancel redirect.

Email (SMTP)

All four MAIL_* values are read directly with os.getenv() in src/service/email_service.py:13-16they are not surfaced in settings.py, so they are easy to miss when auditing config.
VariableDefaultPurpose
MAIL_SERVERsmtp.gmail.comSMTP host.
MAIL_PORT587SMTP port.
MAIL_USERNAME[email protected]SMTP sender address.
MAIL_PASSWORDNoneSMTP app password. Redact.
TEST_EMAIL_SERVERtrueIn settings.py. If true, EmailService runs a one-off SMTP login test at startup and warns (but continues) on failure; it does not disable real sending.

AWS & S3

REGION is the value settings.py exposes and is validated at startup; boto3 clients additionally pick up the standard AWS_* credential vars from the environment implicitly (they are never read in code).
VariableDefaultPurpose
REGIONeu-central-1AWS region. Critical — validated at startup. Used for S3/asset ops.
RESOURCE_TAGskyhub-devAWS resource-tag prefix used for installer/resource naming in drone_routes. Prod: skyhub-prod.
ACCOUNT_ID123AWS account id used in installer/ECR references. Prod: <aws-account-id>.
ASSET_BUCKETskyhub-prod-assetsS3 bucket for assets (video/image/logs).
INSTALLER_BUCKETskyhubcoreS3 bucket for drone installer artifacts (drone_routes).
ECR_REPO<aws-account-id>.dkr.ecr.eu-central-1.amazonaws.comsettings.DOCKER_REPO. ECR registry for SITL/gamepad/core images.
AWS_ACCESS_KEY_IDNoneboto3 credential (implicit; not read in settings.py). Redact.
AWS_SECRET_ACCESS_KEYNoneboto3 credential (implicit). Redact.
AWS_DEFAULT_REGIONeu-central-1boto3 region auto-detect. The lazy get_vpn_service() exists to ensure REGION is set before the S3 client is built (app.py:70).

Variables that are hardcoded, not env-driven

Some behavior that operators expect to configure via env is fixed in code. Changing it requires a code change, not a variable:
BehaviorWhere it livesValue
Access-token TTLmain.py:247timedelta(minutes=10)
Refresh-token TTLmain.py:248timedelta(hours=12)
JWT algorithmmain.py:246HS256
CORS / Socket.IO originsmain.py:188-193,210localhost:4200, dev.skyhub.ai (local); skyhub.ai, api.skyhub.ai (prod)
HTTP/Socket.IO listen portmain.py:2705000
Adding a new frontend origin is a code change (main.py), not an env var. The two environment switches — DEPLOYMENT_ENVIRONMENT (local/server) and APP_ENVIRONMENT (dev/production) — are independent and easy to confuse: the first drives CORS/JWT/middleware behavior, the second only gates db.create_all() in the python main.py dev path.

Minimal local .env

The shipped .env.example boots a fully local dev stack (SITL enabled, insecure defaults). The smallest set that gets the gateway running against a local Postgres:
.env
DEPLOYMENT_ENVIRONMENT=local
JWT_SECRET_KEY=<redacted>            # optional locally; falls back to a test key
DB_USERNAME=idrobots
DB_PASSWORD=idrobots
DB_IP=skyhub-postgres
DB_NAME=skyhub
REGION=eu-central-1
SOCKET_IP=0.0.0.0
ENABLE_SITL=true
For production parity, cross-reference Production Configuration and the platform-wide Environment Variables reference.

Startup, Validation & Composition Root

How these variables are validated and consumed during boot — fail-fast guards, init order, and dev-mode schema creation.