Skip to content
JM.
All posts

strapi · nodejs · devops

Strapi in production: what the docs don't tell you

2026.07.11 · 5 min read · Jeet Mendpara

Strapi is the fastest way I know to give a content team an admin panel and a frontend team an API. My first production project was a Strapi and GraphQL backend serving a website and two mobile apps, and I've shipped it again since. Getting it running locally takes minutes. Running it in production without surprises takes a handful of decisions the quick-start never mentions.

This is that list.

1. The schema lives in code, not in the admin panel

Locally, the Content-Type Builder is wonderful: click together a content type, and Strapi writes the schema to src/api/<name>/content-types/<name>/schema.json. That file is the real source of truth — and Strapi disables the builder when running in production mode, for exactly that reason.

Treat content types like database migrations:

  • Model changes happen in development, get committed, and go through review.
  • Production only ever receives schema changes through a deploy.
  • Nobody "quickly adds a field" on the live admin panel.

It sounds obvious written down. In practice, the first time a client asks for a field "just on production for now," the discipline is what keeps staging and production from drifting apart in ways you discover months later.

2. Local uploads disappear on redeploy

Out of the box, uploaded media goes to public/uploads on the server's disk. On a container platform, that disk is thrown away on every deploy. Everything the content team uploaded is gone, and the database still points at files that no longer exist.

Use an upload provider that writes to object storage from day one. For S3:

// config/plugins.js
module.exports = ({ env }) => ({
  upload: {
    config: {
      provider: "aws-s3",
      providerOptions: {
        s3Options: {
          credentials: {
            accessKeyId: env("AWS_ACCESS_KEY_ID"),
            secretAccessKey: env("AWS_ACCESS_SECRET"),
          },
          region: env("AWS_REGION"),
          params: { Bucket: env("AWS_BUCKET") },
        },
      },
    },
  },
});

Migrating existing uploads later means moving files and rewriting every URL stored in the database. Doing it on day one costs nothing.

3. Everything that differs per environment comes from env

Database, secrets, URLs, provider credentials — all read through env() in config/, never hard-coded, never committed. Two things in particular:

  • Don't run SQLite in production. It's the default for a reason — zero setup — but it's a file on a disk that, again, may not survive a deploy. Use PostgreSQL.
  • Set the app keys, JWT secrets, and API token salt explicitly per environment. If they change between deploys, existing sessions and tokens stop working.

4. populate is where performance goes to die

Strapi's REST API doesn't return relations unless you ask. The tempting fix is to populate everything:

GET /api/articles?populate=*

That works on day one, with ten articles and three relations. Months later, articles have authors, categories, tags, a gallery, SEO components, and related articles — each with relations of their own — and one request is quietly assembling half the database.

Ask for exactly what the screen renders:

GET /api/articles
  ?fields[0]=title&fields[1]=slug&fields[2]=publishedAt
  &populate[cover][fields][0]=url
  &populate[author][fields][0]=name
  &pagination[pageSize]=12

It's longer, but it's explicit, and it stays fast as the content model grows. Wrap these queries in the frontend's data layer so nobody has to write them by hand in components.

If you use the GraphQL plugin, put limits on how deep and how large a query can get so one client can't build an unbounded query:

// config/plugins.js
module.exports = {
  graphql: {
    config: {
      depthLimit: 7,
      amountLimit: 100,
    },
  },
};

5. Keep slow work out of lifecycle hooks

Lifecycle hooks like afterCreate and afterUpdate are the natural place for side effects: send a notification, regenerate a sitemap, call a webhook, sync to a search index. They are also inside the request — the admin panel waits for your hook to finish before it confirms the save. A slow third-party call makes publishing slow, and a failing one can make it fail.

Hooks should record that something needs to happen, not do it:

// src/api/article/content-types/article/lifecycles.js
const { notifyQueue } = require("../../../../queues");
 
module.exports = {
  async afterUpdate(event) {
    const { result } = event;
    if (result.publishedAt) {
      await notifyQueue.add("article-published", { id: result.id });
    }
  },
};

A BullMQ worker picks the job up, does the slow part, and retries on failure — without the editor ever waiting on it. This one change made more difference to how Strapi felt to content teams than any tuning I've done.

6. Build the admin panel at image build, not at startup

The admin panel is a frontend bundle, and building it is slow. Run strapi build in your Docker image build, then start with strapi start. If the build happens at container startup, every deploy and every restart waits for it, and health checks can time out before the app is even listening.

7. Upgrades: small steps, on a copy of production data

Strapi moves fast, and major versions change APIs and plugin interfaces. What has worked for me:

  • Stay reasonably current. Several minor versions at once is much easier than a year of changes in one jump.
  • Read the migration guide for every version you cross, not only the latest.
  • Test against a copy of production data, not an empty database. Most upgrade problems show up in data migrations, and an empty database has no data to migrate.
  • Check your plugins first. A plugin that hasn't been updated for the new version is the most common blocker, and it's better to learn that before you start.

The short version

  • Schema changes are code: develop locally, commit, deploy.
  • Object storage for uploads from day one.
  • Everything environment-specific through env(). PostgreSQL, not SQLite.
  • Never populate=* in production. Ask for exactly what you render; limit GraphQL depth.
  • Lifecycle hooks enqueue work; a queue worker does it.
  • Build the admin at image build time.
  • Upgrade in small steps against real data.

Strapi rewards you for treating it like the rest of your backend — versioned, configured, and deployed with care — rather than like a website builder you happen to host.