Self-hosting Dreamer on your VPS/EC2 box
This covers running the entire platform — the dashboard, api-server,
build-worker, the reverse proxy, and its own Postgres/Redis/MinIO — on
a single box you control, with no external services required anywhere in
the setup: no GitHub App to register, no email provider account, nothing
to sign up for beyond owning your domain. Builds run in throwaway Docker
containers, static output lands in MinIO, server-rendered apps run as
Docker containers — all local to this machine.
Prerequisites
- A VPS you have root SSH access to. Ubuntu/Debian assumed (the
installer uses
apt). 2 vCPU / 4 GB RAM is a reasonable floor — Postgres, Redis, MinIO, and the control-plane services all run continuously; each build and each running dynamic app adds to that on top. - A domain you control, with access to its DNS. You'll point only
*.yourdomain.com(the wildcard) at the box's IP — the bare apex is left alone, so an existing site there keeps working untouched. See Wildcard Domains for why. - Ports 80 and 443 open to the internet on that box. Check your provider's firewall/security-group rules, not just the OS firewall (this trips people up more often than the OS side does).
- Optional but strongly recommended: a Cloudflare-managed zone for your domain, so TLS issuance can be fully unattended (see below). Without it, you do one interactive step during install.
Quick start
git clone https://github.com/SamanPandey-in/dreamer.git
cd dreamer
sudo chmod +x ./install.sh
sudo ./install.sh --domain yourdomain.com --cloudflare-token YOUR_CF_TOKEN
No Cloudflare token? Drop the flag — install.sh falls back to an
interactive certificate flow: it pauses partway through and shows you a
DNS TXT record to create by hand, then continues once you've added it.
Either way, when it finishes the whole stack is up and ready to deploy.
Continue to "Reaching the dashboard" below.
Getting a Cloudflare API token
Cloudflare dashboard → My Profile → API Tokens → Create Token → use
the Edit zone DNS template, scoped to the specific zone for your
domain. That scoped token, not your Global API Key, is what
--cloudflare-token wants.
What install.sh actually does
Worth understanding before you run it as root:
- Installs Docker if it isn't already present.
- Generates every secret the stack needs — Postgres password,
MinIO root password, JWT signing keys, the token-encryption key —
and writes
.env.deploy,api-server/.env,reverse-proxy/.env. This step refuses to overwrite any of those three files if they already exist (see "Re-running the installer" below for why). - Obtains a wildcard-only TLS certificate for
*.yourdomain.comvia a DNS-01 challenge (the only challenge type that can prove ownership of a wildcard at all) — deliberately NOT including the bare apex, since nothing on this box serves it. With--cloudflare-tokenthis is fully unattended; without one, certbot runs interactively and waits for you to create the TXT record it shows you. - Builds the
build-engineimage (dreamer-build-engine:local). This is deliberately not a long-running compose service — it's launched fresh per build, and exits when done. - Brings up the full stack — nine containers via
docker compose up -d --build. Only nginx publishes public ports. - Runs database migrations, retrying up to 5 times in case Postgres is still starting.
- Installs a daily cron job (
/etc/cron.d/...) that runsscripts/renew-certs.sh— a no-op most days; renewal only actually happens within 30 days of expiry. - Prints a summary: the SSH tunnel command to reach the dashboard, plus reminders that the git token and push-to-deploy webhook are both optional, in-app next steps.
Reaching the dashboard (it's not public)
The dashboard has no public hostname at all — deliberate, not an oversight: the control plane is where deployments get created/deleted and credentials live, so it's bound to loopback only. From your own machine:
ssh -L 3000:localhost:3000 -L 8000:localhost:8000 root@your-vps-ip
Leave that running, then open http://localhost:3000 in your own browser. The first thing you'll see is the one-time setup screen — name, email, password — which creates the single admin account. It only ever works once: reload after and you'll get the normal login screen instead (see Authentication for the whole model).
Want the dashboard reachable without an SSH tunnel every time (a Tailscale/VPN address, or you've decided the stricter default isn't worth it for your setup)? That's a deliberate deviation from what ships by default — add your own nginx server block or compose port binding, with the understanding that you're putting the control plane on the network behind nothing but its login form.
Set your git Personal Access Token
Optional, and only needed to deploy private repos — public repos clone and deploy with no token at all.
- GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens (or classic, either works) → Generate new token.
- Scope: Contents: Read-only is enough (plus Metadata: Read-only, which fine-grained tokens require automatically). If you also want push-to-deploy to auto-register its webhook for you later, add Webhooks: Read and write too — otherwise you'll add the webhook by hand, which needs no extra scope.
- In the dashboard: Settings → Git → paste the token → Save. It's encrypted at rest (AES-256-GCM) the same way env vars are.
That's the whole credential model — see Authentication for how it's stored and used.
Optional: push-to-deploy on git push
Off by default — manual Redeploy from the dashboard always works regardless. Turn this on only if you want a push to automatically trigger a build. It's the one feature that needs a public endpoint (GitHub's servers have to reach yours), which is why it's opt-in:
- Generate a shared secret:
openssl rand -hex 32. - Add it to
api-server/.env:GITHUB_WEBHOOK_SECRET=<the value you just generated> ENABLE_PUSH_DEPLOY=true API_PUBLIC_URL=https://hooks.yourdomain.com - Add the matching line to
.env.deploy(read bydocker-compose.ymldirectly, not by the app):ENABLE_PUSH_DEPLOY=true - Restart the containers that need to pick this up:
docker compose --env-file .env.deploy up -d nginx api-server build-worker - On GitHub → repo Settings → Webhooks → Add webhook:
- Payload URL:
https://hooks.yourdomain.com/api/webhooks/github - Content type:
application/json - Secret: the same value from step 1
- Events: just Pushes
- Payload URL:
hooks.yourdomain.com is covered by the wildcard certificate you
already have — nothing extra to issue. nginx proxies only that exact
path; every other route 404s at the edge before ever reaching
api-server, whether or not push-to-deploy is enabled.
Verify the install
docker compose --env-file .env.deploy ps
All nine services should show Up (or Up (healthy) for postgres,
redis, minio). Then do the real end-to-end check — containers being up
isn't the same thing as the platform working:
- With the SSH tunnel open, visit
http://localhost:3000— the setup screen (first run) or login screen should load. - Log in, connect a repository (set the PAT first if it's private), and
deploy it. For a static site, once
RUNNING, check the objects landed in MinIO:docker compose --env-file .env.deploy exec minio \ mc ls local/dreamer-outputs/__outputs/<your-project-slug>/ - For an SSR deploy, confirm a container came up:
and thatdocker ps --filter "name=dreamer-app-"https://<project-slug>.yourdomain.comactually renders — publicly, over the internet, no tunnel needed (this is the one thing that's SUPPOSED to be public).
Day-2 operations
Logs (any service):
docker compose --env-file .env.deploy logs -f api-server
docker compose --env-file .env.deploy logs -f build-worker
Restart a service after changing its .env:
docker compose --env-file .env.deploy restart api-server build-worker
Update to a new version of the code:
git pull
docker compose --env-file .env.deploy up -d --build
docker compose --env-file .env.deploy run --rm --entrypoint sh api-server \
-c "npx prisma migrate deploy" # only if the update includes a schema migration
Rebuild the build-engine image (if build-engine/ itself changed):
docker build -t dreamer-build-engine:local build-engine
Back up Postgres:
docker compose --env-file .env.deploy exec postgres \
pg_dump -U dreamer dreamer > backup-$(date +%F).sql
Back up MinIO (deployment output — regenerable by redeploying, but faster to restore than to rebuild everything):
docker run --rm -v <this-repo>/local-engine_minio_data:/data -v "$(pwd)":/backup \
alpine tar czf /backup/minio-backup-$(date +%F).tar.gz -C /data .
Rotate a secret (e.g. you suspect ENCRYPTION_KEY leaked): edit
api-server/.env directly, restart api-server/build-worker.
Rotating ENCRYPTION_KEY specifically makes the stored git token
undecryptable — you'll need to re-enter it in Settings.
Renewal
The cron job from step 7 runs scripts/renew-certs.sh daily at 03:00;
renewal itself only fires within 30 days of expiry, so most days it's a
no-op. If you used the manual DNS-01 fallback (no Cloudflare token),
this can't renew unattended — re-run
./scripts/lib/issue-certificate.sh yourdomain.com you@yourdomain.com
by hand roughly every 60 days, creating the TXT record it asks for.
Re-running the installer
Safe to run again — every generated .env file is written once and
never overwritten on a later run. This matters more than it looks:
regenerating JWT_ACCESS_SECRET would invalidate every active login
session, and regenerating ENCRYPTION_KEY would make every
already-encrypted secret in the database undecryptable. If you genuinely
want a specific file regenerated, delete that one file yourself first,
then re-run.
Uninstalling
cd local-engine
docker compose --env-file .env.deploy down -v # -v also removes named volumes (Postgres/Redis/MinIO data — irreversible)
rm -rf certbot/letsencrypt
rm /etc/cron.d/dreamer-local-engine-cert-renewal
Omit -v if you might come back — that flag deletes every database,
deployment output, and running app's data permanently.
Troubleshooting
Dashboard won't load through the tunnel — confirm the tunnel is
actually up (ssh -L 3000:localhost:3000 -L 8000:localhost:8000 ...)
and that you're opening http://localhost:3000, not a public hostname —
there isn't one for the dashboard by design.
TLS issuance fails / times out — almost always DNS: the DNS-01 challenge needs your domain's nameservers to actually be Cloudflare's (for the token path), or the TXT record correctly created before pressing Enter (for the interactive path) with a minute to propagate.
Forgot the admin password — no email-based reset exists (nothing in this stack sends email); reset it directly from the server:
docker compose --env-file .env.deploy exec api-server \
npx tsx scripts/reset-admin-password.ts your-new-password
This also signs out every existing session for the account, same as an in-app password change does.
"Set a git Personal Access Token in Settings" when deploying a private repo — expected. Public repos need no token at all.
A push doesn't trigger a redeploy — check ENABLE_PUSH_DEPLOY=true
is set in BOTH api-server/.env AND .env.deploy (nginx reads the
latter), that you restarted nginx/api-server/build-worker after
setting them, then check Webhook → Recent Deliveries on the repo's
own settings page — a non-2xx response there tells you exactly what was
rejected (usually a secret mismatch between GITHUB_WEBHOOK_SECRET and
what you pasted into GitHub's form).
A deploy is stuck in BUILDING forever —
docker ps -a --filter "name=dreamer-build-"
docker logs dreamer-build-<deployment-id>
The build container's own logs (not api-server's) show what actually
failed inside the build — a bad install/build command, a missing
output: 'standalone' for a dynamic Next.js app, etc.
A dynamic app deploy failed but the previous version is still live —
that's by design: a new container that never passes its health check is
discarded and the previous one keeps serving (see
dynamic deployments). Check
docker logs dreamer-app-<slug>-staging-* for why the new one didn't
come up (most commonly: the app doesn't bind to 0.0.0.0:3000, or
crashes on a missing env var).
Ran out of disk space — old build containers and unused images accumulate. Clean up with:
docker system prune -f
docker image prune -af