š³ Slimming a production Strapi 5 Docker image from 3.2 GB to 867 MB
Our production Strapi 5 Docker image weighed 3.2 GB, and almost none of it was our own code. Most of it was tooling and libraries the running server never even loads. Hereās how we got it under a gigabyte, and what has to stay so it still boots.
tl;dr
pnpm deploy --prodonly the Strapi app, drop the webpack bundler with pnpmoverrides, then delete source maps,.d.tsfiles,dist/adminand the admin panelās frontend libraries from the deploy output in the same Docker stage. Boot-test the image after every Strapi upgrade. On a fresh npm app, the same approach takes the image from 1.25 GB to 731 MB.
| Uncompressed | Compressed (pushed / pulled) | |
|---|---|---|
Whole monorepo copied in, pnpm install --prod at the root, pnpm 11 | 3.2 GB | 516 MB |
| The same, after upgrading to pnpm 12 | 2.93 GB | 481 MB |
pnpm deploy --prod of the Strapi app only | 1.68 GB | 266 MB |
| After the steps below | 867 MB | 160 MB |
We run Strapi 5.56 (TypeScript, Postgres, a pnpm monorepo, a few custom plugins) in a Debian node:24-bookworm-slim image. Built the obvious way, the image was 3.2 GB š±. Hereās what we removed, how, and what has to stay.
š Every number in this post was measured on 2026-10-04, on arm64, with Docker 29, Strapi 5.56, Node 24 and pnpm 12. Every image was boot-tested against Postgres: admin panel, REST, GraphQL and plugin routes. The plain npm app further down has its own setup.
The base image (Debian + Node 24) is about 268 MB of that. The rest is the app, and almost all of the app is node_modules.
Inside the app, with the monorepo installed at the root just before step 1 (a 2.78 GB image by then, after a few unrelated repo changes) and after every step below:
| Before | After | |
|---|---|---|
| App layer | 2.05 GB | 439 MB |
node_modules | 1,880 MB | 403 MB |
| Source maps | 472 MB | 0 |
.d.ts files | 187 MB | 0 |
Markdown and unbuilt .ts sources | 69 MB | 0 |
| Repo files (other apps, build cache, lockfile) | 74 MB | 16 MB (the appās dist + lockfile) |
Packages in node_modules/.pnpm | 3,041 | 1,551 |
Contents
- Why a Strapi image is big
- What has to stay
- Things that didnāt work, or werenāt worth it
- Next: one workspace per plugin slice
- Verifying it
- What about a plain npm app?
- What Strapi could change
- The script
Why a Strapi image is big
The admin panel is a React app, and strapi build bundles it into dist/build. Once thatās done, the server never loads the libraries the admin panel was built from. But Strapiās packages declare all of them as dependencies, so every production install keeps them:
- the admin build toolchain: webpack and its loaders, vite, esbuild, swc, lightningcss, babel (about 150 MB).
@strapi/strapideclares webpack as a dependency even though vite is the default bundler; - the admin panelās frontend libraries: React,
styled-components, the design system, codemirror, the Mux video player andhls.js, redux, formik, react-router and more (about 150 MB); - each
@strapi/*packageās prebuilt admin code indist/admin, next to thedist/serverthe server actually runs (about 57 MB); typedocand its markdown plugins, which@strapi/typesdeclares asdependencies(about 18 MB);- source maps,
.d.tsfiles, markdown, and unbuilt.tssources that packages publish (about 380 MB in total).
0. Upgrade pnpm
Upgrading pnpm from 11.26 to 12.8, and re-resolving the lockfile with it, took the image from 3.2 GB to 2.93 GB with no change to how itās built. Strapiās packages depend on each other with many optional peers (@types/*, @strapi/types), and pnpm installs a separate copy of a package for every distinct set of peers it resolves. The new lockfile has about half as many: @strapi/admin went from 12 copies to 6, @strapi/core from 6 to 3, and most other @strapi/* packages from 6 to 4.
ā ļø The upgrade also needed two small peer fixes in our manifests, so some of the saving may come from those rather than from pnpm itself.
If your image installs with pnpm, this shows how many copies you carry:
ls node_modules/.pnpm | grep '^@strapi+admin@'
1. Deploy only the Strapi app
If Strapi lives in a monorepo, donāt copy the repo into the image. pnpm deploy writes one workspace package with its production dependencies into a standalone folder:
RUN pnpm --filter <your-strapi-app> deploy --prod --ignore-scripts --prefer-offline /prod/cms
Two things to know:
- Strapi finds plugins through the appās
dependencies. Declare your local plugins asworkspace:dependencies of the app, and give each apackage.json#fileslist sodeploycopies its build output. - If you use
useTypescriptMigrations, Strapi parsestsconfig.jsonat boot and exits withTS18003when no file matchesinclude. Keep the tsconfig, and any file it needs, in the appāsfiles.
This is the step that brought us down to 1.68 GB. Most of that came from the other workspaces in the monorepo, so a single-app repo will start lower.
2. Keep webpack out of the install
@strapi/strapi declares its deprecated webpack bundler as dependencies. If you build with vite (the default), pnpm overrides can drop those edges. In pnpm-workspace.yaml (or under pnpm.overrides in package.json):
overrides:
"@strapi/strapi>@pmmmwh/react-refresh-webpack-plugin": "-"
"@strapi/strapi>css-loader": "-"
"@strapi/strapi>esbuild-loader": "-"
"@strapi/strapi>fork-ts-checker-webpack-plugin": "-"
"@strapi/strapi>html-webpack-plugin": "-"
"@strapi/strapi>mini-css-extract-plugin": "-"
"@strapi/strapi>style-loader": "-"
"@strapi/strapi>webpack": "-"
"@strapi/strapi>webpack-bundle-analyzer": "-"
"@strapi/strapi>webpack-dev-middleware": "-"
"@strapi/strapi>webpack-hot-middleware": "-"
ā ļø This applies to local installs too, so
strapi build --bundler=webpackstops working.strapi developandstrapi buildwith vite are unaffected.
3. Delete what strapi start never loads
The rest canāt be expressed as an override, because local development still needs it. So itās deleted from the deploy output, in the same Dockerfile stage that runs pnpm deploy:
RUN node scripts/slim-deploy.mjs /prod/cms
š” It has to be the same stage: deleting files in a later layer hides them without shrinking the image.
The script, in full, is at the end of this post. In order, it:
- Deletes
dist/adminfrom every@strapi/*package, plus the unbuiltadmin/,server/anddocumentation/folders@strapi/plugin-users-permissionspublishes. - Deletes source maps,
.d.ts,.ts/.tsx, markdown,__tests__, andtest/docs/examplesfolders at a packageās root. - Removes every symlink to a build-only package:
@types/*, an explicit list of admin frontend libraries,typedocand its two plugins, and the SQLite driver if production runs Postgres. - Deletes every
.pnpmstore entry that is no longer reachable fromnode_modulesby following symlinks. This is what actually frees the space: unlinkingreactmakesreact-dom,schedulerand everything else only React uses unreachable.
The frontend list is a reviewed list of names rather than something computed. We tried computing it from the built code and gave up: a regex misses require.resolve, createRequire and computed specifiers, and a miss is a crash in production on the first request down that path. With an explicit list, anything not on it is kept by default.
react, react-dom, styled-components,
@strapi/design-system, @strapi/icons, @strapi/ui-primitives,
@mux/mux-player-react, codemirror, formik, prismjs,
react-dnd, react-dnd-html5-backend, react-helmet, react-intl,
react-query, react-redux, @reduxjs/toolkit,
react-router, react-router-dom, react-select, react-window
Most of these are dependencies of @strapi/admin, content-manager, upload and the other admin-contributing packages. react, react-dom, react-router-dom and styled-components are their peers, which Strapiās project template adds to the appās own dependencies. Add your own pluginsā admin-only libraries to the list (for us: lucide-react, recharts, @apollo/client, react-hook-form, react-error-boundary).
The catch: @strapi/admin and react need a patch
Unpatched, strapi start crashes once @strapi/admin/dist/admin or react is gone:
Cannot find module './admin/src/components/DefaultDocument.js'
Strapiās CLI registers every command at startup, and commands/build (and commands/develop) statically import node/build ā node/staticFiles ā @strapi/admin/_internal ā DefaultDocument, which imports react and react-dom. So start loads the whole admin build chain even though it never builds anything š¤·.
strapi/strapi#27910 makes both commands load their implementation lazily, inside the commandās action. With it, start skips about 90 modules and boots without the admin slice or React. We apply it with pnpm patch until itās merged. Without the patch, keep @strapi/adminās dist/admin, react and react-dom; everything else still works.
What has to stay
typescript, if you useuseTypescriptMigrations: Strapi parsestsconfig.jsonwith it at boot.@strapi/types: it only provides types, and its runtime entry is an empty file. But@strapi/admin,content-managerandreview-workflowscontain 60 barerequire('@strapi/types')calls, left behind byimport '@strapi/types'statements that bring the globalstrapitype into scope, so the package has to exist: without it,strapi startfails withCannot find module '@strapi/types'. Only thetypedocpackages it pulls in can go. I reported this as strapi/strapi#27911, and my PR strapi/strapi#26544 fixes it.date-fns: it looks like a frontend library, but@strapi/utils,@strapi/database,@strapi/admin(server),@strapi/uploadand@strapi/review-workflowsuse it.sharp:@strapi/uploaduses it for image formats.vite5 and twoesbuildversions:@strapi/strapideclares them asdependenciestoo, but an override would also remove them from local installs, wherestrapi buildandstrapi developneed them. Adding them to the step 3 list might work, since the image only runsstrapi start, but we havenāt tested that and kept them.
Things that didnāt work, or werenāt worth it
- Moving a package to
devDependenciesdoesnāt always remove it. We movedbetter-sqlite3to dev, since production runs Postgres. It stayed:knexlists it as an optional peer, and pnpm satisfies that from the workspace even indeploy --prod. It has to be unlinked like the rest. - Off-the-shelf tools. SlimToolkit keeps only the files a probe run touches, and Strapi loads too much lazily (plugins, migrations, providers) for that to be safe.
@vercel/nftwould need manual includes for Strapiās dynamicrequires.node-prune,clean-modulesandmodcleanonly match file patterns (step 3.2) and have to be downloaded at build time. - A build-time check that no server file imports a listed package. It flags
staticFiles.js, so it canāt replace a boot test. - Collapsing duplicate
@strapi/*copies by hand. pnpm creates several copies of@strapi/adminand others that differ only in peer resolution. In the rootpnpm install --prodimage they cost real space, which is why having fewer of them was part of step 0ās drop from 3.2 GB to 2.93 GB. In thepnpm deployoutput from step 1 on, the copies share their files through hardlinks and cost almost nothing on disk, so collapsing them further isnāt worth it.
Next: one workspace per plugin slice
Our own plugins have the same problem as Strapiās packages, on a smaller scale. Each is one package with an admin/, a server/ and a shared/ folder, so its admin-only libraries (lucide-react, recharts, @apollo/client, react-hook-form, a second react-intl and its @formatjs locale data) are dependencies of a package the server loads. Today step 3 removes them by name. We want to split each plugin into one workspace per slice instead:
<plugin>-serverstays the Strapi plugin: adependencyof the app, withstrapi.kind: "plugin"and the./strapi-serverexport;<plugin>-adminholds the admin panel code and its UI libraries, and is only adevDependencyof the app, because nothing needs it afterstrapi build;<plugin>-sharedholds what both sides use (types, constants, validation), as a dependency of both.
pnpm deploy --prod then leaves the admin slice and its libraries out on its own, with no list to maintain, and the package boundary stops server code from importing an admin-only library by accident.
How it has to be wired, given what Strapi 5.56 does:
- Strapi discovers plugins only from the appās
dependencies, so the admin package canāt register itself. - One plugin name canāt come from two packages: if both declared the same
strapi.name, the explicitconfig/plugins.tsentry would win and the server half wouldnāt load. - So the server package keeps a
./strapi-adminexport that re-exports the admin package.strapi buildresolves that re-export inside the workspace, andstrapi startonly ever loads./strapi-server, so the admin package is never needed at runtime.
š” If you know a supported way to publish a pluginās admin half as a build-time-only package, Iād rather use that than the re-export.
Verifying it
Boot-test the image after every Strapi upgrade. A new version can start importing one of the listed packages on the server, and only a boot catches that. We run:
docker build, which also runs a realstrapi build;- the image against a throwaway
postgres:17-alpine, waiting for/_health; - a smoke test:
/adminand its entry asset, register an admin and log in, a few admin routes (users/me, content-managerinit, content-type-builder, upload, i18n, users-permissions roles), every pluginās routes, REST with and without a token, a GraphQL query, and noCannot find modulein the log; - a click through the admin panel in a browser.
What about a plain npm app?
Everything above comes from a pnpm monorepo. To see what the same approach does for a plain npm project, I created a brand-new app with npm create strapi-app@latest: Strapi 5.56.0, TypeScript, npm, SQLite (the default) and the example blog content types. Same day, same arm64 machine, Docker 29, Node 24.21.0 and the same node:24-bookworm-slim runtime base.
| Fresh npm app | Uncompressed | Compressed |
|---|---|---|
Before: npm ci ā strapi build ā npm prune --omit=dev, copy the app | 1.25 GB | 217 MB |
| After, Strapi as published (no patch) | 749 MB | 155 MB |
| After, with the lazy-command patch (strapi/strapi#27910) | 731 MB | 152 MB |
That cuts the image by 42% uncompressed and 30% compressed, and most of the saving comes without the patch.
What Strapi could change
All of the above works around how the packages are declared. These changes in Strapi itself would make most of it unnecessary for everyone:
- merge strapi/strapi#27910, so
startstops loading the admin build chain; - make the webpack bundler an optional peer (or a separate package), now that vite is the default;
- publish the admin panelās frontend libraries as something production installs can skip, for example an admin package the app needs only at build time;
- stop loading
@strapi/typesat runtime (strapi/strapi#27911, fixed by strapi/strapi#26544), and movetypedocand its plugins to itsdevDependencies; - support plugins whose admin half is a separate, build-time-only package, so plugin authors can keep admin libraries out of production installs;
- publish without source maps and
dist/adminin the server-facing packages, or ship them in separate packages.
The script
Tested with Strapi 5.56, pnpm 12 and Node 24.
š What we run is this script plus our own pluginsā entries in
FRONTEND_PACKAGES; the version below only drops those entries.
scripts/slim-deploy.mjs
// Deletes from a `pnpm deploy` output of a Strapi app what `strapi start` never loads.
// Usage: node scripts/slim-deploy.mjs <deploy dir>
//
// Run it in the stage that writes the deploy output: deleting in a later layer hides
// the files without shrinking the image.
import { readdir, realpath, rm } from "node:fs/promises";
import path from "node:path";
const BUILD_ONLY_FILE = /\.(map|md|markdown|[cm]?tsx?)$/;
const BUILD_ONLY_DIR = new Set(["__tests__"]);
// Only at a package's root: `dist/test` and the like can be a published subpath.
const BUILD_ONLY_ROOT_DIR = new Set([
"doc",
"docs",
"example",
"examples",
"test",
"tests",
]);
// Packages whose admin panel is prebuilt into the app's `dist/build`. Strapi loads each
// at runtime through its `./strapi-server` export, never `dist/admin`. `@strapi/admin`
// qualifies only through the patch from https://github.com/strapi/strapi/pull/27910 until it is merged:
// unpatched, the CLI imports its build command even for `start`, which requires
// `dist/admin/src/components/DefaultDocument.js`.
const ADMIN_SLICE_PACKAGE = /^@strapi\//;
/** @type {Record<string, string[]>} */
const UNBUILT_SOURCES = {
"@strapi/plugin-users-permissions": ["admin", "documentation", "server"],
};
// The libraries the prebuilt admin panel was built from. Packages declare them as
// `dependencies`, but no server code imports them except `@strapi/strapi`'s
// `staticFiles.js` (`react`), which the patch from https://github.com/strapi/strapi/pull/27910 keeps off the `start`
// path until it is merged. A new Strapi or plugin version can start
// importing one server side, and only a boot of the image catches that, so boot-test
// after every bump. Not `date-fns`: Strapi's server uses it.
// Add your own plugins' admin-only libraries here.
const FRONTEND_PACKAGES = new Set([
"@mux/mux-player-react",
"@reduxjs/toolkit",
"@strapi/design-system",
"@strapi/icons",
"@strapi/ui-primitives",
"codemirror",
"formik",
"prismjs",
"react",
"react-dnd",
"react-dnd-html5-backend",
"react-dom",
"react-helmet",
"react-intl",
"react-query",
"react-redux",
"react-router",
"react-router-dom",
"react-select",
"react-window",
"styled-components",
]);
// Production runs Postgres. `@strapi/database`'s `knex` lists the SQLite driver as an
// optional peer, which pnpm satisfies from the workspace even in a `--prod` deploy.
const DEVELOPMENT_DATABASE_DRIVERS = new Set(["better-sqlite3"]);
// `@strapi/types` declares its API-docs generator as `dependencies`. `@strapi/types` itself
// stays, because `@strapi/admin`'s server requires it; nothing but these three imports
// `typedoc`.
const DOCS_GENERATOR_PACKAGES = new Set([
"typedoc",
"typedoc-github-wiki-theme",
"typedoc-plugin-markdown",
]);
const [deployDir] = process.argv.slice(2);
if (!deployDir) {
console.error("Usage: node scripts/slim-deploy.mjs <deploy dir>");
process.exit(1);
}
const nodeModules = path.join(deployDir, "node_modules");
const store = await realpath(path.join(nodeModules, ".pnpm"));
/** @typedef {{ name: string; location: string; isLink: boolean }} Package */
/**
* The entries of one `node_modules` folder, scoped names included, skipping
* pnpm's own `.bin`, `.pnpm` and `.modules.yaml`.
*
* @param {string} dir
* @returns {Promise<Package[]>}
*/
async function listPackages(dir) {
const packages = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".")) {
continue;
}
if (entry.name.startsWith("@") && entry.isDirectory()) {
for (const scoped of await readdir(path.join(dir, entry.name), {
withFileTypes: true,
})) {
packages.push({
name: `${entry.name}/${scoped.name}`,
location: path.join(dir, entry.name, scoped.name),
isLink: scoped.isSymbolicLink(),
});
}
continue;
}
packages.push({
name: entry.name,
location: path.join(dir, entry.name),
isLink: entry.isSymbolicLink(),
});
}
return packages;
}
/** The store's package folders, without its hoisting folder and lockfile. */
async function listStoreEntries() {
const entries = await readdir(store, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory() && entry.name !== "node_modules")
.map((entry) => entry.name);
}
/**
* Every package installed in the store, as the folder holding its files. The
* other entries of `.pnpm/<entry>/node_modules` are symlinks to its dependencies.
*/
async function listInstalledPackages() {
const installed = [];
for (const entry of await listStoreEntries()) {
const packages = await listPackages(
path.join(store, entry, "node_modules"),
);
installed.push(...packages.filter((pkg) => !pkg.isLink));
}
return installed;
}
/** @param {Package[]} installed */
async function removeAdminSlices(installed) {
for (const pkg of installed) {
if (!ADMIN_SLICE_PACKAGE.test(pkg.name)) {
continue;
}
const folders = ["dist/admin", ...(UNBUILT_SOURCES[pkg.name] ?? [])];
await Promise.all(
folders.map((folder) =>
rm(path.join(pkg.location, folder), {
recursive: true,
force: true,
}),
),
);
}
}
/** @param {string} name */
function isBuildOnlyPackage(name) {
return (
name.startsWith("@types/") ||
FRONTEND_PACKAGES.has(name) ||
DEVELOPMENT_DATABASE_DRIVERS.has(name) ||
DOCS_GENERATOR_PACKAGES.has(name)
);
}
/** Removes every link to a build-only package, so pruneUnreachable deletes it. */
async function unlinkBuildOnlyPackages() {
const folders = [nodeModules];
for (const entry of await listStoreEntries()) {
folders.push(path.join(store, entry, "node_modules"));
}
for (const folder of folders) {
const links = (await listPackages(folder)).filter(
(pkg) => pkg.isLink && isBuildOnlyPackage(pkg.name),
);
await Promise.all(links.map((link) => rm(link.location)));
}
}
/** @param {NodeJS.ErrnoException} error */
function ignoreDanglingLink(error) {
if (error.code === "ENOENT") {
return undefined;
}
throw error;
}
/**
* Deletes every store entry no longer reachable from the app's `node_modules` by
* following dependency symlinks.
*
* `.pnpm/node_modules` is deliberately not a root: pnpm hoists every package there
* for undeclared imports, so starting from it would keep everything. An entry
* reached only through it is a dependency nobody declares.
*/
async function pruneUnreachable() {
/** @type {Set<string>} */
const reachable = new Set();
const pending = [nodeModules];
for (let dir = pending.pop(); dir !== undefined; dir = pending.pop()) {
for (const pkg of await listPackages(dir)) {
const target = await realpath(pkg.location).catch(
ignoreDanglingLink,
);
if (target === undefined) {
continue;
}
const [entry] = path.relative(store, target).split(path.sep);
if (
entry === undefined ||
entry.startsWith("..") ||
reachable.has(entry)
) {
continue;
}
reachable.add(entry);
pending.push(path.join(store, entry, "node_modules"));
}
}
const unreachable = (await listStoreEntries()).filter(
(entry) => !reachable.has(entry),
);
await Promise.all(
unreachable.map((entry) =>
rm(path.join(store, entry), { recursive: true }),
),
);
}
/**
* @param {string} dir
* @param {boolean} isPackageRoot
*/
async function removeBuildOnlyFiles(dir, isPackageRoot) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const location = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (
BUILD_ONLY_DIR.has(entry.name) ||
(isPackageRoot && BUILD_ONLY_ROOT_DIR.has(entry.name))
) {
await rm(location, { recursive: true });
} else {
await removeBuildOnlyFiles(location, false);
}
continue;
}
if (entry.isFile() && BUILD_ONLY_FILE.test(entry.name)) {
await rm(location);
}
}
}
const installed = await listInstalledPackages();
await removeAdminSlices(installed);
for (const pkg of installed) {
await removeBuildOnlyFiles(pkg.location, true);
}
await unlinkBuildOnlyPackages();
await pruneUnreachable();
Thanks for reading my blog posts! š
- Created At
- 10/4/2026
- Updated At
- 10/4/2026
- Published At
- 10/4/2026