Skip to content

Script-based Setup (deployment/server-setup.sh)

For a non-Docker, bare-metal deployment on a fresh Ubuntu (22.04 / 24.04) or Debian 12 host, Doable ships an automated installer: deployment/server-setup.sh.

This is the path that backs doable.me and every staging environment. It provisions the full hardened stack (Caddy, Cloudflare Tunnel, per-project sandbox UIDs, nftables egress firewall, UFW, fail2ban) end-to-end.

What it installs

On a clean Ubuntu 22.04 / 24.04 or Debian 12 server, run as root:

sudo ./deployment/server-setup.sh

It will install and configure:

Component Purpose
Node.js 22 Runtime
pnpm 9 Package manager
PostgreSQL 16 + pgvector, pgcrypto, pg_trgm extensions Database (listen on localhost only)
Caddy Reverse proxy with automatic Let's Encrypt SSL (or self-signed in NO_TUNNEL=1 mode)
cloudflared Cloudflare Tunnel: public access without opening ports
tmux Multiplexer that holds the api / web / ws processes (window per service)
fail2ban SSH brute-force protection (systemd backend)
UFW Firewall, deny-all incoming, allow SSH only (plus 80/443 when NO_TUNNEL=1)
Puppeteer / Chromium deps libnspr4, libnss3, libgbm1, libasound2(t64), fonts-liberation, and friends for thumbnail capture
Python 3 venv + pip + python${major.minor}-venv Required by FastAPI / Django publish adapters
bubblewrap + nftables + uidmap Per-project sandbox UIDs (10001-65000), namespace remap, egress firewall
Swap file 1-2 GB depending on RAM, so small VPSes survive build spikes
systemd unit doable.service Auto-start on boot, wraps the tmux session
systemd unit cloudflared.service Auto-start the tunnel

What it asks you for

The script is interactive (or fully non-interactive with NON_INTERACTIVE=1, CONTAINER_MODE=1, or a non-TTY stdin). It prompts for:

  • Domain (e.g. app.example.com): required. Multi-level domains like dev.example.com are auto-rewritten to single-level dashed hostnames (dev-api.example.com, dev-ws.example.com) so they fit Cloudflare's free Universal SSL coverage. See the Cloudflare-compatible naming note.
  • API subdomain (default api): becomes api.example.com or, on multi-level domains, <env>-api.<zone>.
  • WS subdomain (default ws): becomes ws.example.com or <env>-ws.<zone>.
  • Publish layout (default prefix): prefix works on free Cloudflare Universal SSL (e.g. do-portfolio-x7k2m.example.com); infix is cleaner (portfolio-x7k2m.dev.example.com) but requires Cloudflare Advanced Certificate Manager.
  • GitHub repo to clone (default doable-me/doable).
  • Database password (default doable; a freshly generated random value is used in non-interactive mode).
  • Optional: Google / GitHub OAuth client ID + secret, Anthropic / OpenAI / MiniMax API keys, Stripe secret + webhook secret.

Random secrets (JWT_SECRET, ENCRYPTION_KEY, INTERNAL_SECRET, DOABLE_KEK, INSTALL_BOOTSTRAP_TOKEN) are generated automatically with openssl rand.

Pre-staged .env for bulk deploys

Drop a pre-built .env at ${INSTALL_DIR:-/root/doable}/.env before running server-setup.sh and the script will source it, derive DOMAIN / API_DOMAIN / WS_DOMAIN / DB_PASS from the URLs already in the file, and skip the 14 prompts. Combine with NON_INTERACTIVE=1 (or pipe the script via SSH) for unattended provisioning at scale.

The script is also idempotent on re-runs: it preserves an existing .env, back-fills missing DOABLE_KEK, INSTALL_BOOTSTRAP_TOKEN, WS_ALLOWED_ORIGINS, and CORS_ORIGINS rather than rotating live secrets, and aligns the live PostgreSQL doable role's password with whatever is in .env.

Modes

Flag Effect
Default (Cloudflare Tunnel) Caddy serves on 127.0.0.1; cloudflared exposes it publicly. No public ports except SSH.
NO_TUNNEL=1 HOST=203.0.113.10 Pure-IP install. Caddy fronts api/ws/web on :443 with a self-signed cert. UFW opens 80 and 443.
CONTAINER_MODE=1 Skips UFW, swap, gh-auth, repo clone, cloudflared auth, and service start. Used by the Docker-secure image (Dockerfile.secure runs systemd PID 1 inside the container).
NON_INTERACTIVE=1 Skips every prompt and accepts pre-staged env defaults.

What runs after install

systemd
 └── doable.service         # Wraps a tmux session named "doable"
      ├── window: api       # tsx watch services/api/src/index.ts
      ├── window: web       # next start -H 127.0.0.1
      └── window: ws        # tsx watch services/ws/src/index.ts
 └── cloudflared.service    # Cloudflare Tunnel routes to 127.0.0.1:* services
 └── caddy.service          # Reverse proxy with SSL
 └── postgresql.service     # Database (listens on localhost only)
 └── nftables.service       # Egress firewall for sandbox UID range 10001-65000
 └── fail2ban.service       # SSH brute-force protection

All application services bind to 127.0.0.1 only. Public access goes through Cloudflare Tunnel (or Caddy in NO_TUNNEL=1 mode). See Network Binding.

Day-2 operations

# Watch a window in tmux
tmux attach -t doable
# Ctrl-b 0/1/2 to switch windows; Ctrl-b d to detach

# Restart everything
systemctl restart doable

# Update Doable
cd /root/doable
git pull
pnpm install
pnpm db:migrate
systemctl restart doable

The API and WS services use tsx watch; file changes are picked up automatically without a build step. The web app needs a rebuild for production:

cd /root/doable
pnpm build --filter=@doable/web
systemctl restart doable

Disabling components

You don't want this What to do
Cloudflare Tunnel Run with NO_TUNNEL=1 HOST=<ip-or-hostname> to serve api/ws/web behind Caddy on a public IP with a self-signed cert
OAuth Leave the OAuth prompts blank; email/password still works. Add them later via the /setup wizard
Stripe billing Leave Stripe prompts blank; credit system runs in disabled mode
Full sandbox hardening Set DOABLE_HARDENING=off and DOABLE_HARDENING_LEVEL=off in .env. Not recommended in multi-tenant deployments

Verifying the install

Run these after the script finishes:

# All services bound to localhost only?
ss -tlnp | grep -v 127.0.0.1
# Should show only sshd on 0.0.0.0:22 (and Caddy on 80/443 if NO_TUNNEL=1)

# Caddy serving?
curl -I https://your-domain/

# API healthy?
curl https://api.your-domain/health

Troubleshooting

See Troubleshooting. The most common issues:

  • PostgreSQL connection failed: re-running server-setup.sh aligns the live doable role's password with /root/doable/.env. If you edited .env by hand, also sudo -u postgres psql -c "ALTER USER doable WITH PASSWORD '...';".
  • Port 80/443 already in use: pre-existing nginx / apache from a previous install. server-setup.sh Step 2.a stops them automatically; if they were running outside systemd, systemctl stop nginx apache2 and re-run.
  • cloudflared login fails: re-run interactively: cloudflared tunnel login. Or pre-stage /etc/cloudflared/cert.pem and re-run.
  • Setup script Step 10 errors with "Tunnel credentials file not found": known quirk on first run. Re-run the script and the second pass takes the existing-tunnel branch and succeeds.

For the Docker path see Docker Setup.