Using Sequelize in Render

When deploying your Sequelize applications to Render on the free tier, you only get one database instance. Think of this like having one big house for all your projects. You still need to keep each project’s belongings (tables) separate, and you can do that by using PostgreSQL “schemas.” This separation avoids conflicts between different applications trying to use similarly named tables.

In this tutorial, you’ll learn how to modify your migration and seeder files so that each of your projects uses its own schema in that single Render database. By doing this, you tell Postgres which “room” (schema) your table or data belongs to.

Why Schemas?

Imagine you have one library (the Render database) but multiple sections (schemas)—each “section” holds a different project’s books (tables). This keeps one project’s tables from stepping on another’s. So if you’re building multiple Express apps, each one can have its own “room” within the same database.

Applying the options Object

To direct Sequelize to the correct schema in production, you’ll use an options object that includes schema if NODE_ENV === 'production'. This snippet belongs at the top of every migration and seeder file (but outside your up and down functions):

let options = {};
if (process.env.NODE_ENV === 'production') {
  options.schema = process.env.SCHEMA; // define your schema in options object
}

When NODE_ENV is production, options.schema points Sequelize to your custom schema name (taken from the .env variable SCHEMA). This is how each project can live in its own subsection within the Render database.

Migrations: createTable

For migrations that create a table, pass the options object as the third argument to queryInterface.createTable. For example:

return queryInterface.createTable("Users", {
  id: {
    allowNull: false,
    autoIncrement: true,
    primaryKey: true,
    type: Sequelize.INTEGER
  },
  username: {
    type: Sequelize.STRING(30),
    allowNull: false,
    unique: true
  },
  // ...
  updatedAt: {
    allowNull: false,
    type: Sequelize.DATE,
    defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
  }
}, options); // pass options as 3rd argument

Remember, options might look like:

let options = {};
if (process.env.NODE_ENV === 'production') {
  options.schema = process.env.SCHEMA;
}

Migrations: Other queryInterface methods

For all other queryInterface methods, you pass options as the first argument, but you need to include options.tableName = "TableName". This replaces the usual string argument that indicates the table. Here’s an example using addColumn:

'use strict';
let options = {};
if (process.env.NODE_ENV === 'production') {
  options.schema = process.env.SCHEMA; // define your schema
}

module.exports = {
  async up (queryInterface, Sequelize) {
    options.tableName = "Users"; // specify the table name
    await queryInterface.addColumn(options, 'firstName', {
      type: Sequelize.STRING(30)
    });
  },

  async down (queryInterface, Sequelize) {
    options.tableName = "Users"; // specify the same table name
    await queryInterface.removeColumn(options, 'firstName');
  }
};

Notice how addColumn (and similarly removeColumn, changeColumn, renameColumn) doesn’t take the table name as its second argument, but rather you pass it in the options object as options.tableName. This is crucial for telling Postgres which table (and schema) you want to modify.

Seeder Files

Seeder files also need the schema in production. Typically, you’ll place the same snippet at the top:

'use strict';
const bcrypt = require("bcryptjs");

let options = {};
if (process.env.NODE_ENV === 'production') {
  options.schema = process.env.SCHEMA; // define your schema in options object
}

module.exports = {
  async up (queryInterface, Sequelize) {
    options.tableName = 'Users'; // specify the table for your seeds
    return queryInterface.bulkInsert(options, [
      {
        email: 'demo@user.io',
        username: 'Demo-lition',
        hashedPassword: bcrypt.hashSync('password')
      },
      // ...
    ], {});
  },

  async down (queryInterface, Sequelize) {
    options.tableName = 'Users'; 
    const Op = Sequelize.Op;
    return queryInterface.bulkDelete(options, {
      username: { [Op.in]: ['Demo-lition'] }
    }, {});
  }
};

Again, we pass options (with schema and tableName) as the first argument to bulkInsert or bulkDelete.

Seeding with Model.bulkCreate

If you’re using Model.bulkCreate to seed data, you do not need to specify the schema in your seed file. That’s because the Model in Sequelize already knows which schema it belongs to (based on your production configuration). An example:

async up (queryInterface, Sequelize) {
  await User.bulkCreate([
    {
      email: 'demo@user.io',
      username: 'Demo-lition',
      hashedPassword: bcrypt.hashSync('password')
    },
  ], { validate: true });
}

Since the User model is configured to use the right schema in production, you don’t need to pass any special options about the schema.

Why This Matters

With these changes, each of your Express projects deployed to Render can share one big database instance but still keep their data separate via schemas. This approach is crucial on Render’s free tier, which doesn’t give you multiple databases.

By applying the options object properly, you ensure your tables and data land in the correct “sub-room” of that single database, so they don’t conflict with other apps.

What You’ve Learned

With these best practices, your migrations and seeders will run smoothly in production, letting each project maintain its own data within a single Render database. That means fewer conflicts, easy maintenance, and a more seamless overall experience.