Skip to content

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)

  1. 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).
  2. 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).
  3. Generates deployment/docker/.env (mode 600 / owner-only ACL on Windows) with random JWT_SECRET, ENCRYPTION_KEY, INTERNAL_SECRET, DOABLE_KEK, POSTGRES_PASSWORD, DOABLE_APP_PASSWORD, INSTALL_BOOTSTRAP_TOKEN, and the right NEXT_PUBLIC_*, CORS_ORIGINS, WS_ALLOWED_ORIGINS URLs.
  4. Wires Caddy: writes DOABLE_SITE, DOABLE_TLS, DOABLE_BIND_ADDR into .env. Stops any pre-existing host-side nginx / caddy / apache2 / lighttpd so the Caddy container owns ports 80/443.
  5. 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.
  6. docker compose up -d builds or pulls images and starts everything, including the one-shot migrate container.
  7. Migrate watchdog: waits up to 60s for the migrate container to exit cleanly. Fails loudly with the recovery docker compose down -v command when a stale postgres_data volume 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:

docker compose -f deployment/docker/docker-compose.yml restart api ws

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:

docker exec doable-caddy caddy reload --config /etc/caddy/Caddyfile

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.1 only (the tunnel becomes the sole public ingress).
  • Caddy uses tls=internal for 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/.env is missing required secrets. Re-run setup.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 after up. The Caddyfile uses depends_on: service_healthy so this should be rare, but the migrate watchdog window can race on slow hosts. Wait 30s. Check docker logs doable-api.
  • Browser certificate warning on localhost: trust install was skipped. Re-run with --install-trust (bash) or -InstallTrust (PowerShell), or import deployment/docker/certs/cert.pem into your OS trust store by hand.