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 pullwithout rebuilding images (services run viatsx 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.andws.). - 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.1and reverse-proxies to the three services. - Cloudflare Tunnel routes
app.example.com,api.example.com,ws.example.comto Caddy on127.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/Caddyfileto bind0.0.0.0instead of127.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¶
Restart everything¶
The systemd unit kills the tmux session and starts a fresh one with all three services.
Update 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¶
Rotating secrets¶
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 refused —
setup-server.shwrites the password to/root/doable/.env. Check it matches whatpsql -U doable -d doableaccepts. - Cloudflare Tunnel failing —
cloudflared tunnel info <id>shows status. Re-login withcloudflared tunnel loginif 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 foundwhen systemd starts** — confirmapt install -y tmuxsucceeded; the unit file'sExecStartrequires the absolute path/usr/bin/tmux.