Skip to main content
The hosted service runs on Railway with a managed Neon (Postgres) + Upstash (Redis) stack. The same apps/api/Dockerfile is the artifact you ship — there is no separate “OSS” image. Whether you use Railway, Fly, Render, ECS, or plain Kubernetes, the moving parts are the same.

Architecture: two services, one image

The API container has two entry points and you run them as two separate services in production: Both share the same image and the same env vars. Only the start command and the public-networking flag differ. Why split: a queue backlog (Twitter rate limit, Instagram container processing) keeps the worker busy. If the worker shared a process with the HTTP listener, that backlog would starve POST /v1/posts. Splitting lets you scale them independently and ensures the HTTP path stays responsive under queue pressure.

What you need

  • A container host (Railway, Fly, Render, ECS, Kubernetes — anywhere Docker runs)
  • Managed Postgres — Neon, RDS, Supabase, or a Postgres you operate
  • Managed Redis — Upstash, ElastiCache, or a Redis you operate
  • An S3 bucket configured per the media spec
  • DNS control for the hostname your API will live on
  • Your own platform OAuth credentials (see platform credentials)

Build the image

The repo-root apps/api/Dockerfile builds a single image from the monorepo:
The build context must be the monorepo root, not apps/api/. The Dockerfile copies pnpm-lock.yaml, every workspace package.json, and the packages/schemas/ source — none of which exist inside apps/api/.

Environment variables

Set these on both services (api and worker): The dashboard hosts elsewhere (Vercel, Cloudflare Pages, your own static host) and needs NEXT_PUBLIC_API_URL=https://api.your-domain.example on its environment.

Health check

The API exposes GET /health:
Configure your platform’s health check to hit /health on the api service with a 30s timeout. The worker doesn’t bind a port — disable its health check.

Migrations

The api service runs migrations on boot (node dist/db/migrate.js && node dist/server.js). The worker does not run migrations — the api owns that responsibility so two replicas don’t race. For a schema change in your fork:
Push — the api service applies the new migration on the way up, then the new code starts serving. For zero-downtime deploys, follow the standard pattern: additive in one release, destructive in a follow-up. Add the new column / table, ship the code that writes to both old and new, deploy. Once you’ve verified the migration backfilled and the new code is healthy, ship a follow-up that drops the old column.

Reference: Railway

Step-by-step setup for our production deploy (api + worker on Railway, Neon for Postgres, Upstash for Redis) lives in DEPLOY.md in the repo. The two non-obvious things to know if you mirror it:
  1. Root Directory: blank / / on both services. Not /apps/api. The Dockerfile needs the monorepo root as its build context.
  2. Same KEK_MASTER on both services. They’re separate Railway services, so it’s two env-var copies — but the value must be identical or the worker can’t decrypt tokens the api wrote.

Rollback

Container platforms handle rollback by redeploying a previous image. Migrations are forward-only — if you need to roll back across a schema change, restore the database from a point-in-time snapshot first, then redeploy the previous image.

Smoke test after deploy

If the worker is wired up correctly, the post moves from queuedpublished within a few seconds. If it stays queued, the worker isn’t consuming — see troubleshooting.

Self-host parity

There is no feature gate between the OSS image and api.letmepost.dev. Same code, same API surface, same error envelope. The hosted tier’s only differentiation is managed OAuth apps (you don’t have to register your own with each platform) and managed infrastructure.