git clone https://github.com/doable-me/doable.git
cd doable
./deployment/docker/setup.shgit clone https://github.com/doable-me/doable.git
cd doable
./deployment/docker/setup.ps1Pure PowerShell — uses .NET RNG instead of openssl, Get-PSDrive instead
of df, and downloads mkcert.exe automatically to install a local CA
into the Windows root store. Requires Docker Desktop for Windows.
PowerShell 5.1 (built into Windows 10/11) is enough; no pwsh 7+ needed.
Open https://localhost in your browser. Sign up — the first account
becomes platform owner automatically. The setup wizard walks you
through AI keys and integrations. No SSH, no SQL, no .env editing.
The cert at https://localhost is auto-trusted on Linux, macOS,
Windows-via-WSL2, and native Windows — both setup.sh and setup.ps1
use mkcert to install a local
CA into your OS+browser trust stores. See the docker
README for the full cross-platform
cert-trust matrix.
To use AI features locally: drop your Anthropic or OpenAI key into the wizard (Step 2). Or connect GitHub Copilot if you have a subscription.
This guide takes you from a fresh Linux VPS to a working public Doable instance with HTTPS, AI features, sandboxed previews, and per-tenant DNS for published sites.
There are two supported deployment shapes for VPS installs:
| Shape | When to pick | Public ingress |
|---|---|---|
| A. Docker stack with Caddy + Let's Encrypt | Easiest path. Single command, one container per service, Caddy auto-fetches the LE cert. | Port :80 + :443 must be reachable from the internet for the ACME HTTP-01 challenge. |
| B. Docker stack behind Cloudflare Tunnel | Recommended for production. Zero public ports on the box, all ingress flows through Cloudflare's edge with its own DDoS + WAF. | Only outbound :443 to Cloudflare needed. Cleanest setup, but requires a Cloudflare account + a domain on their DNS. |
Both shapes use the same ./deployment/docker/setup.sh. The only
difference is which env vars / flags you pass.
Doable is not hardcoded to any domain. Substitute
<your-domain>with your actual zone (e.g.example.com) everywhere below.setup.shdefaults tolocalhostwhen no domain is provided.
After this guide:
| URL | Purpose |
|---|---|
https://app.<your-domain> |
Web app (Next.js) |
https://app.<your-domain>/api/* |
API (Hono) — same origin, behind Caddy |
wss://app.<your-domain>/ws |
WebSocket (Yjs CRDT) — same origin |
https://*.<your-domain> |
Per-user published sites (one wildcard subdomain pattern) |
Use any subdomain you like instead of app. — the convention is just
"one hostname for the platform, one wildcard pattern for published
sites underneath it". TLS, routing, OAuth callbacks all work out of
the box with this layout.
- A VPS — Ubuntu 22.04 / 24.04 or Debian 12, root SSH access, at least 4 GB RAM and 20 GB disk. Any cloud or bare-metal host works.
- A domain you own registered at any registrar (Namecheap, Porkbun, Cloudflare Registrar, etc.). For Shape B (Cloudflare Tunnel) the domain's nameservers must point at Cloudflare.
- DNS pointing at the server — an A record for the hostname you
picked (e.g.
app.example.com → <server-ip>). For Shape B the record is created automatically bycloudflaredduring setup. - (Shape B only) A browser logged into Cloudflare — used once
during setup for the OAuth approval that authorizes
cloudflaredto manage your zone. Doesn't need to be on the server; your laptop is fine. - (Optional) Provider credentials for features you want — the
stack works without these:
- Anthropic / OpenAI / any of 60+ supported providers — AI features (the in-app wizard handles BYOK)
- Google OAuth — "Sign in with Google" + Gmail / Drive / Calendar
- GitHub OAuth — "Sign in with GitHub" + repo import
- Stripe — paid billing tiers
The setup wizard surfaces missing keys + the OAuth callback URLs you need to register, so you don't need them upfront.
If you don't want to buy a domain today, use localhost mode:
./deployment/docker/setup.sh with no DOMAIN=. Everything stays on
127.0.0.1, mkcert installs a trusted cert into your browser, and you
reach the app at https://localhost on the server itself (or via SSH
tunnel from your laptop). Caveats: provider OAuth login won't work
(callback validators reject localhost-with-path), no published-site
subdomains. Useful for kicking the tires before committing to a
domain.
On a fresh VPS with Docker installed (or any Linux / macOS / Windows box where Docker Desktop is running):
Linux / macOS / WSL2:
git clone https://github.com/doable-me/doable.git
cd doable
DOMAIN=app.example.com \
EMAIL=you@example.com \
./deployment/docker/setup.shNative Windows (PowerShell — self-hosting on a Windows machine):
git clone https://github.com/doable-me/doable.git
cd doable
.\deployment\docker\setup.ps1 -Domain app.example.com -Email you@example.comNote: Shape B (Cloudflare Tunnel) below assumes a Linux host because
cloudflaredintegrates withsystemd. For a Windows host behind Cloudflare, installcloudflared.exeas a Windows service per Cloudflare's docs and runsetup.ps1 -Domain ... -SkipSsl.
What happens (~10 min on a 2-vCPU VPS):
setup.shdetects Linux + docker, writesdeployment/docker/.envwith random secrets- Caddy env vars set:
DOABLE_SITE=app.example.com,DOABLE_TLS=you@example.com,DOABLE_BIND_ADDR=0.0.0.0 docker compose build(5–10 min on first install) +up -d- The Caddy container binds 0.0.0.0:80 + 0.0.0.0:443 and auto-fetches a Let's Encrypt cert via the HTTP-01 challenge — no certbot, no cert manager, no DNS plugin. It also renews automatically.
- Migrate container runs once + exits clean
- All 5 services healthy
DNS: point app.example.com (A record) at the server's public IPv4
before running setup, so Caddy's ACME challenge succeeds on first
boot. If you skip this, Caddy keeps retrying every few minutes until
DNS catches up.
Verify:
curl -sS -o /dev/null -w "%{http_code}\n" https://app.example.com/
# Expect: 200
curl -sS https://app.example.com/api/health
# Expect: {"status":"healthy",...}
echo | openssl s_client -connect app.example.com:443 -servername app.example.com 2>/dev/null \
| openssl x509 -noout -subject -issuer
# Expect: Issuer: C = US, O = Let's Encrypt, CN = E*In this mode the server has zero public ports — Cloudflare's
network is the only ingress. The tunnel daemon (cloudflared)
maintains an outbound connection to Cloudflare's edge and forwards
matching requests back to Caddy on 127.0.0.1.
- Domain added to Cloudflare as a zone (free plan works). Free
Universal SSL covers
<zone>+*.<zone>(one level only). Two-level wildcards like*.staging.example.comneed Cloudflare Advanced Certificate Manager (paid). - Cloudflare account with permission to create tunnels + DNS records in that zone.
# 1. Install cloudflared on the server (Debian/Ubuntu):
curl -L --output cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared.deb
# 2. Authenticate cloudflared against your CF account (opens a browser):
cloudflared tunnel login
# Pick your zone in the browser, click "Authorize"
# 3. Create a named tunnel:
cloudflared tunnel create doable
# Note the UUID printed — you'll need it in step 5
# 4. Run setup.sh in behind-proxy mode:
git clone https://github.com/doable-me/doable.git
cd doable
DOMAIN=app.example.com ./deployment/docker/setup.sh --skip-ssl
# --skip-ssl (or DOABLE_BEHIND_PROXY=1) tells Caddy:
# - Bind 127.0.0.1 only (no public 0.0.0.0 — the tunnel is the only ingress)
# - Use internal self-signed for the origin↔tunnel hop
# (Cloudflare Tunnel doesn't verify the origin cert)
# 5. Configure cloudflared to forward to Caddy:
sudo tee /etc/cloudflared/config.yml > /dev/null <<EOF
tunnel: <tunnel-uuid-from-step-3>
credentials-file: /root/.cloudflared/<tunnel-uuid-from-step-3>.json
ingress:
- hostname: app.example.com
service: https://localhost:443
originRequest:
noTLSVerify: true # Caddy's internal self-signed isn't a real CA
httpHostHeader: app.example.com
- hostname: "*.example.com"
service: https://localhost:443
originRequest:
noTLSVerify: true
httpHostHeader: app.example.com
- service: http_status:404
EOF
# 6. Route DNS for each public hostname:
cloudflared tunnel route dns doable app.example.com
cloudflared tunnel route dns doable "*.example.com" # wildcard for published sites
# 7. Install cloudflared as a systemd service so it survives reboots:
sudo cloudflared service install
sudo systemctl enable --now cloudflaredVerify (from your laptop, after Cloudflare DNS propagates — 10-60s):
curl -sI https://app.example.com/ # 200 OK
curl -sI https://app.example.com/api/health # 200 OKIf you see Error 1033 from Cloudflare, the tunnel isn't connected
yet — systemctl status cloudflared on the server, then
journalctl -u cloudflared -n 50.
When users publish their AI-built sites, the platform mints a hostname like
portfolio-x7k2m.<somewhere>. setup-server.sh asks which layout to use,
and the choice maps to Cloudflare's TLS coverage:
| Layout | URL shape | Cloudflare TLS | When to pick |
|---|---|---|---|
prefix |
<env-prefix><slug>.<zone> |
Free Universal SSL (*.<zone>) |
Default. One zone shared across envs (e.g. dev-foo.example.com + prod-foo.example.com both ride *.example.com). |
infix |
<slug>.<env>.<zone> |
Cloudflare ACM (*.<env>.<zone>) |
You have ACM and want clean per-env wildcards (e.g. foo.dev.example.com under *.dev.example.com). |
DOMAIN=dev.example.com \
PUBLISH_LAYOUT=prefix \
PUBLISH_PREFIX=dev- \
./deployment/server-setup.shPublished-site URLs: https://dev-portfolio-x7k2m.example.com
(rides the zone's free *.example.com wildcard cert).
DOMAIN=dev.example.com \
PUBLISH_LAYOUT=infix \
WILDCARD_HOSTNAME='*.dev.example.com' \
./deployment/server-setup.shPublished-site URLs: https://portfolio-x7k2m.dev.example.com
(needs ACM on the zone — free Universal SSL only covers one wildcard level).
The setup script auto-creates the *.dev.example.com CNAME via the Cloudflare
API token extracted from cloudflared tunnel login (so the OAuth login you
already did is enough — no separate CF API token needed). It also persists
dns_mode=wildcard + dns_wildcard_hostname=*.dev.example.com in
platform_settings so the /admin DNS panel reflects the choice.
If cloudflared tunnel login hasn't been run or the token can't be extracted,
the wildcard step is skipped with a warning — you can finish it post-install
at https://<your-domain>/admin → DNS settings → "Auto-configure wildcard".
# Move dev.example.com from prefix → infix:
./deployment/reconfigure-domain.sh \
--domain dev.example.com \
--layout infix \
--wildcard-hostname '*.dev.example.com'
# Or the reverse:
./deployment/reconfigure-domain.sh --domain dev.example.com --layout prefixreconfigure-domain.sh rewrites .env, rebuilds apps/web (NEXT_PUBLIC_*
are baked at build time), and restarts the doable service.
Visit https://app.<your-domain>/signup (or https://localhost/signup
for localhost installs). The first account to sign up is automatically
promoted to platform owner. No SSH, no SQL.
After signup you're redirected to /setup. Five short steps:
- Welcome — set your workspace name
- AI provider — pick from 60+ providers (OpenAI, Anthropic, Google AI Studio, OpenRouter, Together, Groq, MiniMax, Ollama for local models, etc.), paste your API key
- Sign-in providers — optional Google / GitHub OAuth setup with copy-paste callback URLs
- Cloudflare — optional, for per-project custom domain features
- Plans & billing — optional Stripe wiring
Each step is skippable from the wizard and revisitable from /admin.
<repo>/deployment/docker/.env # All secrets, chmod 600
<repo>/deployment/docker/certs/ # mkcert-issued cert (local installs only)
<repo>/deployment/docker/docker-compose.yml
<repo>/deployment/docker/Caddyfile # TLS terminator + reverse proxy config
For the Cloudflare Tunnel shape, additionally:
/etc/cloudflared/config.yml # Tunnel ingress map
/root/.cloudflared/cert.pem # CF account auth
/root/.cloudflared/<tunnel-uuid>.json # Per-tunnel credentials
# View all logs
docker compose -f deployment/docker/docker-compose.yml logs -f
# Specific service
docker compose -f deployment/docker/docker-compose.yml logs -f api
docker compose -f deployment/docker/docker-compose.yml logs -f caddy
# Restart the app
docker compose -f deployment/docker/docker-compose.yml restart api ws web
# Full nuke + rebuild (drops postgres data)
docker compose -f deployment/docker/docker-compose.yml down -v
./deployment/docker/setup.sh
# Cloudflare Tunnel
sudo systemctl restart cloudflared
sudo journalctl -u cloudflared -f| Symptom | Likely cause | Fix |
|---|---|---|
curl: (35) error reading server hello on https://app.example.com |
DNS hasn't propagated to the cert issuer yet | Wait 60s + retry. Caddy's ACME has its own retry loop. |
Caddy keeps logging obtaining certificate ... error |
Port :80 not reachable from the internet (Shape A) | Open port 80 in your VPS firewall + verify curl http://app.example.com returns 200 from outside |
Error 1033 from Cloudflare (Shape B) |
Tunnel daemon not running or not authorized | systemctl status cloudflared + journalctl -u cloudflared -n 50 |
Browser shows "your connection is not private" on https://localhost |
mkcert CA not trusted by the browser yet | Restart Chrome (Windows policy applies on next launch). For Firefox, re-run mkcert -install after Firefox is installed. |
| Migrate container exited non-zero | Stale postgres data volume from a prior install with different .env |
docker compose down -v && ./setup.sh (drops + rebuilds postgres) |
next build crashes with Cannot read properties of null (reading 'useContext') |
Stale .next from a prior failed build |
rm -rf apps/web/{.next,.turbo} + docker compose build --no-cache web |
| Web returns 502 right after restart | Caddy started before api/ws were healthy | docker compose restart caddy — Caddy has depends_on: service_healthy but bursty cold starts can still race |
When you reach the setup wizard's Sign-in providers step, the wizard shows the exact URLs to paste into each provider's dashboard. For reference, the pattern is:
| Provider | Callback URL |
|---|---|
https://app.<your-domain>/api/auth/google/callback |
|
| GitHub (one OAuth App) | https://app.<your-domain>/api/oauth/github/ ← register the parent; GitHub's subdir-match covers login, copilot, and repo sub-paths |
| Stripe webhooks | https://app.<your-domain>/api/billing/stripe/webhook |
All API routes live under /api/* on the same hostname as the web
app — Caddy in the docker stack handles the routing.
- Docker README — deep dive on the docker stack, security model, multi-tenant sandbox overlay, troubleshooting
- Provider docs — full list of supported AI providers + per-provider configuration notes
- The in-app wizard at
/setup— keeps you in the UI for everything the docs above describe