Docker Setup¶
Doable's Docker setup uses Compose v2 with a multi-stage Dockerfile. Everything you need is in deployment/docker/.
A single caddy container terminates TLS and reverse-proxies to api/ws/web on the docker network. There is no host-side nginx, certbot, brew, or systemctl dance. The same compose stack runs identically on Linux, macOS, and Windows (Docker Desktop or WSL2).
Three deployment modes (one script)¶
deployment/docker/setup.sh (or setup.ps1 on native Windows) handles secrets, certs, builds, and Caddy wiring in one shot. Pick a mode:
| Mode | Command | Use when |
|---|---|---|
| Localhost | ./deployment/docker/setup.sh |
Trying it out on your laptop |
| Private network / LAN | HOST=192.168.1.50 ./deployment/docker/setup.sh |
Air-gapped server, Tailscale, home lab |
| Public domain + Let's Encrypt | DOMAIN=app.example.com ./deployment/docker/setup.sh |
Real production |
| Public domain, behind another proxy | DOMAIN=app.example.com ./deployment/docker/setup.sh --skip-ssl |
Cloudflare Tunnel, ingress controller, etc. |
Native Windows users use setup.ps1 instead. PowerShell 5.1 (built into Windows 10/11) is enough, no WSL required:
.\deployment\docker\setup.ps1
.\deployment\docker\setup.ps1 -DoableHost 192.168.1.50 -InstallTrust
.\deployment\docker\setup.ps1 -Domain app.example.com -Email you@example.com
.\deployment\docker\setup.ps1 -Domain app.example.com -SkipSsl
See Windows Quick Start for the native-PowerShell walkthrough.
What the script does in each mode:
- Generates
deployment/docker/.envwith randomJWT_SECRET,ENCRYPTION_KEY,INTERNAL_SECRET,DOABLE_KEK,POSTGRES_PASSWORD,DOABLE_APP_PASSWORD, andINSTALL_BOOTSTRAP_TOKEN. - Sets
NEXT_PUBLIC_API_URL,NEXT_PUBLIC_WS_URL,NEXT_PUBLIC_APP_URL,CORS_ORIGINS, andWS_ALLOWED_ORIGINSto match your domain/IP. - Writes
DOABLE_SITE,DOABLE_TLS, andDOABLE_BIND_ADDRso the in-stack Caddy picks up the right hostname, cert source, and host port binding. - In localhost or HOST mode (when
--install-trustor-InstallTrustis requested), downloads mkcert, installs its local CA into the host OS + browser trust stores, then issues a leaf cert atdeployment/docker/certs/cert.pem. Caddy mounts the cert read-only and serves it onhttps://${LISTEN_HOST}. - In domain mode, Caddy auto-fetches a Let's Encrypt cert via the ACME HTTP-01 challenge on port 80.
- Builds (or pulls, with
--prebuilt) and starts every container, then waits for the one-shotmigratecontainer to exit cleanly before reporting success.
Compose services¶
Defined in deployment/docker/docker-compose.yml:
| Service | Image | Host port | Notes |
|---|---|---|---|
postgres |
pgvector/pgvector:pg16 |
not published | Reached as postgres:5432 on the docker network |
migrate |
built locally, target migrate |
none | Runs once, exits 0 (DDL with owner role) |
api |
built locally, target api |
not published | Hono REST API on api:4000, fronted by Caddy at /api/* |
ws |
built locally, target ws |
not published | WebSocket / Yjs on ws:4001, fronted by Caddy at /ws |
web |
built locally, target web |
not published | Next.js on web:3000, fronted by Caddy at / |
caddy |
caddy:2-alpine |
${DOABLE_BIND_ADDR}:80, ${DOABLE_BIND_ADDR}:443 |
TLS terminator and path router |
DOABLE_BIND_ADDR defaults to 127.0.0.1 for localhost / HOST / behind-proxy modes. In direct-public domain mode it flips to 0.0.0.0 so Let's Encrypt's HTTP-01 challenge can reach port 80 from the internet. The api/ws/web containers themselves never publish ports; everything browser-facing is routed through Caddy.
Caddy routing¶
The Caddyfile at deployment/docker/Caddyfile maps:
| Path | Backend |
|---|---|
/api/otlp/* |
web:3000 (Next.js proxies browser OTLP traces to api's internal receiver) |
/api/* (after stripping /api/) |
api:4000 |
/auth/*, /oauth/* |
api:4000 |
/preview/* (long timeouts for SSE/HMR) |
api:4000 |
/ws, /socket (Upgrade: websocket) |
ws:4001 |
| everything else | web:3000 |
Edit the Caddyfile, then restart the caddy container to pick it up:
HTTP-only behind-tls-proxy mode¶
If you terminate TLS upstream (Cloudflare Tunnel, an ingress controller, an upstream nginx, hardware load balancer), Caddy still runs in the stack but uses tls=internal so the origin-to-tunnel hop is self-signed. Caddy also binds 127.0.0.1 only so the tunnel is the only public ingress.
Trigger this mode with --skip-ssl (bash) or -SkipSsl (PowerShell), or set DOABLE_BEHIND_PROXY=1. There is also a standalone deployment/docker/nginx-http.conf.template you can render on the host if you prefer plain HTTP behind your TLS proxy, with the same path routing as the Caddyfile.
Manual Compose usage¶
If you'd rather run Compose directly without setup.sh, generate secrets by hand, then start the stack:
cp deployment/docker/.env.example deployment/docker/.env
# Edit deployment/docker/.env to set:
# JWT_SECRET, ENCRYPTION_KEY, INTERNAL_SECRET, DOABLE_KEK (openssl rand -hex 32 / -base64 32)
# POSTGRES_PASSWORD, DOABLE_APP_PASSWORD (openssl rand -hex 16)
# NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WS_URL, NEXT_PUBLIC_APP_URL (https://your-host/api etc.)
# CORS_ORIGINS, WS_ALLOWED_ORIGINS (https://your-host)
# DOABLE_SITE, DOABLE_TLS, DOABLE_BIND_ADDR (Caddy wiring)
docker compose -f deployment/docker/docker-compose.yml \
--env-file deployment/docker/.env up --build
The compose file uses ${VAR:?required} syntax for every secret, so any missing or empty value aborts immediately rather than silently defaulting to a weak placeholder.
Pre-built images (--prebuilt / -Prebuilt)¶
Source builds take 5-10 minutes and peak around 22 GB of intermediate layers. Pre-built images from ghcr.io/doable-me/doable-* install in roughly 30 seconds and only need ~5 GB of free disk:
Pin a specific tag with DOABLE_IMAGE_TAG=v1.2.3. If the ghcr.io registry returns "denied / unauthorized / not found / private", the script automatically falls back to a source build.
Image structure (deployment/docker/Dockerfile)¶
The Dockerfile is multi-stage. Targets:
base: Node 22 alpine + pnpm.deps: installs prod + dev deps for the workspace.build: runspnpm buildfor all packages.migrate: minimal image that runspnpm db:migrateand exits.api,ws,web: slim runtime images, one per service. Each runs as an unprivileged user.
This keeps individual service images small (~150-300 MB) while sharing all build steps.
Volumes¶
| Volume | Mounted in | Purpose |
|---|---|---|
postgres_data |
postgres |
Database files |
api_projects |
api |
User projects (filesystem-backed) |
api_thumbnails |
api |
Generated preview screenshots |
ws_projects |
ws |
Shared with API for collab access |
caddy_data |
caddy |
ACME state (Let's Encrypt account key, cert renewals) |
caddy_config |
caddy |
Caddy runtime config snapshot |
Back these up; see Backups.
Common operations¶
# Tail logs
docker compose -f deployment/docker/docker-compose.yml logs -f
docker compose -f deployment/docker/docker-compose.yml logs -f api
# Restart a service
docker compose -f deployment/docker/docker-compose.yml restart api
# Run migrations on demand
docker compose -f deployment/docker/docker-compose.yml run --rm migrate
# Open psql against the running database
docker compose -f deployment/docker/docker-compose.yml exec postgres \
psql -U doable -d doable
# Stop everything (data preserved)
docker compose -f deployment/docker/docker-compose.yml down
# Stop and DELETE all data
docker compose -f deployment/docker/docker-compose.yml down -v
Updating¶
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
Why Caddy in the compose stack?¶
- One TLS termination point, same image on every OS.
- Automatic Let's Encrypt in domain mode, mkcert-signed local CA in localhost/HOST mode, internal self-signed when behind another proxy. No certbot, no brew, no apt for nginx.
- Routes
/,/api/,/auth/,/oauth/,/preview/,/wsto the right backend over the docker internal network. None of the api/ws/web containers publish a host port, so a misconfigured firewall can't expose them. - Caddy runs with
cap_drop: [ALL]plus onlyNET_BIND_SERVICEto bind ports 80/443, a minimal capability surface.
The Caddyfile is at deployment/docker/Caddyfile. Edit it and docker compose restart caddy to pick up changes.
Prefer non-Docker? See Script-based Setup. Want to install everything manually? See Manual Setup.