Skip to content

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:

  1. Generates deployment/docker/.env with random JWT_SECRET, ENCRYPTION_KEY, INTERNAL_SECRET, DOABLE_KEK, POSTGRES_PASSWORD, DOABLE_APP_PASSWORD, and INSTALL_BOOTSTRAP_TOKEN.
  2. Sets NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WS_URL, NEXT_PUBLIC_APP_URL, CORS_ORIGINS, and WS_ALLOWED_ORIGINS to match your domain/IP.
  3. Writes DOABLE_SITE, DOABLE_TLS, and DOABLE_BIND_ADDR so the in-stack Caddy picks up the right hostname, cert source, and host port binding.
  4. In localhost or HOST mode (when --install-trust or -InstallTrust is requested), downloads mkcert, installs its local CA into the host OS + browser trust stores, then issues a leaf cert at deployment/docker/certs/cert.pem. Caddy mounts the cert read-only and serves it on https://${LISTEN_HOST}.
  5. In domain mode, Caddy auto-fetches a Let's Encrypt cert via the ACME HTTP-01 challenge on port 80.
  6. Builds (or pulls, with --prebuilt) and starts every container, then waits for the one-shot migrate container 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:

docker compose -f deployment/docker/docker-compose.yml restart caddy

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:

DOABLE_PREBUILT=true ./deployment/docker/setup.sh
# or
./deployment/docker/setup.sh --prebuilt
.\deployment\docker\setup.ps1 -Prebuilt

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: runs pnpm build for all packages.
  • migrate: minimal image that runs pnpm db:migrate and 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/, /ws to 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 only NET_BIND_SERVICE to 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.