API web/scheduler split
The API runs as two Railway services from one codebase: a web service that
serves HTTP with multiple replicas, and a scheduler service that runs the cron
jobs with exactly one replica.
Why
@nestjs/schedule registers cron jobs per process. Every replica of a
cron-enabled service runs every @Cron on every tick. Two web replicas would
mean two executions of all nineteen scheduled jobs — the distributed twin of the
duplicate-ScheduleModule bug found on 2026-08-10, except spread across
containers where no in-process guard can detect it.
assertSingleCronRegistration (apps/api/src/common/assert-single-cron-registration.ts)
only counts registrations inside one process. It cannot see a second
container. The split is the only thing preventing cross-container duplication.
Some jobs would survive duplication — the two charge sweeps hold Postgres advisory locks, and check-in dispatch and auto-no-show claim rows before acting. Roughly fifteen others have no such guard.
Topology
| Service | Config file | CRONS_ENABLED | Replicas | Public domain | Migrations |
|---|---|---|---|---|---|
@taikan/api (web) | /apps/api/railway.toml | unset | 2 (see below) | api.taikan.fit | yes (preDeployCommand) |
@taikan/api-scheduler | /apps/api/railway.scheduler.toml | true | 1 | none | no |
Web ships at 1 replica and is raised to 2 only once the cutover is done.
Until CRONS_ENABLED is removed from it, a second web replica would fire every
cron twice per tick — the same duplication this split exists to prevent, only
spread across containers where the boot guard cannot detect it.
Both build from the same commit and run the same image. Everything that differs is in the two config files plus one environment variable.
Invariants
Breaking any of these reintroduces a bug that is silent in production:
-
The web service must not have
CRONS_ENABLEDset. If it does, every cron runs on every web replica. -
The scheduler must stay at one replica.
numReplicas = 1inrailway.scheduler.tomlis not a default to tune. -
Only the web service runs migrations.
drizzle-kit migratetakes no advisory lock, so two services migrating concurrently can double-apply. -
The scheduler gets no public domain. It is not load-balanced and is not sized for request traffic.
-
CRONS_ENABLEDis the only variable that may differ between the two services. They run the same image, so any other difference means one of them is running on stale configuration. Verify withbash scripts/check-env-parity.sh.
One-time setup
Steps 1–3 are dashboard-only — the CLI cannot create a service or set production variables.
-
Create the service. Railway → Taikan project → New → GitHub Repo →
desmotech/taikan. Name it@taikan/api-scheduler. -
Point it at the scheduler config. Service → Settings → Config-as-code → set the path to:
/apps/api/railway.scheduler.tomlLeave Root Directory empty, matching the web service.
-
Copy the environment. The scheduler needs the same variables as the web service. Fastest path: web service → Variables → Raw Editor → copy all → paste into the scheduler’s Raw Editor. Then add:
CRONS_ENABLED=true⚠️ This copy is a point-in-time snapshot with no link back to the source. From here on the two services drift with every one-sided edit, and drift on the scheduler is invisible: nothing serves traffic there, so only a cron can surface it. That is exactly how TAIKAN-BACKEND-46 happened — the rebrand flipped
RESEND_FROM_ADDRESSon the web service, the scheduler kept the pre-rebrand sender, and every cron-sent email began failing ten days later when the old domain was pulled from Resend. Nobody had touched either service.Any variable change from now on must be applied to both services.
scripts/check-env-parity.shdiffs them and fails on anything outside the allowlist; theRailway Env Parityworkflow runs it daily. -
Remove
CRONS_ENABLEDfrom the web service. This is the cutover. Until it is removed, crons run in both places. -
Do not generate a domain for the scheduler.
-
Deploy web first, then the scheduler — web owns migrations, so it should apply schema changes before the scheduler boots against them.
-
Only now raise web to 2 replicas. Set
numReplicas = 2under[deploy.multiRegionConfig.us-west2]inapps/api/railway.tomland deploy. Doing this before step 4 double-fires every cron.
Verifying the cutover
After both services are live, confirm each cron fires exactly once per tick, from the scheduler only:
# Should show cron activity — one line per job per tick
railway logs --service "@taikan/api-scheduler" --environment production \
--since 30m --json | grep -c "Check-in dispatch"
# Should show NONE
railway logs --service "@taikan/api" --environment production \
--since 30m --json | grep -c "Check-in dispatch"The */15 jobs (check-in dispatch, payment monitoring) are the quickest signal —
at most a 15-minute wait. Boot logs should also carry, once per process:
[CronRegistration] ScheduleModule registered once — cron jobs will fire once per tickRolling back
Set CRONS_ENABLED=true on the web service and scale the scheduler to zero.
Crons resume on web. If web is still at two replicas, drop it to one first, or
every job double-fires.
Related
scripts/check-env-parity.sh— diffs the two services’ variables; run it after any variable edit, and see.github/workflows/env-parity.ymlfor the daily rundocs/runbooks/deploy.md— overall promotion pathdocs/runbooks/migrations.md— migration policydocs/runbooks/monitoring.md— Better Stack probes; readiness lives at/health/ready, liveness at/health/live