DevOps · Database Engineering · Pickles Asia - Feb 2026

When Good Decisions
Outlive Their Context

A synchronize: true setting left over from the manual migration era clashed with newly automated CI/CD database migrations - two schema management strategies running simultaneously. On deployment, TypeORM tried to drop an index that a MySQL foreign key constraint depended on, crashing the application on startup. One targeted fix, a safety guardrail, and a cleaner config closed the gap permanently with zero data loss.

nestjs.ci.stagingGuarded
0
Data loss
1
PR fix
0
Recurrence
100%
CI/CD
ci crash · 1 pr · guarded ↑
The Challenge

Two systems managing the same schema simultaneously

While the CI/CD pipeline was being built, manual database migrations were the norm - someone (usually me) would tunnel into staging and run migration commands by hand. To smooth over rough edges during that period, synchronize: true was enabled in the TypeORM config so schema changes were auto-applied during development. This was a pragmatic, intentional decision for that context.

When the DevOps engineer shipped the automated pipeline - GitHub Actions running npm run migration:run before each deploy - it worked perfectly. But synchronize: true was never turned off. Now two systems were managing the schema: migrations via CI/CD before deployment, and TypeORM synchronization at application startup. Most of the time, synchronize found nothing to change and was silent. Until it wasn't.

  • synchronize: true left active after CI/CD migrations went live
  • TypeORM's introspection detected a minor index naming discrepancy and tried to reconcile
  • Attempted: DROP INDEX IDX_yards_created_by ON yards
  • MySQL blocked it: the index supported a foreign key constraint and could not be dropped
  • Transaction rollback → application startup failure → deploy down in staging

startup.crash.logApplication End
2
Conflicts
ROLLBACK
TX State
504
Deploy ms
2026-02-09 14:40:58query failed
DROP INDEX IDX_yards_created_byFK constraint
DROP INDEX IDX_yards_updated_byFK constraint
ROLLBACK-
Application endcrash
TypeORM synchronize vs FK constraints
Root Cause

TypeORM introspected a discrepancy it shouldn't have touched

The @ManyToOne decorators on the Yard entity create foreign key constraints. MySQL automatically creates an index to support each FK. TypeORM's synchronization algorithm read the existing indexes, detected a minor discrepancy from how it would have named them, and generated DROP INDEX statements to "fix" it. MySQL correctly refused - you cannot drop an index that an active foreign key constraint depends on. The crash was MySQL protecting data integrity, not a bug.

The root cause was the race: migrations (running in CI/CD before deploy) created the correct schema. Synchronize (running at app startup after deploy) tried to modify it. This is the fundamental incompatibility: synchronize and migrationsRun are two competing schema management strategies and should never both be active in the same environment.

The Solution

Choose one strategy per environment, make it loud

1
Disabled synchronize in staging/production - changed synchronize: true to synchronize: process.env.NODE_ENV === 'development'. Synchronize is now only active in local dev where schema iteration speed matters and data loss risk is acceptable.
2
Enabled migrationsRun in all non-dev environments - set migrationsRun: process.env.NODE_ENV !== 'development'. Migrations now run automatically as part of app startup in staging and production, in addition to the CI/CD pre-deploy step.
3
Added a FATAL safety check at startup - if TYPEORM_SYNCHRONIZE=true is detected in staging or production, the app throws a fatal error before connecting to the database. Makes it impossible to accidentally leave synchronize enabled after a CI/CD build.
4
Required explicit opt-in for synchronize in local dev - synchronize only activates when both NODE_ENV=development and TYPEORM_SYNCHRONIZE=true are set. A warning is logged when it's active. Passive by default, loud when active.
5
Documented the transition pattern - added comments to the TypeORM config explaining the decision context, what each environment variable controls, and how to safely re-enable synchronize for local schema iteration. Good decisions need expiration dates.
Schema Management Strategy
Local Dev
synchronize: true (opt-in)
migrationsRun: false
↓ env boundary
CI/CD Pipeline
npm run migration:run (pre-deploy)
↓ deploy
Staging / Prod
synchronize: false
migrationsRun: true
↓ startup check
Safety Net
FATAL if synchronize=true in staging/prod
The Results

One PR. Zero data loss. Zero recurrence risk.

0
Data loss

The crash happened during application startup - before any user traffic hit the new deploy. Staging data was untouched. The ROLLBACK ensured the partial index operations were cleanly reversed.

1
PR to fix everything

A single configuration change - synchronize: process.env.NODE_ENV === 'development' - resolved the immediate crash. The safety check and explicit opt-in mechanism were added in the same PR.

0
Recurrence risk

The FATAL startup guard means if anyone accidentally sets TYPEORM_SYNCHRONIZE=true in a staging or production environment variable, the app will refuse to start with an explicit error before touching the database.

100%
Migration coverage

All schema changes now flow exclusively through the migration system in staging and production. The CI/CD pipeline runs migrations before deploy; the app runs them again at startup as a safety net - no synchronize racing to "fix" things.

Engineering Decisions

Architectural choices, tradeoffs, and what nearly broke production

Why synchronize at all in development?

Wouldn't disabling synchronize everywhere be simpler?

No. In local development, synchronize is a feature not a bug. You iterate on entity decorators and want the schema to reflect them instantly. Removing it forces developers to write migrations for trivial schema changes in their local flow, killing iteration speed. The key insight is that synchronize and migrationsRun are incompatible at the same environment tier, not incompatible everywhere.

Why not use TypeORM's synchronize within a safety gate?

Could you wrap synchronize with validation logic instead of disabling it?

Theoretically, but operationally risky. TypeORM's synchronize algorithm is a black box you don't control. Even with pre-flight validation, you're hoping the algorithm produces what you expect. By contrast, migrations are explicit SQL. You review every change. This is why the fix disabled synchronize in staging/production - explicit control beats implicit safety gates.

The real problem wasn't the crash

Why treat this as an architecture decision rather than just a hotfix?

Because the crash was a symptom. The root cause was a decision made years ago when the codebase was young and manual. At that time, synchronize was the right call. Over time, CI/CD became more sophisticated, migrations became standard practice, and the old decision outlived its context.

If I had only fixed the crash, the same race condition could happen again during the next major refactor or schema change. Instead, I treated this as a decision lifecycle issue: good decisions need expiration dates and regular re-evaluation.

Why fail loudly instead of silently skipping?

Why throw a fatal error instead of just ignoring synchronize?

Silent failure is how configuration mistakes compound into production disasters. By making it loud, you force engineers to consciously decide: 'I'm activating this feature.' The failure is intentional and visible. This prevents accidental re-enablement after a careless config change.

THE TRADEOFFS

Cost · Risk · Tradeoffs

Estimated values based on production metrics and monitoring data

Cost Impact
unchanged
Developer iteration speed
Synchronize still works in local dev with opt-in activation. No impact on developer workflow velocity.
low increase
Operational complexity
Added one environment variable check and a startup guard. Minimal overhead for the reliability gain.
Risk Reduction
high to low
Deployment risk
Race condition eliminated. The fatal startup check prevents any misconfigured deployment from reaching the database.
improved
Future maintainability
The decision is now explicitly documented with an expiration context. Future engineers understand why this config exists and when they might revisit it.
Before vs After

Transformative Results: Before vs After

✕Before
  • synchronize: true activeSchema conflict risk
  • Two systems managing schemaRace conditions
  • No safety guardrailsSilent failures
  • Manual DB ops eraInconsistent state
→
✓After
  • synchronize: false enforcedNo conflict risk
  • CI/CD-only migrationsSingle source of truth
  • Safety check addedFail-fast protection
  • Automated & reliableConsistent state
⇪Business Impact
0 data loss
1 PR to fix permanently
0 recurrence risk
Tech Stack

What was used

ORMTypeORM
DatabaseMySQL
FrameworkNestJS
RuntimeNode.js
CI/CDGitHub Actions
InfraAWS ECS / Fargate

Need someone who thinks about the lifecycle of engineering decisions?

I build systems that are maintainable under change - not just correct at the moment of writing.