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:
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 likedev.example.comare 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): becomesapi.example.comor, on multi-level domains,<env>-api.<zone>. - WS subdomain (default
ws): becomesws.example.comor<env>-ws.<zone>. - Publish layout (default
prefix):prefixworks on free Cloudflare Universal SSL (e.g.do-portfolio-x7k2m.example.com);infixis 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:
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.shaligns the livedoablerole's password with/root/doable/.env. If you edited.envby hand, alsosudo -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.shStep 2.a stops them automatically; if they were running outside systemd,systemctl stop nginx apache2and re-run. cloudflaredlogin fails: re-run interactively:cloudflared tunnel login. Or pre-stage/etc/cloudflared/cert.pemand 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.