RE:NODE

Strapi in production: env vars, database, uploads and upgrades

By RE:NODE Team
15 min read


Running Strapi in production comes down to six decisions made once and written down: a set of secrets in .env that never change after launch, a database you can back up (SQLite for one small site, PostgreSQL for anything with editors and traffic), somewhere for uploads that survives a rebuild, an admin panel built with NODE_ENV=production, a public url that matches what the reverse proxy serves, and an upgrade routine that starts with a backup. Strapi needs about 2 GB of memory to build its admin panel comfortably and much less to serve requests afterwards. Get those six right and Strapi is a quiet Node process on port 1337. Get one wrong and you find out the day an editor logs in and every API token stops working.

This guide covers Strapi 5, which is the current major version, and notes where Strapi 4 differs. File names are shown as .js; a TypeScript project has the same files as .ts.

How a Strapi app server is put together#

Strapi is a Node.js application with two halves that ship in one process. The first is the content API: REST (and GraphQL, if you add the plugin) under /api, generated from the content types you defined. The second is the admin panel, a React application at /admin that editors use to write content and that you, in development, use to build content types.

That split explains the most surprising rule of a production Strapi: the Content-Type Builder only works in development mode. strapi develop watches the project and restarts when a model changes; strapi start does not, and the builder is read-only. Content types are code - JSON schema files under src/api and src/components - and they belong in Git. You change them on a development copy, commit, and deploy, exactly as you would a database migration in any other framework.

On start, Strapi compares those schemas with the database and brings the tables into line. That is convenient and also the thing to respect: a field you renamed is, to the database, one field deleted and another created, and the data in the old column does not follow the new name. Rename fields on a copy of production data first, never on the live site.

The three commands you will use, all defined in the package.json a new project is generated with:

CommandWhat it does
npm run developDevelopment mode, auto-reload, Content-Type Builder on
npm run buildBuilds the admin panel (and compiles TypeScript)
npm run startProduction mode, no auto-reload, no builder
HTTPS 443HTTP 1337upload providermedia URLsBrowsersite or editorReverse proxyHTTPS, your domainStrapiNode, port 1337DatabaseSQLite or PostgreSQLS3 bucketuploaded media
One request path from browser to Strapi and its data

Environment variables and secrets#

A new project gets a .env file with these entries, each filled with a random value by the project generator. In production every one of them must be set, unique to that environment, and kept unchanged from launch day onwards.

VariableRead byWhat breaks if it changes
APP_KEYSconfig/server.jsSigned session cookies become invalid
API_TOKEN_SALTconfig/admin.jsEvery API token stops working
ADMIN_JWT_SECRETconfig/admin.jsEvery admin is logged out
TRANSFER_TOKEN_SALTconfig/admin.jsTransfer tokens stop working
JWT_SECRETconfig/plugins.jsEnd-user logins (Users and Permissions) expire

APP_KEYS is a comma-separated list, not one value. Strapi reads it with env.array('APP_KEYS') and uses the keys to sign cookies; more than one key lets you rotate them. API_TOKEN_SALT is used to hash API tokens before they are stored, which is why changing it silently invalidates every token your front end, build pipeline or mobile app holds. JWT_SECRET belongs to the Users and Permissions plugin, the one that issues tokens to the people who sign up on your site, as opposed to the editors in the admin. Recent Strapi 5 projects also generate an ENCRYPTION_KEY for the admin's encrypted secrets; if your .env has one, treat it the same way.

The rest of the file is ordinary runtime settings:

.env
HOST=0.0.0.0PORT=1337NODE_ENV=productionAPP_KEYS=key1base64==,key2base64==,key3base64==,key4base64==API_TOKEN_SALT=replace-with-random-base64ADMIN_JWT_SECRET=replace-with-random-base64TRANSFER_TOKEN_SALT=replace-with-random-base64JWT_SECRET=replace-with-random-base64STRAPI_TELEMETRY_DISABLED=true

To generate a value, any source of 16 or more random bytes encoded as base64 will do:

bash
$ openssl rand -base64 32$ node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

If APP_KEYS is missing, Strapi refuses to start with an error from the strapi::session middleware saying that app keys are required. That is a good failure: loud, at boot, and obvious. The bad failures are the silent ones above, which happen when someone regenerates secrets on a running site. Environment variables and secrets covers the general discipline of keeping them out of code and out of logs.

SQLite or PostgreSQL#

Strapi supports SQLite, PostgreSQL and MySQL, selected by DATABASE_CLIENT and configured in config/database.js. The generated file reads every connection setting from the environment, so switching is a matter of variables plus installing the right driver.

VariableUsed forDefault in the generated config
DATABASE_CLIENTsqlite, postgres or mysqlsqlite
DATABASE_FILENAMESQLite file path.tmp/data.db
DATABASE_HOSTServer addresslocalhost
DATABASE_PORTServer port5432 for PostgreSQL
DATABASE_NAMEDatabase namestrapi
DATABASE_USERNAMELoginstrapi
DATABASE_PASSWORDPasswordstrapi
DATABASE_SSLTLS to the databasefalse
DATABASE_SCHEMAPostgreSQL schemapublic
DATABASE_POOL_MIN / MAXConnection pool2 / 10

SQLite is a single file and the process is the database. For a marketing site, a portfolio or a documentation CMS with one or two editors and a front end that is built statically or cached, it is entirely adequate: reads are fast, there is no network hop, and there is nothing else to run or pay for. Its limits are writes and copies. Writes are serialised, so a busy admin plus a webhook-driven import will queue. And the file is live: copying data.db while Strapi is writing can produce a copy that does not open.

Note the default path. .tmp/data.db sits in a folder whose name invites deletion, so on a long-lived server either leave it alone knowingly or move it with DATABASE_FILENAME=data/strapi.db and make sure the folder exists.

PostgreSQL is the choice once there is any of: several editors working at the same time, a public API taking real traffic, content imported by scripts, or a need for point-in-time-safe backups with pg_dump while the site stays up. It needs the pg package in the project:

bash
$ npm install pg
.env
DATABASE_CLIENT=postgresDATABASE_HOST=db.example.netDATABASE_PORT=5432DATABASE_NAME=strapiDATABASE_USERNAME=strapiDATABASE_PASSWORD=long-random-passwordDATABASE_SSL=false

DATABASE_URL is also read if you prefer one connection string. Set DATABASE_SSL=true when the database is reached across the internet rather than a private network; PostgreSQL sslmode explained covers what each level actually checks. The default pool of 10 connections is fine for one Strapi process; if you run several, add up their pools against the database's connection limit, as connection pools and limits explains.

Moving an existing SQLite site to PostgreSQL is done with Strapi's own export and import, not by converting the file: export from the SQLite instance, point the variables at an empty PostgreSQL database, start Strapi once so it creates the tables, stop it, then import. The commands are in the backups section below.

Uploads: local disk or an S3 bucket#

By default the Upload plugin stores files in public/uploads and Strapi serves them itself. On a single server with persistent disk that works, and it is how a one-click Strapi starts. Three things push you to object storage instead: the disk filling with images and generated thumbnails, a deployment process that replaces the project folder, and wanting media served without every request passing through Node.

Strapi's official S3 provider is @strapi/provider-upload-aws-s3. Despite the name it works with any S3-compatible service, provided you give it an endpoint and, for most non-AWS services, path-style addressing.

bash
$ npm install @strapi/provider-upload-aws-s3
config/plugins.js
module.exports = ({ env }) => ({  upload: {    config: {      provider: 'aws-s3',      providerOptions: {        baseUrl: env('S3_PUBLIC_URL'),        s3Options: {          credentials: {            accessKeyId: env('S3_ACCESS_KEY_ID'),            secretAccessKey: env('S3_ACCESS_SECRET'),          },          region: env('S3_REGION'),          endpoint: env('S3_ENDPOINT'),          forcePathStyle: true,          params: {            Bucket: env('S3_BUCKET'),          },        },      },      actionOptions: {        upload: {},        uploadStream: {},        delete: {},      },    },  },});

The variable names after env( are yours to choose; only the structure is fixed. baseUrl is the public address files are served from, which may differ from the API endpoint if a CDN or proxy sits in front of the bucket. forcePathStyle: true makes requests go to endpoint/bucket/key instead of bucket.endpoint/key - path-style vs virtual-hosted S3 explains why most self-hosted storage needs it.

The step everyone misses is the admin panel's Content Security Policy. Strapi's strapi::security middleware only allows images from its own origin, so after switching providers uploads succeed but thumbnails in the Media Library are broken. Add your bucket's host to img-src and media-src in config/middlewares.js:

config/middlewares.js
module.exports = [  'strapi::logger',  'strapi::errors',  {    name: 'strapi::security',    config: {      contentSecurityPolicy: {        useDefaults: true,        directives: {          'connect-src': ["'self'", 'https:'],          'img-src': ["'self'", 'data:', 'blob:', 'market-assets.strapi.io', 'files.example.com'],          'media-src': ["'self'", 'data:', 'blob:', 'market-assets.strapi.io', 'files.example.com'],          upgradeInsecureRequests: null,        },      },    },  },  'strapi::cors',  'strapi::poweredBy',  'strapi::query',  'strapi::body',  'strapi::session',  'strapi::favicon',  'strapi::public',];

Switching providers does not move existing files. Media uploaded before the change still points at /uploads/... on the local disk; either keep those files in place, or plan the move as an export, a provider switch and an import.

The admin build and memory#

npm run build compiles the admin panel into static files. It runs once per deploy, not per request, but it is the most memory-hungry thing Strapi does: bundling a React application with every plugin's admin code can briefly need well over a gigabyte, while the running server afterwards often sits at a few hundred megabytes. Strapi's own deployment documentation lists 2 GB of RAM as the minimum.

Three rules keep the build out of trouble:

  1. Build with NODE_ENV=production set, so the panel is minified and built for the production configuration.
  2. Rebuild after anything that changes the admin: a new plugin, a Strapi upgrade, a change to config/admin.js, or a change to the admin path or public URL.
  3. If the build dies with a JavaScript heap error, raise Node's limit for that one command with NODE_OPTIONS=--max-old-space-size=1536 (sized below the server's memory), and read Node memory limits explained for why the default is not your plan's RAM.

On a server with a hard memory limit, a build that overruns does not slow down and recover; the process is killed. The symptom is a build that stops partway with no error of its own. That is the case to fix with a bigger plan for the build or with the heap flag above, not by retrying.

Public URL, admin path and reverse proxy#

Strapi listens on HOST and PORT - 0.0.0.0:1337 by default - and expects a reverse proxy in front for TLS and the domain. Two settings in config/server.js make it behave correctly there:

config/server.js
module.exports = ({ env }) => ({  host: env('HOST', '0.0.0.0'),  port: env.int('PORT', 1337),  url: env('PUBLIC_URL', 'https://cms.example.com'),  app: {    keys: env.array('APP_KEYS'),  },});

url is the public address of the site including the scheme. Strapi uses it to generate absolute links - the admin's own address, file URLs from the local upload provider, links in password-reset emails - so leaving it unset produces http://0.0.0.0:1337/... links that only work on the server itself. If you serve Strapi under a sub-path such as https://example.com/cms, put the path in url and rebuild the admin.

The second setting is proxy trust. Behind a proxy, Strapi sees every request as plain HTTP from the proxy's address unless it is told to trust the X-Forwarded-For and X-Forwarded-Proto headers. The option is proxy in config/server.js; its shape changed between Strapi 4 (a boolean) and Strapi 5 (an object with separate keys), so check the server configuration page for your version rather than copying an old snippet. Without it, the classic error is Cannot send secure cookie over unencrypted connection when a login flow tries to set a secure cookie.

The admin panel lives at /admin by default and its path is set in config/admin.js. Moving it does not make it secure - the API still answers wherever it is - but it does cut noise from scanners. What a reverse proxy does covers the headers in detail, and if you run your own nginx in front, the nginx reverse proxy guide has a working block. Remember nginx's client_max_body_size defaults to 1m, which blocks any upload larger than one megabyte with a 413 before Strapi sees it.

Strapi exposes /_health, which answers with an empty 204 when the server is up - useful for an uptime monitor that should not log in or hit the database-backed API.

Backups and restores#

There are two layers, and you want both.

The first is the database plus uploads, copied as files. With PostgreSQL, pg_dump takes a consistent dump while Strapi keeps running - pg_dump and pg_restore covers formats and restores. With SQLite, the safe file copy is taken with Strapi stopped, or by a tool that uses SQLite's backup API; a plain copy of a file mid-write is a gamble. Uploads in public/uploads are ordinary files. Together with the project code and your .env, those three are a complete restore.

The second is Strapi's own data transfer commands, which produce a portable archive independent of the database engine:

bash
$ npm run strapi export -- --no-encrypt -f strapi-backup$ npm run strapi import -- -f strapi-backup.tar.gz

Without --no-encrypt the archive is encrypted with a key you are prompted for, and gets a .tar.gz.enc suffix. The archive holds content, uploaded files and project configuration. It does not hold admin users or API tokens, so it is not a substitute for a database backup - it is the tool for moving between SQLite and PostgreSQL, or between servers. An import deletes the existing content in the target before writing, so never point it at the wrong instance. strapi transfer does the same thing live between two running Strapi instances using a transfer token, which is what TRANSFER_TOKEN_SALT protects.

Whatever you choose, restore it somewhere once. Backups that actually restore explains why a backup nobody has restored is a hypothesis.

Upgrading Strapi safely#

Strapi releases often, and the admin is rebuilt on every upgrade, so upgrades deserve a routine rather than a npm update. Strapi 5 ships an upgrade tool that bumps the packages and runs code transforms:

bash
$ npx @strapi/upgrade patch --dry$ npx @strapi/upgrade minor$ npx @strapi/upgrade major

patch and minor stay inside the current major version; major is the path from Strapi 4 to 5, and it is a migration project, not an update - plugins, custom code and the database all change. --dry shows what would happen without writing anything.

The routine that works:

  1. Back up the database and uploads, and keep the archive somewhere other than the server.
  2. Run the upgrade on a development copy with a recent copy of production data.
  3. Run npm run build, start the app, and click through the admin and the API your front end uses.
  4. Commit the result, including the lock file, and deploy that exact commit.
  5. Rebuild the admin on the server and start it; watch the first start's log, because database schema changes run then.

Check the Node.js version too. Each Strapi release supports a specific range of even-numbered LTS Node versions; running an odd-numbered or end-of-life Node version is a common cause of install failures after an upgrade.

Strapi on RE:NODE#

On RE:NODE, Strapi is a one-click app on the app hosting page. It is installed and built on the server's first start, with data in SQLite on the server, so the first thing to do is open /admin and create your admin account. Plans start at 2 GB of memory, which matches what the admin build needs. The .env file is in the project folder and can be edited in the file manager or over SFTP; check that every secret in the table above holds a long random value before you hand out the address.

The panel covers the operational pieces this guide describes. Point an A record at the address shown on the proxy slot and the certificate is issued and renewed automatically, with the real client IP arriving in X-Forwarded-For. Backup slots come with every plan, run on demand or on the Schedules tab, and are stored off the machine - see backups in the guides. Because SQLite is a live file, schedule backups for a quiet hour or stop the server first. If you outgrow SQLite, a PostgreSQL server gives you the DATABASE_* values to paste in, and an S3 object storage plan gives you an endpoint, keys and a first bucket for the upload provider, with path-style addressing and any region name.

At the memory limit the server is stopped and restarted clean rather than swapping, which is the build-killed symptom described above: if npm run build dies silently, that is the reason.

Troubleshooting#

Admin login works, then everyone is logged out after a deploy. ADMIN_JWT_SECRET changed, usually because a fresh .env was generated. Restore the original value.

Every API token returns `401` after a migration. API_TOKEN_SALT differs from the one the tokens were created under. Restore it, or create new tokens and update every client.

Thumbnails are broken in the Media Library but files upload. The bucket host is missing from img-src in config/middlewares.js.

Links in emails and media URLs point at `0.0.0.0:1337`. url is not set in config/server.js. Set it and rebuild the admin.

The admin shows the old version after an upgrade. The admin was not rebuilt. Run npm run build and restart.

Uploads over 1 MB fail with `413`. The reverse proxy's body size limit, not Strapi.

Content types cannot be edited in the admin. You are in production mode, which is correct. Edit them in development and deploy.

FAQ#

Is SQLite good enough for Strapi in production?

For a small site with one or two editors and a cached or statically built front end, yes. It struggles with many concurrent writes and needs care when backing up, so move to PostgreSQL once several people edit at once or the API takes real traffic.

Can I change APP_KEYS or the other secrets later?

You can, but each change has a cost: admins are logged out, API tokens die or user sessions end, depending on which one. Rotate deliberately, one at a time, and tell the people holding tokens first.

Do I need to rebuild after changing .env?

Not for database credentials or secrets, which are read when the server starts - a restart is enough. You do need a rebuild after changing the public URL, the admin path, plugins or the Strapi version.

Why can I not create content types on the production server?

Strapi disables the Content-Type Builder outside development mode on purpose. Models are code, and changing them on a live server would change the database schema with no record in version control.

Does strapi export back up my admin users?

No. Admin users and API tokens are left out of the archive. Keep a database-level backup as well, and treat the export as a migration and portability tool.

Was this helpful?