DevOps & hosting
Coolify on Hetzner Singapore for API + workers + Redis + Postgres. GitHub Actions + Turborepo remote cache. Mongolia→Singapore is ~80-100 ms.
Updated by audit (May 26):
- Hetzner Singapore (live since August 2024) — switch from Helsinki/Falkenstein. Mongolia→Singapore ~80-100ms vs ~200-260ms.
- Don't use Fly.io — removed permanent free tier 2024, May 2026 Singapore proxy incident, operational woes flagged mid-2025.
- If VPS <4GB RAM, consider Dokploy (~0.8% idle CPU vs Coolify's ~6%).
- Apply CVE-2026-31431 patch on Coolify.
- Front-end framework changed to TanStack Start (audit overturn) — no longer Next.js, no longer Vercel-locked.
Hosting plan
| Component | Recommendation | Cost (early) |
|---|---|---|
| Next.js web | Vercel (Hobby → Pro) | $0 → $20/mo |
| Hono API + Workers + Redis | Hetzner CCX13 (Tokyo or HEL/FSN) + Coolify | ~€13/mo |
| Postgres | Neon Tokyo Launch tier (managed, has PITR) OR self-host on same CCX13 | $19/mo or included |
| Object storage | Cloudflare R2 | ~$0.015/GB |
| Custom domain / DNS / CDN | Cloudflare | $0 |
| SMS / push / email | 131344 (per msg) + FCM (free) + Resend | variable |
Mongolia latency
No major cloud POP in UB. Tokyo ~70-100 ms; Singapore ~80-120 ms; Helsinki ~200-260 ms. Tokyo wins.
- Fly.io
nrtregion (Tokyo) for API if you want managed PaaS. - Neon Tokyo region for managed Postgres.
- Hetzner Helsinki/Falkenstein is acceptable for non-realtime ops; migrate Postgres to Neon Tokyo if latency complaints arise.
Cost estimate (100 DAU, 10 GB DB, 1M req/mo)
- Managed: Fly.io API $5 + Neon $19 + Upstash free + Vercel Hobby $0 + Grafana free + Sentry free = ~$25-45/mo.
- Self-host: all-Hetzner Coolify (Postgres + Redis on one CCX13) = ~€13/mo + ops time.
Deploy topology
github.com/spacehub-mn/spacehub26
│
├─ Push to main
│ ↓
│ GitHub Actions
│ ├─ turbo prune --scope=api --docker → build → push to ghcr.io
│ ├─ turbo prune --scope=workers --docker → build → push to ghcr.io
│ └─ deploy:
│ 1. drizzle-kit migrate (against prod DB, gate via job)
│ 2. coolify webhook → pull api + workers images, swap containers
│ 3. trigger Vercel deploy (Next.js)
│ 4. smoke test /v2/health on api + / on web
│ 5. notify Slack
CI/CD details
Turborepo remote cache
Vercel offers free remote cache for any Turborepo (not just Vercel deploys). Repo secrets: TURBO_TOKEN, TURBO_TEAM. Or self-host turborepo-remote-cache on the same VPS — same protocol.
Docker via turbo prune
# apps/api/Dockerfile
FROM node:22-alpine AS base
RUN corepack enable
FROM base AS pruner
WORKDIR /app
COPY . .
RUN npm i -g turbo
RUN turbo prune --scope=@spacehub/api --docker
FROM base AS deps
WORKDIR /app
COPY --from=pruner /app/out/json/ ./
COPY --from=pruner /app/out/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=pruner /app/out/full/ ./
RUN pnpm --filter @spacehub/api build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/apps/api/dist ./apps/api/dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/apps/api/package.json ./apps/api/
EXPOSE 3001
CMD ["node", "apps/api/dist/index.js"]
Migrations
Order matters. Migrations always before app rollout. Always backwards-compatible (expand-then-contract):
- Migration adds new column/table → ship.
- App rolls out using new column → ship.
- Migration removes old column (later release) → ship.
GitHub Actions outline
# .github/workflows/deploy.yml
on:
push: { branches: [main] }
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run lint typecheck test
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
migrate:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile
- run: pnpm db:migrate
env: { DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} }
deploy-api:
needs: migrate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/build-push-action@v5
with:
context: .
file: apps/api/Dockerfile
push: true
tags: ghcr.io/spacehub-mn/spacehub26-api:latest
- run: curl -X POST ${{ secrets.COOLIFY_API_WEBHOOK }}
deploy-web:
needs: migrate
runs-on: ubuntu-latest
steps:
- uses: amondnet/vercel-action@v25
with: { vercel-token: ${{ secrets.VERCEL_TOKEN }}, vercel-args: '--prod' }
Secrets
- Now: GitHub Actions secrets +
.envlocally. - Later: Infisical self-hosted on Coolify when sprawl hits.
- Never: commit
.env.
Backups + DR
- Neon: PITR built-in, 7 days on Launch.
- Self-hosted Postgres: pgBackRest → Hetzner Object Storage or Backblaze B2. Daily full + 15-min WAL archive (RPO ≤ 15 min). Quarterly restore drill.
- R2 buckets: versioning on; lifecycle 30-day retention for delete markers.
DB tooling
- Drizzle Studio for everyday browsing.
- TablePlus ($90 one-time) for ad-hoc; DBeaver if free matters.
Build steps (Phase 0/1)
- Provision Hetzner CCX13. Install Docker + Coolify.
- Add domains:
app.spacehub.mn(Vercel),api.spacehub.mn(Coolify),files.spacehub.mn(R2 public bucket). - Create R2 bucket + access keys.
- Choose Postgres: Neon (faster start) or self-host (cheaper).
- Create GitHub Actions secrets:
TURBO_TOKEN,PROD_DATABASE_URL,COOLIFY_API_WEBHOOK,VERCEL_TOKEN,SENTRY_DSN, etc. - Wire
.github/workflows/deploy.yml. - Smoke-test full deploy with the scaffold.
Open questions
- Vercel or self-host Next.js? Vercel for speed + edge functions; self-host for cost. Recommend Vercel for v2 launch, migrate if Vercel bill hits $50+/mo.
- Postgres: Neon Tokyo or self-host? Start managed (Neon) — saves backup setup. Migrate to self-host once team comfortable with pgBackRest.
- Coolify vs Kamal 2? Coolify wins for UI-driven; Kamal 2 wins for code-as-config purists. Both work; pick by team preference.
- Worker scaling: when does one CCX13 stop being enough? Probably ~500 active properties + sustained 5 req/sec.