NestJS TypeORM Migrations: Stop Using synchronize: true in Production

synchronize: true will eventually destroy your production database. Here's how to set up TypeORM migrations in NestJS the right way, with real commands and a workflow that actually makes sense.

NestJS TypeORM Migrations: Stop Using synchronize: true in Production

Introduction To Nest JS

Part 6 of 2

2
NestJS TypeORM Migrations: Stop Using synchronize: true in Production

At some point every NestJS developer has synchronize: true in their TypeORM config and feels perfectly fine about it.

Then they push to production, the app restarts, TypeORM looks at the database schema, decides something changed, and drops a column. With real user data in it.

That moment is when migrations stop being something you’ll “set up later” and become something you wish you’d set up from the beginning.

I’ve been there. This article is the setup guide I needed back then.

In the previous article we connected NestJS to PostgreSQL and I promised we’d fix the synchronize: true problem properly. This is that fix.

📋 What you'll learn

  • Why synchronize: true is dangerous and what it actually does under the hood
  • How to configure TypeORM migrations in a NestJS project
  • How to generate, run, and revert migrations
  • How to create a datasource config that works for both NestJS and the TypeORM CLI
  • A clean migration workflow you can actually use day to day

What synchronize: true Actually Does

When synchronize: true is on, TypeORM compares your entity definitions to the actual database schema every time the application starts.

If it finds differences, it runs SQL to fix them. Automatically. Without asking.

That sounds helpful in development and it is — you change an entity, restart the server, the table updates. No extra steps.

The problem is that TypeORM’s algorithm for “fixing differences” is destructive. If you rename a column in your entity, TypeORM doesn’t rename the column in the database. It drops the old one and creates a new one. Any data in that column is gone.

It also runs on every restart. In production, your app might restart because of a deployment, a crash, a scaling event — and each restart is another opportunity for something to go wrong with your schema.

The safe alternative is migrations. You write a migration file, review the SQL it generates, and run it deliberately. Nothing touches your database without you seeing it first.

Setting Up the DataSource Config

Here’s where NestJS + TypeORM migrations get slightly annoying. The TypeOrmModule.forRootAsync() config in your AppModule uses NestJS’s dependency injection system. The TypeORM CLI, which generates and runs migrations, doesn’t know about NestJS at all — it needs a standalone config file it can read directly.

You need two configs: one for NestJS, one for the CLI. But you want them to share the same values so you’re not maintaining two separate files.

Create a data-source.ts file in your src folder:

// src/data-source.ts
import { DataSource } from 'typeorm';
import * as dotenv from 'dotenv';

dotenv.config(); // load .env before anything else

Now define the datasource:

export const AppDataSource = new DataSource({
  type: 'postgres',
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT || '5432'),
  username: process.env.DB_USERNAME,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_DATABASE,
  entities: [__dirname + '/**/*.entity{.ts,.js}'],
  migrations: [__dirname + '/migrations/*{.ts,.js}'],
  synchronize: false, // always false here — migrations handle schema changes
});

This file is what the TypeORM CLI uses. It’s a plain DataSource — no NestJS, no dependency injection, just a direct connection config.

Install dotenv if you haven’t already:

npm install dotenv

Update AppModule to Use the Same Values

Now update your AppModule to turn off synchronize and point to migrations:

TypeOrmModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    type: 'postgres',
    host: config.get<string>('DB_HOST'),
    port: config.get<number>('DB_PORT'),
    username: config.get<string>('DB_USERNAME'),
    password: config.get<string>('DB_PASSWORD'),
    database: config.get<string>('DB_DATABASE'),
    entities: [__dirname + '/**/*.entity{.ts,.js}'],
    migrations: [__dirname + '/migrations/*{.ts,.js}'],
    synchronize: false, // ← changed from true
  }),
}),

Your app no longer touches the schema on startup. Good. Now migrations are in charge.

Add Migration Scripts to package.json

The TypeORM CLI needs to know where your datasource file is. Add these scripts to your package.json:

{
  "scripts": {
    "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli",
    "migration:generate": "npm run typeorm -- -d src/data-source.ts migration:generate",
    "migration:run": "npm run typeorm -- -d src/data-source.ts migration:run",
    "migration:revert": "npm run typeorm -- -d src/data-source.ts migration:revert",
    "migration:create": "npm run typeorm -- migration:create"
  }
}

Install ts-node and tsconfig-paths if they’re not already in your project:

npm install -D ts-node tsconfig-paths

tsconfig-paths is needed so TypeScript path aliases like @users/user.entity resolve correctly when the CLI runs outside of NestJS.

Generating Your First Migration

Here’s where it gets satisfying. Once your entities are defined and your datasource config is set up, you can generate a migration automatically.

TypeORM compares your entities to the current database state and writes the SQL for you:

npm run migration:generate -- src/migrations/CreateUsersAndPosts

This creates a file in src/migrations/ that looks something like this:

// src/migrations/1716201600000-CreateUsersAndPosts.ts
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateUsersAndPosts1716201600000 implements MigrationInterface {
  name = 'CreateUsersAndPosts1716201600000';

The up method runs when you apply the migration:

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      CREATE TABLE "users" (
        "id" uuid NOT NULL DEFAULT uuid_generate_v4(),
        "name" character varying NOT NULL,
        "email" character varying NOT NULL,
        "password" character varying NOT NULL,
        "createdAt" TIMESTAMP NOT NULL DEFAULT now(),
        "updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
        CONSTRAINT "UQ_users_email" UNIQUE ("email"),
        CONSTRAINT "PK_users" PRIMARY KEY ("id")
      )
    `);

The down method reverts it:

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP TABLE "users"`);
  }
}

You should always read through this file before running it. TypeORM is usually right but not always — especially with complex changes like renaming columns or splitting tables.

Running the Migration

Once you’re happy with what’s in the migration file:

npm run migration:run

TypeORM connects to your database, runs all pending migrations in order, and records which ones have run in a migrations table it creates automatically. Next time you run migration:run, it skips the ones that already ran.

You’ll see output like this:

query: SELECT * FROM "migrations"
query: START TRANSACTION
query: CREATE TABLE "users" ...
query: CREATE TABLE "posts" ...
query: INSERT INTO "migrations" VALUES (...)
query: COMMIT
Migration CreateUsersAndPosts1716201600000 has been executed successfully.

That’s your database schema updated safely, with a full audit trail.

Reverting a Migration

If something went wrong or you need to roll back:

npm run migration:revert

This runs the down() method of the most recent migration, undoing whatever the up() method did. Run it again to revert the one before that, and so on.

This is why writing a proper down() method matters. Don’t skip it. If you ever need to roll back quickly and your down() is empty or wrong, you’re doing manual SQL at the worst possible moment.

Creating a Blank Migration Manually

Sometimes you need to write a migration by hand — for a data migration, a complex rename, or something TypeORM can’t generate automatically.

npm run migration:create -- src/migrations/SeedAdminUser

This creates an empty migration file:

import { MigrationInterface, QueryRunner } from 'typeorm';

export class SeedAdminUser1716201700000 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    // write your SQL here
    await queryRunner.query(`
      INSERT INTO "users" (id, name, email, password)
      VALUES (uuid_generate_v4(), 'Admin', 'admin@brandfordtech.com', 'hashed_password')
    `);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      DELETE FROM "users" WHERE email = 'admin@brandfordtech.com'
    `);
  }
}

Data migrations live here — seeding initial data, backfilling new columns, transforming existing records. Anything that needs to happen once, in order, across all environments.

The Day-to-Day Workflow

Once this is set up, your workflow for any schema change becomes:

First, update your entity file — add a column, change a type, add a relation. Then generate a migration based on the diff:

npm run migration:generate -- src/migrations/AddProfilePictureToUsers

Read through the generated file. Make sure the SQL looks right. Then run it:

npm run migration:run

That’s it. Every schema change is tracked, reviewable, and reversible. Your production database never gets touched without you seeing exactly what SQL will run first.

One Thing That Trips People Up

If you’re getting an error like relation "migrations" does not exist or No changes in database schema were found, it usually means one of two things.

Either your data-source.ts isn’t finding your entities — double check the path in entities: [__dirname + '/**/*.entity{.ts,.js}']. The glob pattern needs to match where your entity files actually live.

Or your database already has tables that match your entities, so TypeORM sees no diff and generates an empty migration. If you were using synchronize: true before, the tables exist but there’s no migration history. In that case, generate a migration anyway — it’ll be empty — and treat it as your baseline. From here on, everything is tracked.

What’s Next

You now have a proper migration workflow. Your schema changes are deliberate, tracked, and reversible. synchronize: true is off and your production database is safe.

The next thing most NestJS projects need after getting the database right is solid error handling — because right now, if something goes wrong anywhere in your app, users are probably getting raw 500 errors with stack traces. We fix that in the next article on NestJS error handling, where we build a global exception filter that sends clean, consistent error responses across your entire API.

If you hit any issues with the migration setup, drop a comment below.

Continue the series →

Next up: NestJS Modules, Controllers and Services — the architecture finally explained.

Read next article →

Related Posts