Docker Setup (Full Stack)
Runs the whole SAPOT stack — server (db, redis, api, certgen, nginx), admin (admin dashboard), tileserver, and gsm-fastapi (SMS gateway) — via the root docker-compose.yml. Alternative to setting up each component individually by hand: server-setup.md (bare-metal server), admin-frontend setup, tileserver setup, gsm-module setup. All commands below run from the repo root, not server/.
This is the developer stack. For a disconnected production site, build and use an offline Docker bundle.
Prerequisites
- Docker + Docker Compose v2
Running under WSL
Everything below works from a WSL2 distro's bash shell as-is — use docker/up.sh (not up.ps1, which is for plain PowerShell). Set up in this order:
- Install Docker Engine inside the distro — use the official Docker Engine install steps for your distro (e.g. Ubuntu), not Docker Desktop. A fresh WSL install has no
dockercommand otherwise. - Start the Docker daemon —
sudo service docker start(or enable it via systemd if your distro has it enabled:sudo systemctl enable --now docker). - Confirm
python3is installed in the distro — the steps above assume it's already present. - Before connecting a mobile client or another machine on the LAN, check WSL2's networking mode —
docker/up.shauto-detects a LAN IP for the dev TLS cert's SAN, but under WSL2's default NAT networking it detects the WSL2 virtual adapter's IP, not the Windows host's real LAN IP.curl https://localhost/versionfrom WSL or Windows still works either way, but a phone running the mobile app — or any other machine on the same network — won't be able to reach the server at that IP unless you enable WSL2 mirrored networking (Windows 11):- In the Windows user folder (
%UserProfile%, i.e.C:\Users\<you>\), create a file named.wslconfig— it usually doesn't exist yet. - Add this to it:
[wsl2]networkingMode=mirrored
- From a normal (non-admin) PowerShell, run
wsl --shutdown, then restart your distro and Docker.
- In the Windows user folder (
- Allow the ports through Windows Firewall — a separate requirement from step 4 and easy to miss. Even once mirrored networking or a
portproxyrule gets a packet to the WSL2 interface, Windows Firewall still blocks unsolicited inbound traffic by default on every profile (Get-NetFirewallProfiletypically showsDefaultInboundAction: NotConfigured, which resolves to block). Symptom if this is the missing piece: the connection just times out — no TLS error, no "connection refused" — both from another machine on the LAN and from the docker host itself when hitting its own real LAN IP (https://192.168.x.x/...) instead oflocalhost. Fix, from an elevated PowerShell (Run as Administrator) on the Windows host:MatchNew-NetFirewallRule -DisplayName "SAPOT dev server (HTTPS 443)" -Direction Inbound -Protocol TCP -LocalPort 443 -Profile Private -Action AllowNew-NetFirewallRule -DisplayName "SAPOT dev server (HTTP 80)" -Direction Inbound -Protocol TCP -LocalPort 80 -Profile Private -Action Allow-Profileto your active network category —Get-NetConnectionProfileshows it (usuallyPrivatefor a home/trusted network).
Configure
cp server/.env.example server/.env
Before starting the stack, replace the JWT_SECRET_KEY and SERVER_ED25519_SEED placeholders in
server/.env. Generate a separate value for each with openssl rand -hex 32; also replace the
other change-me-* secrets with values appropriate for your environment.
Optional — override the stack's host-side ports (default: nginx 443/80, admin 3000,
gsm-fastapi 8001) by copying the repo-root env file too:
cp .env.example .env
Only needed if you want to change a port (e.g. running a second stack concurrently — see Running from a git worktree below). Skip it and the defaults apply.
To also bring up the admin dashboard and SMS gateway, configure their env files too:
cp admin-frontend/sapot-admin/.env.example admin-frontend/sapot-admin/.env
cp GSM-module/GSM-fastapi/.env.example GSM-module/GSM-fastapi/.env
gsm-fastapi's GSM_SECRET must match server/.env's GSM_SECRET — they authenticate the
webhook calls between the two services (see environment-config.md).
Compose sets the server's GSM_GATEWAY_URL to http://gsm-fastapi:8001, which resolves through the
internal Docker network. Do not replace it with localhost: inside the api container, that address
refers to the API container rather than the separate GSM gateway container.
The gsm-fastapi container passes through the GSM modem at /dev/ttyACM0, but only when
docker-compose.gsm-hardware.yml is explicitly merged in (Compose has no "optional device" syntax,
so this stays out of the base docker-compose.yml/docker-compose.override.yml — otherwise the
whole docker compose up would abort on any machine without the GSM modem attached, leaving nginx/
admin stuck in Created). On a machine with the GSM modem attached:
./docker/up.sh -f docker-compose.yml -f docker-compose.gsm-hardware.yml up --build -d
Without the GSM modem, just run the normal ./docker/up.sh up --build -d below — gsm-fastapi still
starts, it just won't have serial access.
To exercise outbound SMS flow in Docker without hardware, merge the PTY emulator overlay instead.
It starts the emulator in the gsm-fastapi container, so its generated device path is visible to the
gateway process. Do not set SERIAL_PORT to a host /dev/pts/<n> path: containers have separate PTY
namespaces.
./docker/up.sh -f docker-compose.yml -f docker-compose.gsm-emulator.yml up --build -d
docker compose logs -f gsm-fastapi
The gateway logs the generated port and becomes ready after the emulator handshake. The emulator
prints each valid outbound destination and body in the same service logs. Do not merge the emulator
overlay with docker-compose.gsm-hardware.yml; use the hardware overlay for real modem testing.
See the repo-root SECURITY.md for why DATABASE_URL, JWT_SECRET_KEY, CORS_ALLOWED_ORIGINS, and SERVER_ED25519_SEED are required at import time. server/.env.example supplies safe defaults only for local service addresses; it never supplies usable secrets.
Run
./docker/up.sh up --build -d
(docker/up.sh wraps docker compose, auto-detecting this machine's LAN IP for the dev TLS cert's SAN — use it instead of calling docker compose directly. Windows: docker/up.ps1.)
This brings up every service in docker-compose.yml. Besides db/redis/api/certgen/nginx,
that includes:
admin: the Next.js admin dashboard. Itsnext.config.tssetsbasePath: "/admin", so the dashboard lives athttp://localhost:3000/admin(published port) orhttps://localhost/admin(throughnginx), not at the bare/, which 404s.tileserver: offline map tiles. Not published to the host:docker-compose.ymlonlyexposes port 8080 on the internal network, so reach it athttps://localhost/tiles/throughnginx.gsm-fastapi: the SMS gateway,http://localhost:8001on host loopback only (starts without the GSM modem; adddocker-compose.gsm-hardware.ymlper the Configure section above for real SMS)
nginx declares depends_on on admin and tileserver (both proxied by nginx.docker.conf as
static upstreams, which nginx resolves at config-load time and refuses to start without). Naming
nginx therefore always pulls those two in. gsm-fastapi is the only service you can leave out:
./docker/up.sh up --build -d db redis api certgen nginx
Apply database migrations
Required on a fresh db-data volume. The stack does not do this for you. The schema is owned
by Alembic (ADR 0007); the app no longer calls
create_all() at startup, and the api image's CMD is a bare gunicorn with no migration step.
Skip this and api starts fine but every request that touches the database fails on a missing
table.
docker compose exec api alembic upgrade head
Run it again after any pull that adds a migration. See migrations.md for the full workflow, and its one-time cutover section if you are pointing the stack at a database that predates Alembic.
Verify
docker compose ps
Expect db/redis healthy, certgen exited (0), and api/nginx/admin/tileserver/gsm-fastapi running. Only db and redis declare healthchecks in the dev stack. api's /version probe lives in docker-compose.ci.yml and is not loaded here, so api shows no health status and nginx waits only for its process to start (condition: service_started). A slow first boot can therefore still race nginx into serving 502s for a few seconds; the Troubleshooting entry below covers telling that apart from a real failure.
docker compose logs -f api
Look for Uvicorn's "Application startup complete" with no traceback.
curl -sk https://localhost/version
Expect a JSON version payload. A connection/TLS error means nginx/certgen isn't ready; a 502 Bad Gateway means nginx is up but couldn't reach api. Check the api logs above, not nginx's own logs, to find the actual cause.
Troubleshooting
nginx returns 502 Bad Gateway. This means nginx itself is fine — TLS terminated, the request was parsed — but its proxy_pass to api:8000 (Docker's internal bridge network, unrelated to any LAN/cert IP) got nothing back. Always check api first, not nginx:
docker compose ps # is api running, exited, or restarting?
docker compose logs api --tail=50 # look for a traceback right before "Application startup failed"
apiis running and the 502s stop on their own within a few seconds ofup -d: expected. The dev stack givesapino healthcheck, songinxstarts as soon as theapiprocess does, which is before Uvicorn finishes importing the app. Just retry.apiis running and the 502s persist: read the logs. An unset required env var (DATABASE_URL,JWT_SECRET_KEY,CORS_ALLOWED_ORIGINS,SERVER_ED25519_SEED, orQA_API_TOKENwhenENVIRONMENT=developmentorstaging) raises at import time and the container exits before serving anything. An unrecognisedENVIRONMENTvalue also stops the app at import time.
Requests reach api but fail on missing tables (1146 Table ... doesn't exist). Migrations were never applied to this db-data volume. Run Apply database migrations.
Another machine on the LAN can't reach https://<host-LAN-IP>/... at all (times out, not a TLS or refused error). Two independent requirements, both needed:
- WSL2 networking mode — see step 4 under Running under WSL. Confirms the packet reaches the WSL2 network namespace at all.
- Windows Firewall inbound rule — see step 5 under the same section. Even with (1) solved, Windows blocks unsolicited inbound by default; this is the one that's easy to miss because the WSL2-networking docs don't mention it. Diagnostic: if even the docker host itself can't reach its own real LAN IP (as opposed to
localhost), that's this, not (1).
https://0.0.0.0/... doesn't work. Expected — 0.0.0.0 is a wildcard bind address (Docker publishes nginx's port on every host interface), not a real address a client can connect to. Use https://localhost/... from the host or https://<host-LAN-IP>/... from any machine on the LAN.
Port 80/443 already allocated — nginx (and anything depending on it, like admin) never starts. If you ran the old server/docker-compose.yml stack (from before it moved to the repo root) and never tore it down, it's still running under the Compose project name server, holding those ports:
docker ps -a --filter "name=server-" # confirms the old stack is still up
docker compose -p server down # stops and removes it
The new stack runs under a different project name (derived from the repo root directory), so Docker treats them as two independent stacks that happen to fight over the same host ports.
admin (or any other service) stays stuck in Created and never actually starts. docker compose up (no service names) starts every service in dependency order; if one fails partway, services later in the batch can be left created but never started. The most common cause used to be gsm-fastapi's /dev/ttyACM0 device passthrough failing on machines without the GSM modem — that's no longer in the base docker-compose.yml (see Configure), so this should only recur if you merged in docker-compose.gsm-hardware.yml without the GSM modem actually attached. Either way, bring up the specific services you need directly instead of relying on the full batch:
docker compose up -d db redis api certgen nginx # pulls in admin + tileserver, skips gsm-fastapi
gsm-fastapi logs Cannot open /dev/ttyACM0: No such file or directory. The Arduino may be connected to the host, but the running container was created without the hardware overlay, so Docker did not expose the serial device inside it. Confirm the host sees the device, then recreate only the gateway with the overlay:
ls -l /dev/ttyACM* /dev/ttyUSB*
./docker/up.sh -f docker-compose.yml -f docker-compose.gsm-hardware.yml up -d --force-recreate gsm-fastapi
If the first command reports a port other than /dev/ttyACM0, update SERIAL_PORT in
GSM-module/GSM-fastapi/.env. The docker/up.sh wrapper reads that file when the hardware overlay is
selected, so Compose maps the same device path into the gateway container.
This overlay accepts host serial devices such as /dev/ttyACM0 and /dev/ttyUSB0. It cannot pass a
host /dev/pts/<n> pseudo-terminal into Docker. Use docker-compose.gsm-emulator.yml for PTY testing.
https://localhost/admin works but http://localhost:3000 returns 404. Expected. The admin app sets basePath: "/admin" in next.config.ts, so its published port serves the dashboard at http://localhost:3000/admin, not at the root path.
nginx logs host not found in upstream "api" even though api is running. The nginx container was created against a stale image/config and never recreated (Compose reuses an existing container if it thinks nothing relevant changed). Force it:
docker compose up -d --force-recreate nginx admin
Running from a git worktree
Running docker/up.sh (or plain docker compose) from inside a git worktree checkout works
correctly and is isolated from the main checkout's stack, with one thing to configure if you want
both running at once:
- Isolation is automatic.
docker/up.shcds to its own script's directory before callingdocker compose, so every relative path indocker-compose.yml— build contexts, the./server/applive-reload bind mount,./docker/nginx.docker.conf, etc. — resolves inside that worktree, not the main checkout. Compose also derives the project name from the checkout's directory name, so a worktree gets its own containers, network, anddb-datavolume automatically — no shared state with the main checkout's stack. - Host ports are not automatically isolated. Two stacks (main checkout + a worktree, or two
worktrees) both bind
443/80/3000/8001on the host by default. GSM port 8001 binds only to loopback, while the other published services keep their configured interfaces. Bringing up a second stack while the first is still running fails with "port is already allocated". If you want them running concurrently, give the worktree its own.env(root-level, copied from.env.example) with different port values, e.g.:(NGINX_HTTPS_PORT=8443NGINX_HTTP_PORT=8080ADMIN_PORT=13000GSM_FASTAPI_PORT=18001.env.examplealso carriesTILESERVER_PORT, butdocker-compose.ymlno longer publishestileserverto the host, so setting it has no effect.) If you only ever run one stack at a time — the more common workflow, matching how you'd run bare-metal dev servers — you can skip this and leave every worktree's ports at their defaults. gsm-fastapihas no live bind mount — its code is baked into the image atdocker compose buildtime from that worktree's./GSM-module/GSM-fastapi. Editing GSM code in a worktree and runningupwithout rebuilding will still run whatever was baked in last. Rebuild after pulling or editing GSM code there:docker/up.sh up --build -d gsm-fastapi.- The GSM modem device (
/dev/ttyACM0) is physical hardware — it can't be attached to two containers at once, so don't rungsm-fastapifrom more than one stack simultaneously regardless of port configuration.
Next
- Mobile app setup to connect a client.
- Admin frontend setup to create the first administrator and sign in to the dashboard the
adminservice is already serving. - environment-config.md for the full server environment variable list.