DevOps & hosting

Coolify on Hetzner Singapore for API + workers + Redis + Postgres. GitHub Actions + Turborepo remote cache. Mongolia→Singapore is ~80-100 ms.

Platform
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

ComponentRecommendationCost (early)
Next.js webVercel (Hobby → Pro)$0 → $20/mo
Hono API + Workers + RedisHetzner CCX13 (Tokyo or HEL/FSN) + Coolify~€13/mo
PostgresNeon Tokyo Launch tier (managed, has PITR) OR self-host on same CCX13$19/mo or included
Object storageCloudflare R2~$0.015/GB
Custom domain / DNS / CDNCloudflare$0
SMS / push / email131344 (per msg) + FCM (free) + Resendvariable

Mongolia latency

No major cloud POP in UB. Tokyo ~70-100 ms; Singapore ~80-120 ms; Helsinki ~200-260 ms. Tokyo wins.

Cost estimate (100 DAU, 10 GB DB, 1M req/mo)

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):

  1. Migration adds new column/table → ship.
  2. App rolls out using new column → ship.
  3. 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

Backups + DR

DB tooling

Build steps (Phase 0/1)

  1. Provision Hetzner CCX13. Install Docker + Coolify.
  2. Add domains: app.spacehub.mn (Vercel), api.spacehub.mn (Coolify), files.spacehub.mn (R2 public bucket).
  3. Create R2 bucket + access keys.
  4. Choose Postgres: Neon (faster start) or self-host (cheaper).
  5. Create GitHub Actions secrets: TURBO_TOKEN, PROD_DATABASE_URL, COOLIFY_API_WEBHOOK, VERCEL_TOKEN, SENTRY_DSN, etc.
  6. Wire .github/workflows/deploy.yml.
  7. Smoke-test full deploy with the scaffold.

Open questions

Sources