Strapi is the headless CMS you deploy when you want the content team in an admin panel and the frontend team on a clean REST or GraphQL API. Getting it to production trips people up in four specific places: the SQLite-to-Postgres switch, the pile of secret env vars it refuses to boot without, the admin panel build, and the media uploads folder that silently evaporates on containerized platforms. Let's handle all four.
Switch the database from SQLite to PostgreSQL
The default create-strapi-app project runs on SQLite — fine for local development, wrong for production, where a file-based database inside a container disappears with the container. Strapi speaks Postgres through knex; install the driver:
npm install pgRecent Strapi templates ship a config/database.ts that already switches on DATABASE_CLIENT and supports a single connection string. The Postgres branch looks roughly like this — check yours matches:
// config/database.ts (postgres branch, simplified)
export default ({ env }) => {
const client = env('DATABASE_CLIENT', 'sqlite');
const connections = {
postgres: {
connection: {
connectionString: env('DATABASE_URL'),
ssl: env.bool('DATABASE_SSL', false) && {
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
},
},
pool: {
min: env.int('DATABASE_POOL_MIN', 2),
max: env.int('DATABASE_POOL_MAX', 10),
},
},
// ...sqlite branch stays for local dev
};
return {
connection: {
client,
...connections[client],
acquireConnectionTimeout: env.int('DATABASE_CONNECTION_TIMEOUT', 60000),
},
};
};Two things to notice. First, the whole switch is driven by environment variables — in production you set DATABASE_CLIENT=postgres and provide DATABASE_URL, and your local setup keeps using SQLite untouched. Second, the pool max of 10 is a deliberate number: managed Postgres plans cap connections (PandaStack's free tier allows 50), and Strapi holds pool connections open. Two replicas at max: 10 plus an admin session fits comfortably; max: 50 does not.
The five secrets Strapi won't boot without
In production mode Strapi validates its security config at startup and exits if anything is missing. You need all of these:
APP_KEYS=<key1>,<key2>,<key3>,<key4>
API_TOKEN_SALT=<random>
ADMIN_JWT_SECRET=<random>
TRANSFER_TOKEN_SALT=<random>
JWT_SECRET=<random>Generate each value locally:
openssl rand -base64 32APP_KEYS takes four comma-separated values (they sign session cookies; multiple keys exist so you can rotate them). ADMIN_JWT_SECRET signs admin panel logins, JWT_SECRET signs end-user tokens from the users-permissions plugin, and the two salts protect API and transfer tokens.
The failure mode to avoid: copying the secrets from your dev .env into production. Anyone with your repo history could then mint valid admin JWTs. Generate a fresh set for production, store them only in the platform's env var settings, and know that rotating APP_KEYS or ADMIN_JWT_SECRET logs everyone out — sometimes that's exactly what you want.
Build and start commands
Strapi has a real build step, but it's for the admin panel (a React app), not your API code:
NODE_ENV=production npm run build # strapi build — compiles the admin UI
npm run start # strapi start — production servernpm run develop is the dev-only mode with auto-reload and the content-type builder enabled; never run it in production — among other things, it lets anyone with admin access edit your schema live.
Networking defaults are container-friendly out of the box: the generated config/server.ts binds HOST to 0.0.0.0 and PORT to 1337, both overridable via env. One practical warning: the admin build is the heaviest part of the pipeline and can be memory-hungry on constrained builders. If the build gets killed, it's almost always memory, not a code problem.
What about migrations?
Strapi's answer is different from a typical framework: content-type schema changes are applied automatically at startup. When you deploy a build with a new content type or field, Strapi diffs the schema against the database and alters tables at boot. There is no migrate command to run for normal schema evolution.
For everything the auto-sync can't express — data backfills, custom indexes, renames that would otherwise drop-and-recreate — Strapi runs plain knex migration files from database/migrations/ automatically at startup, each exactly once, in filename order. Two rules keep this safe in production:
- 1One Strapi project per database. Pointing two different codebases at one database makes the auto-sync fight itself.
- 2Beware renames. Renaming a field in the content-type builder is a delete-plus-create at the database level; the old column's data doesn't follow. Do renames as an explicit migration with a copy step.
The uploads gotcha
By default, Strapi's media library writes files to public/uploads on the local disk. On any containerized platform, that directory is ephemeral — every deploy, restart, or scale event gives you a fresh filesystem, and your images are gone. Editors upload a hero image, it works, and three days later it 404s. This is the single most common "Strapi in production" bug report.
The fix is a cloud upload provider. The S3 provider works with any S3-compatible object store:
npm install @strapi/provider-upload-aws-s3// config/plugins.js
module.exports = ({ env }) => ({
upload: {
config: {
provider: 'aws-s3',
providerOptions: {
credentials: {
accessKeyId: env('S3_ACCESS_KEY_ID'),
secretAccessKey: env('S3_ACCESS_SECRET'),
},
region: env('S3_REGION'),
params: { Bucket: env('S3_BUCKET') },
},
},
},
});Do this before launch, not after the content team has uploaded 200 images to a disk that's about to vanish.
A Dockerfile for Strapi
Strapi needs most of the project present at runtime — dist, config, database, public, package.json — so an aggressive multi-stage split buys little. Build, then prune dev dependencies:
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --omit=dev
EXPOSE 1337
CMD ["npm", "run", "start"]Simple beats clever here; the prune step gets you most of the image-size win without the archaeology of figuring out exactly which directories Strapi reads at runtime in your plugin configuration.
Deploying on PandaStack
With the config above, the platform side is short:
- 1Create a managed PostgreSQL database from the dashboard — 14.x and 16.x are available, and backups run daily (retention is 7 days on the free plan, 15 on Pro, 30 on Premium — relevant for a CMS, since the database *is* the content).
- 2Connect the repo as a container app. The Dockerfile is picked up and built with rootless BuildKit in an ephemeral Kubernetes job pod; build logs stream live, which matters for Strapi because the admin build is where failures surface.
- 3Attach the database.
DATABASE_URLis injected into the container automatically — you only addDATABASE_CLIENT=postgresand the five secrets in the app's environment settings. - 4Push. Every push to the connected branch builds and deploys. Point a custom domain at it and SSL is issued automatically.
One free-tier behavior to plan around: idle apps scale to zero and cold-start on the next request. A dormant Strapi admin panel taking a beat to wake up is fine for a hobby project; for an editorial team working in the CMS all day, the paid tiers keep the app warm on stable nodes.
That's the whole path — Postgres wired in, secrets done right, uploads somewhere durable, and deploys reduced to git push. If you want to try it end to end, https://pandastack.io is the place.