Deployment: Docker + Caddy (and the nginx-http alternate)¶
The recommended production path. One script, three modes, the same image set on every OS.
A caddy container in the compose stack terminates TLS and routes paths to api/ws/web on the docker network. There is no host-side nginx or certbot to install. The same flow runs on Linux, macOS, and Windows.
Operators who already terminate TLS upstream (Cloudflare Tunnel, an ingress controller, an existing nginx, a hardware load balancer) can either keep Caddy in the stack with --skip-ssl (origin self-signed, 127.0.0.1 binding) or render the bundled HTTP-only template at deployment/docker/nginx-http.conf.template.
TL;DR¶
git clone https://github.com/doable-me/doable.git
cd doable
# Public domain with auto-SSL via Caddy + Let's Encrypt:
DOMAIN=app.example.com EMAIL=[email protected] ./deployment/docker/setup.sh
# Private network, server's LAN IP, mkcert-signed self-signed SSL:
HOST=192.168.1.50 ./deployment/docker/setup.sh --install-trust
# Localhost only (cert auto-trusted into host store):
./deployment/docker/setup.sh
# Behind another TLS proxy (Cloudflare Tunnel, ingress, ...):
DOMAIN=app.example.com ./deployment/docker/setup.sh --skip-ssl
Native Windows (no WSL, no Git Bash):
.\deployment\docker\setup.ps1 -Domain app.example.com -Email you@example.com
.\deployment\docker\setup.ps1 -DoableHost 192.168.1.50 -InstallTrust
.\deployment\docker\setup.ps1
.\deployment\docker\setup.ps1 -Domain app.example.com -SkipSsl
What gets installed¶
| Component | Where |
|---|---|
| Docker images: postgres, api, ws, web, caddy | Containers; see deployment/docker/docker-compose.yml |
| Caddy | In the compose stack as the caddy service; config mounted from deployment/docker/Caddyfile |
| mkcert local CA (localhost or HOST mode with trust install) | Host OS + browser trust stores. Cert lands at deployment/docker/certs/{cert,key}.pem and is mounted read-only into Caddy |
| Let's Encrypt cert (domain mode) | Inside the caddy_data volume (Caddy's ACME state). Auto-renewed by Caddy |
No host-side nginx. No certbot systemd timer. No apt-install of TLS tooling.
What deployment/docker/setup.sh does (step by step)¶
- Pre-flight: detects OS family, verifies Docker + Compose v2 are installed (auto-installs on Debian/Ubuntu when missing), runs a disk-space pre-check (25 GB for source build, 5 GB for
--prebuilt). - Mode detection:
DOMAIN=means Let's Encrypt via Caddy ACME;HOST=or no input means self-signed (mkcert when trust install is requested, Caddy internal otherwise). - Generates
deployment/docker/.env(mode 600 / owner-only ACL on Windows) with randomJWT_SECRET,ENCRYPTION_KEY,INTERNAL_SECRET,DOABLE_KEK,POSTGRES_PASSWORD,DOABLE_APP_PASSWORD,INSTALL_BOOTSTRAP_TOKEN, and the rightNEXT_PUBLIC_*,CORS_ORIGINS,WS_ALLOWED_ORIGINSURLs. - Wires Caddy: writes
DOABLE_SITE,DOABLE_TLS,DOABLE_BIND_ADDRinto.env. Stops any pre-existing host-side nginx / caddy / apache2 / lighttpd so the Caddy container owns ports 80/443. - Issues SSL cert (only in localhost / HOST mode with trust): downloads mkcert if missing, installs its local CA into the host trust store, generates a leaf cert in
deployment/docker/certs/. Domain mode lets Caddy fetch from Let's Encrypt at first boot. docker compose up -dbuilds or pulls images and starts everything, including the one-shotmigratecontainer.- Migrate watchdog: waits up to 60s for the
migratecontainer to exit cleanly. Fails loudly with the recoverydocker compose down -vcommand when a stalepostgres_datavolume mismatches the freshly generated password.
Caddy routing¶
The Caddyfile at deployment/docker/Caddyfile maps:
| Path | Backend |
|---|---|
/api/otlp/* |
web:3000 (Next.js OTLP forwarder) |
/api/* (after handle_path strips /api/) |
api:4000 |
/auth/* |
api:4000 |
/oauth/* |
api:4000 (covers /oauth/github/login, /copilot, /repo callbacks) |
/preview/* (24h read/write timeouts for SSE + HMR) |
api:4000 |
/ws, /socket, /ws/*, /socket/* (Upgrade: websocket) |
ws:4001 |
| everything else | web:3000 |
Edit the Caddyfile, then docker compose -f deployment/docker/docker-compose.yml restart caddy.
After install¶
# Container status
docker compose -f deployment/docker/docker-compose.yml ps
# Tail all logs
docker compose -f deployment/docker/docker-compose.yml logs -f
# Just Caddy
docker logs -f doable-caddy
Adding or changing AI keys¶
Edit deployment/docker/.env, then:
Day-to-day, prefer the in-app setup wizard at /setup. Wizard-saved keys are envelope-encrypted with DOABLE_KEK and don't require a container restart.
Renewing SSL¶
Caddy renews Let's Encrypt certs automatically; renewal state lives in the caddy_data volume. To force a renewal:
For mkcert-signed certs (localhost / HOST mode), re-run setup.sh or setup.ps1 to reissue.
Updating Doable¶
cd doable
git pull
docker compose -f deployment/docker/docker-compose.yml up --build -d
docker compose -f deployment/docker/docker-compose.yml run --rm migrate
Behind Cloudflare Tunnel (or any upstream TLS proxy)¶
Use --skip-ssl (bash) or -SkipSsl (PowerShell). Effect:
- Caddy binds
127.0.0.1only (the tunnel becomes the sole public ingress). - Caddy uses
tls=internalfor the origin-to-tunnel hop. Cloudflare Tunnel does not verify the origin cert. - Same Caddyfile routing: paths still terminate inside Caddy before reaching api/ws/web.
In the Cloudflare dashboard, set:
- SSL/TLS mode: Full.
- Always Use HTTPS: On.
- WebSockets: On (under Network).
If you prefer to skip Caddy entirely and have your tunnel speak plain HTTP to the origin, render deployment/docker/nginx-http.conf.template on the host (__HOST__ substitution), drop it into /etc/nginx/sites-enabled/doable, and stop the Caddy container. The template only routes ports 80 and matches the same path scheme.
Behind a Kubernetes ingress controller¶
Don't run the Caddy container in k8s. Scale postgres, api, ws, web as Deployments (or use the bundled Helm chart in deployment/platforms/k8s/) and put your ingress (nginx-ingress, Traefik, Contour) in front. The bundled setup.sh is host-oriented; for k8s, use the compose file as a reference.
Troubleshooting¶
See Troubleshooting. Highlights:
Set JWT_SECRET ...on container start:deployment/docker/.envis missing required secrets. Re-runsetup.sh/setup.ps1, or paste them in by hand. The compose file uses${VAR:?required}so any missing secret aborts immediately.- Stale postgres_data password mismatch: the migrate container exits non-zero. Recover with
docker compose -f deployment/docker/docker-compose.yml --env-file deployment/docker/.env down -v && ./deployment/docker/setup.sh. 502 Bad Gateway: the API is still booting afterup. The Caddyfile usesdepends_on: service_healthyso this should be rare, but the migrate watchdog window can race on slow hosts. Wait 30s. Checkdocker logs doable-api.- Browser certificate warning on localhost: trust install was skipped. Re-run with
--install-trust(bash) or-InstallTrust(PowerShell), or importdeployment/docker/certs/cert.peminto your OS trust store by hand.