AWS EC2 (Self-Host)

Migrate off Railway to your own AWS EC2 — one instance running the whole backend via docker-compose, Caddy for automatic HTTPS, reusing the existing GitHub Actions CI/CD, with R2 storage and Cloudflare Pages unchanged. Roughly the cost of one t3 instance per month.

What this doc answers

Move Zapvol off Railway onto your own AWS account, running the entire backend on one EC2 instance with docker-compose. By the end you can bring it up, and you’ll know exactly what Railway did for you that you now do yourself on a bare host.

This is not a replacement for the Cloudflare + Railway setup — it’s the path you take when data must stay inside your own VPC (Virtual Private Cloud) or you want off usage-metered managed hosting. The frontend (Cloudflare Pages) and file storage (R2) choices stay the same — only the backend compute and data layer move.

Key parameters

ItemValue
ComputeOne EC2 instance (start with t3.large: 2 vCPU / 8 GB)
Orchestrationdocker-compose (server + worker + postgres + redis)
TLS terminationCaddy container, automatic Let’s Encrypt certificates
Databasepostgres:16 container (data on an EBS volume)
Queue / Cacheredis:8 container
File storageCloudflare R2 (unchanged, zero code change)
FrontendCloudflare Pages (unchanged)
CI/CDExisting .github/workflows/docker.yml (just swap secrets)
Registryghcr.io (unchanged)
Starting cost/month$60–75 (one on-demand t3.large + EBS)

Why single-host EC2 + compose

Your ask is cheap + easy CI/CD. This combination is the only one that hits both:

  • Cheap — one EC2 + one EBS runs everything. Postgres and Redis are containers, not RDS / ElastiCache — you skip tens of dollars/month per managed service, and you skip the migration traps (RDS mandatory TLS, ElastiCache not supporting cluster-mode with BullMQ). Storage stays on R2 (free egress); the frontend stays free on Cloudflare Pages.
  • Easy CI/CD — the repo’s existing docker.yml already does “build image → push ghcr.io → SSH into host and docker compose up”. That flow is identical for Railway and for EC2. On the CI side the migration is just pointing VPS_HOST at the EC2 address — the workflow file doesn’t change.

Heavier options ruled out:

  • ECS Fargate + RDS + ElastiCache — no ops, elastic, but adds RDS + ElastiCache + ALB cost every month and requires rewriting the deploy (task definitions). Not worth it below real scale — keep it as the future upgrade path.
  • EKS — Kubernetes for a single app is pure complexity debt.

Topology

One EC2 host: four workload containers plus a Caddy sidecar.

                Internet
                   │  :443 (HTTPS + wss)  :80 (ACME + redirect)
        ┌──────────▼───────────────────────────────┐  EC2 (SG opens only 22/80/443)
        │  caddy  ── automatic Let's Encrypt certs   │
        │    │ internal-network reverse_proxy         │
        │    ▼                                        │
        │  server :8001 ──┐         worker            │
        │   (HTTP + WS)   │      (BullMQ consumer)     │
        │        ┌────────┴────────┐   │              │
        │        ▼                 ▼   ▼              │
        │   postgres:16        redis:8               │
        │   (EBS: pg_data)    (redis_data)           │
        └────────────────────────────────────────────┘
              External: Cloudflare R2 (files) · Cloudflare Pages (web/marketing)
                        Anthropic / OpenAI (agent inference)

server and worker are the same image, different start commands; migrate is a one-shot container that runs Drizzle migrations and exits. All orchestrated by docker/docker-compose.yaml — which was written for self-hosting from the start.

What Railway did for you that you now do yourself

Moving to bare EC2 requires zero code or image changes. The only things to add are three freebies the managed platform gave you:

  1. TLS termination + domain — Railway hands you an HTTPS domain automatically. On a bare host server only emits plain HTTP on 8001, but OAuth callbacks, better-auth secure cookies, and wss:// connections all require HTTPS. Fix: docker/docker-compose.prod.yaml layers on a Caddy reverse proxy that obtains and auto-renews a Let’s Encrypt cert and proxies 80/443 to server:8001. WebSocket passes through with no extra config.
  2. Host bootstrap — install Docker, log in to ghcr, drop in the compose files and .env. See steps below.
  3. Data backups — a Railway plugin managed Postgres backups; on a bare host you do this yourself (a pg_dump cron to R2).

Steps

1. Launch the EC2 instance

  • AMI — Ubuntu 24.04 LTS or Amazon Linux 2023, either works.
  • Instance type — start with t3.large (2 vCPU / 8 GB). The agent queue runs concurrency 50 with many long connections; memory is the pressure point. Start at large, adjust after observing.
  • Storage (EBS) — 30 GB root to start. Postgres data lives in the Docker named volume pg_data (on the root volume by default). If data will grow, mount a separate EBS at /var/lib/docker/volumes.
  • Security group — open only 22 (SSH, from your IP), 80, 443. Do not expose 8001 — external traffic enters only via Caddy on 443.

2. Bootstrap the host in one shot

Clone / scp the repo to the host, then run docker/bootstrap-host.sh. It’s idempotent and does it all in one step: installs Docker + the compose plugin, adds the current user to the docker group, creates the deploy dir (default ~/zapvol/), flattens docker-compose.yaml / docker-compose.prod.yaml / Caddyfile into it, seeds a .env from .env.example (never overwriting an existing one), and optionally logs in to ghcr:

# Basic
bash docker/bootstrap-host.sh

# Private image: also log in to ghcr (PAT needs read:packages)
GHCR_USER=<your-username> GHCR_PAT=<token> bash docker/bootstrap-host.sh

Follow the next steps it prints. If you were just added to the docker group, re-login (or newgrp docker) first. The manual equivalents (get.docker.com to install Docker → mkdir + cp to flatten files → docker login ghcr.io) work too — the script just collapses them into one command.

3. Fill in .env

Postgres and Redis are same-host containers — the connection strings are already assembled inside compose (hostnames are literally postgres / redis). You do not hand-write DATABASE_URL / REDIS_URL; just set the password. Key entries:

# Auto-layer the Caddy overlay so plain `docker compose up` needs no -f flags
COMPOSE_FILE=docker-compose.yaml:docker-compose.prod.yaml

# Caddy uses this to obtain the cert; must be a domain resolving to this host's public IP
SERVER_DOMAIN=api.example.com
BASE_URL=https://api.example.com          # public HTTPS entry (OAuth callbacks + secure cookies)

# DB password (make it strong) — compose builds DATABASE_URL as postgres://…@postgres:5432/…
POSTGRES_PASSWORD=<openssl rand -hex 24>

# Auth + AI (at minimum these two, else server starts but agent calls 401)
BETTER_AUTH_SECRET=<openssl rand -hex 32>
ANTHROPIC_API_KEY=<...>                   # or OPENAI_API_KEY / AI_GATEWAY_API_KEY

# File storage: keep R2 (zero code change, free egress). All four set, or none
R2_ACCOUNT_ID=<...>
R2_ACCESS_KEY_ID=<...>
R2_SECRET_ACCESS_KEY=<...>
R2_BUCKET_NAME=zapvol-prod
R2_PUBLIC_URL=https://files.example.com   # optional, for building download URLs

# Optional hardening: bind server to loopback only; Caddy still reaches it
SERVER_PORT=127.0.0.1:8001

Full variable list: repo root .env.example and apps/server/.env.example. The ghcr login is handled by the step-2 script (or, if you didn’t set GHCR_*, run docker login ghcr.io once yourself — creds land in ~/.docker/config.json).

4. DNS

Point an A record for api.example.com at the EC2 public IP.

If your DNS is on Cloudflare: this record must be DNS only (grey cloud), not proxied (orange). Cloudflare’s proxy terminates WebSocket on non-paid plans, and Zapvol depends heavily on WS; the orange cloud also interferes with Caddy’s ACME challenge.

5. First boot

cd ~/zapvol
docker compose pull        # COMPOSE_FILE already includes both files, Caddy comes along
docker compose up -d
# Order: postgres → migrate (runs migrations, exits) → server / worker + caddy
docker compose logs -f caddy    # watch cert issuance; a few seconds to ~30s on first run
curl https://api.example.com/health   # → {"ok":true}

6. Point the frontend at the new API domain

apps/web stays on Cloudflare Pages — no migration needed. Just point its API / WS URL at the new domain (variable names per apps/web/.env.example) and redeploy once.

Wiring up CI/CD

The existing .github/workflows/docker.yml already does “push tag → build image → push ghcr → SSH deploy”. Migrating to EC2 only means changing, under Settings → Secrets and variables → Actions:

TypeNameValue
SecretVPS_HOSTEC2 public IP or domain
SecretVPS_USERubuntu (Ubuntu AMI) or ec2-user
SecretVPS_SSH_KEYThe EC2 key pair’s private key (full PEM)
VariableDEPLOY_ENABLEDtrue
VariableDEPLOY_PATH~/zapvol

Then a release:

git tag v1.2.0 && git push origin v1.2.0

CI builds and pushes the image to ghcr, SSHes into EC2, and runs docker compose pull && up -d — the migrate container applies new migrations, and server takes traffic once healthy.

Migrating data from Railway

Run once during a low-traffic window:

# 1. Dump from Railway (using its connection string)
pg_dump "$RAILWAY_DATABASE_URL" -Fc -f zapvol.dump

# 2. Ship to EC2, restore into the postgres container
#    Ensure compose is up first (postgres + migrations have created the tables)
docker compose cp zapvol.dump postgres:/tmp/zapvol.dump
docker compose exec postgres pg_restore -U zapvol -d zapvol --clean --if-exists /tmp/zapvol.dump

Redis needs no migration — it only holds in-flight jobs; drain the queue, then cut over, losing at most a few queued tasks (just re-trigger them). R2 data stays in place if you keep R2.

Backups (mandatory on a bare host)

One cron pushing a daily full snapshot to R2 / S3:

# crontab -e, daily at 03:00
0 3 * * * docker compose -f ~/zapvol/docker-compose.yaml exec -T postgres \
  pg_dump -U zapvol zapvol | gzip | \
  aws s3 cp - s3://zapvol-backups/pg/$(date +\%F).sql.gz

What this setup does not do

  • No HA — one host is a single point of failure. If the instance dies, service is down until restart/rebuild. For HA, take the ECS/RDS path.
  • No Kubernetes — not worth it for a single app.
  • No multi-region — single-region deploy; the frontend already covers the globe via the CF edge.
  • No managed database — Postgres / Redis are local containers. That’s exactly where the savings come from; the price is that backups / upgrades / scaling are yours to run (see Backups).

Pitfalls checklist

Each maps to a real, reachable failure:

  • Starting Caddy before DNS resolves — Caddy’s cert issuance runs an ACME challenge; SERVER_DOMAIN must already resolve to this host with 80/443 reachable, or it can’t get a cert and 443 won’t come up. Configure DNS first, then up.
  • Cloudflare orange cloud breaks WebSocket — the api subdomain must be DNS only. If WS handshakes then immediately drops, check this first.
  • 8001 exposed to the internet — the security group must allow only 22/80/443; exposing plain 8001 bypasses TLS. Optionally add SERVER_PORT=127.0.0.1:8001.
  • The node sandbox runs arbitrary code inside the server container — with SANDBOX_TYPE=node, agent-generated code shares an execution context with your DB/R2 credentials. Once self-hosted in your own VPC that boundary is yours: use daytona / e2b (just set the API key), or accept it and shrink the blast radius with the security group + least privilege. This is the one responsibility that grows heavier vs. managed hosting.
  • EBS too small / data on the wrong volumepg_data, redis_data, uploads_data, caddy_data are Docker named volumes, on the root volume by default. A full root volume takes down the DB and cert renewal at once. Monitor disk, or move the volumes dir to a dedicated EBS.
  • ghcr private image not logged indocker compose pull returns denied; run docker login ghcr.io on the host first.
  • pnpm version mismatch — root package.json says pnpm@11.13.0, docker/Dockerfile uses 10.32.1. The image pins 10.32.1 and builds fine, but be aware before changing so the lockfile doesn’t drift.
  • docker compose exec needs -T — in cron / CI (no TTY) add -T, or you get “the input device is not a TTY”.

Cost estimate

At starting scale (< 100 DAU, single instance):

ItemCost/month
EC2 t3.large on-demand~$60
EBS 30 GB gp3~$2.5
Egress (light API/WS traffic)$1–5
Cloudflare R2 (< 10 GB)$0
Cloudflare Pages (web / marketing)$0
Total~$65–70

Savings levers:

  • Buy a 1-year Savings Plan / Reserved Instance — ~40% off t3.large.
  • If load is low, drop to t3.medium (4 GB), but watch memory (agent-loop GC pressure).
  • Once you outgrow it, move to ECS Fargate + RDS + ElastiCache — evolving along the “scaling up” notes in the Cloudflare + Railway doc.

Further reading

Was this page helpful?