Skip to content

Deployment: Docker + nginx + Let's Encrypt

The recommended production path. One script, three modes.

TL;DR

git clone https://github.com/doable-me/doable.git
cd doable

# Public domain with auto-SSL:
DOMAIN=app.example.com EMAIL=you@example.com sudo ./docker/setup.sh

# Private network — server's LAN IP, self-signed SSL:
HOST=192.168.1.50 sudo ./docker/setup.sh

# Localhost only:
sudo ./docker/setup.sh

# Behind another TLS proxy (Cloudflare, ingress controller, …):
DOMAIN=app.example.com sudo ./docker/setup.sh --skip-ssl

What gets installed

Component Where
Docker images: postgres, api, ws, web, (redis) Containers — see docker/docker-compose.yml
nginx Host (apt) — config in /etc/nginx/sites-enabled/doable
certbot Host (apt) — only when domain mode + Let's Encrypt
UFW Host — opens 22, 80, 443 only

What docker/setup.sh does (step by step)

  1. Pre-flight — checks Docker + Compose v2 are installed.
  2. Mode detection — domain → Let's Encrypt; HOST/localhost → self-signed.
  3. Generates docker/.env with random JWT_SECRET, ENCRYPTION_KEY, INTERNAL_SECRET, and the right NEXT_PUBLIC_* URLs.
  4. Installs nginx + certbot if not present.
  5. Issues SSL cert — Let's Encrypt for domains, OpenSSL self-signed otherwise.
  6. Renders nginx.conf.template with your hostname → writes to /etc/nginx/sites-enabled/doable.
  7. docker compose up --build -d — pulls/builds images, starts everything.
  8. docker compose run --rm migrate — applies DB migrations.
  9. Configures UFW — deny incoming, allow 22/80/443.

nginx routing

The template at docker/nginx.conf.template maps:

Path Backend
/api/* 127.0.0.1:4000 (api)
/auth/* 127.0.0.1:4000 (api)
/ws (Upgrade: websocket) 127.0.0.1:4001 (ws)
/preview/* 127.0.0.1:4000 (api → reverse-proxies to per-project Vite)
everything else 127.0.0.1:3000 (web / Next.js)

Edit the template and re-run setup.sh to regenerate the live config.

After install

# Containers status
docker compose -f docker/docker-compose.yml ps

# Tail all logs
docker compose -f docker/docker-compose.yml logs -f

# Reload nginx after editing the conf
sudo nginx -t && sudo systemctl reload nginx

Adding/changing AI keys

Edit docker/.env, then:

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

Renewing SSL

certbot installs a systemd timer that renews automatically. Force a renewal:

sudo certbot renew --force-renewal
sudo systemctl reload nginx

Updating Doable

cd doable
git pull
docker compose -f docker/docker-compose.yml up --build -d
docker compose -f docker/docker-compose.yml run --rm migrate

Behind Cloudflare proxy (orange cloud)

Use --skip-ssl. nginx serves on port 443 with a self-signed cert; Cloudflare's "Full (strict)" mode validates against the CA — switch Cloudflare to "Full" (non-strict) instead, or upload the cert to Cloudflare → Origin Certificates.

In the Cloudflare dashboard, set:

  • SSL/TLS mode: Full
  • Always Use HTTPS: On
  • WebSockets: On (under Network)

Behind a Kubernetes ingress controller

You typically run Doable in Kubernetes by not using the host nginx at all. Instead, scale the three services as Deployments and put your ingress (e.g. nginx-ingress, Traefik) in front. The bundled docker/setup.sh is host-oriented; for k8s, use the Compose file as a reference and write your own manifests / Helm chart.

Troubleshooting

See Troubleshooting. Highlights:

  • Set JWT_SECRET … on container startdocker/.env is missing required secrets. Re-run setup.sh or paste them in by hand.
  • Browser shows nginx default pagesetup.sh couldn't replace the default site. sudo rm /etc/nginx/sites-enabled/default && sudo systemctl reload nginx.
  • 502 Bad Gateway — the API is still booting after up; wait 30s. Check docker logs doable-api.