Skip to content

Deployment: Bare-metal + Caddy + Cloudflare Tunnel

The non-Docker production path. Powered by setup-server.sh.

When to choose this

  • You want zero open ports on the public internet (Cloudflare Tunnel does it all).
  • You don't want to deal with Docker.
  • You want hot-reload on git pull without rebuilding images (services run via tsx watch).

TL;DR

ssh root@your-server
git clone https://github.com/doable-me/doable.git
cd doable
sudo ./setup-server.sh

You'll be prompted for:

  • Your domain (e.g. app.example.com).
  • API and WS subdomains (default api. and ws.).
  • DB password.
  • Optional: GitHub/Google OAuth, Anthropic/OpenAI keys, Stripe keys.

What it sets up

Component Purpose
Node.js 22 + pnpm Runtime
PostgreSQL 16 + pgvector + pgcrypto + pg_trgm Database
Caddy Reverse proxy with automatic Let's Encrypt SSL
cloudflared Cloudflare Tunnel (no inbound ports)
tmux Session for api, web, ws windows
systemd unit doable.service Auto-start on boot
systemd unit cloudflared.service Tunnel auto-start
UFW Deny-all + allow SSH
fail2ban SSH brute-force protection
Swap 2 GB swap file (helps small VPSes)
Chromium deps For Puppeteer thumbnails

Tunnel vs direct serving

setup-server.sh configures both:

  • Caddy binds to 127.0.0.1 and reverse-proxies to the three services.
  • Cloudflare Tunnel routes app.example.com, api.example.com, ws.example.com to Caddy on 127.0.0.1.

Result: nothing the public can reach over TCP. The only inbound traffic is the outbound tunnel connection to Cloudflare's edge.

If you don't want Cloudflare in the loop:

  • Skip the tunnel-login prompt during install (just press Ctrl-C at that step).
  • Edit /etc/caddy/Caddyfile to bind 0.0.0.0 instead of 127.0.0.1.
  • Open ports 80/443 in UFW: sudo ufw allow 80,443/tcp.

Caddy will then serve the public directly with auto-Let's Encrypt — equivalent to the Docker+nginx path.

Day-2 operations

Watch the services

tmux attach -t doable
# Ctrl-b 0/1/2  switch between api, web, ws
# Ctrl-b d      detach

Restart everything

sudo systemctl restart doable

The systemd unit kills the tmux session and starts a fresh one with all three services.

Update Doable

cd /root/doable
git pull
pnpm install
pnpm db:migrate
sudo systemctl restart doable

Production builds (optional)

By default the bare-metal install runs services in dev mode (tsx watch for API/WS, Next dev for web) — fast hot-reload on git pull. For lower memory:

cd /root/doable
pnpm build --filter=@doable/web
# Then edit /etc/systemd/system/doable.service to use `pnpm start:web` etc., and:
sudo systemctl daemon-reload && sudo systemctl restart doable

View logs

journalctl -u doable -f
journalctl -u cloudflared -f
sudo tail -f /var/log/caddy/access.log

Rotating secrets

nano /root/doable/.env       # edit JWT_SECRET / ENCRYPTION_KEY
sudo systemctl restart doable

Note: rotating JWT_SECRET invalidates all sessions. Rotating ENCRYPTION_KEY invalidates all stored OAuth/integration tokens — users will need to reconnect.

Adding the Doable platform itself behind a private network

Skip Cloudflare Tunnel and use Tailscale, Headscale, or WireGuard instead. The setup is the same; just point your VPN's DNS at the server and access it on port 443.

Troubleshooting

  • PostgreSQL connection refusedsetup-server.sh writes the password to /root/doable/.env. Check it matches what psql -U doable -d doable accepts.
  • Cloudflare Tunnel failingcloudflared tunnel info <id> shows status. Re-login with cloudflared tunnel login if the cert expired.
  • Caddy not auto-issuing SSL — Caddy needs DNS to resolve to the server or the Cloudflare Tunnel route to be active. Check /var/log/caddy/access.log.
  • tmux: command not found when systemd starts** — confirm apt install -y tmux succeeded; the unit file's ExecStart requires the absolute path /usr/bin/tmux.