Every Strapi headless CMS architecture diagram shows three boxes and lies about all of them. Here is the seven-box topology we actually deploy, and the schema-sync arrow that crash-loops your second container.
The last Strapi rescue we picked up opened with a message at 00:40: a rolling deploy onto three ECS tasks had left the `products` table with a new `variants` column and no `products_variants_links` join table. The admin panel loaded fine. `/_health` returned 204, because it answers without touching the schema. Every product page shipped an empty variants array for six hours before a human noticed, since nothing in Strapi treats a missing join table as an error — it just returns nothing.
Search for a Strapi headless CMS architecture diagram and none of that is visible. You get a browser, a box labelled Strapi, a cylinder labelled Postgres. Three boxes, two arrows, and no hint that the middle box is five things stacked together: a Koa server, a Knex query layer, a compiled React admin SPA, a plugin loader, and a schema reconciler that issues ALTER TABLE on every single boot. Those five fail in five different ways. The default configuration assumes one process on one disk forever, which holds perfectly on a laptop and is behind nearly every production incident we get called in to fix.
Drawn properly it is seven: the frontend that consumes the API, a CDN in front of the read path, one or more Strapi Node processes, Postgres, an object store for uploads, Redis if you want a response cache or a rate limiter that counts across instances instead of per process, and a webhook consumer that triggers rebuilds. The admin panel is not its own box — it is a static bundle compiled into the same process and served from the same origin. That is why installing a plugin needs a rebuild and a redeploy rather than a restart, and why the admin build routinely wants more than 1 GB of heap, so a 512 MB build step dies with exit 137 and no useful log line.
The arrow from Strapi to Postgres carries two completely different kinds of traffic. One is runtime query traffic from the Document Service. The other is DDL from the reconciler: each content type you define writes `src/api/*/content-types/*/schema.json`, and on boot Strapi diffs those files against the recorded schema in the database and emits ALTER TABLE to close the gap. Sharing a connection pool between the two is fine. Running unversioned migrations automatically on every process start is not — and note the pool defaults to max 10 per process, so three replicas plus a migrator plus your own psql session is 35 connections against a Postgres often capped at 100.
The race is mechanical. Three tasks start within about two seconds of each other, all three read the same `schema.json`, all three conclude the same relation is missing, all three issue DDL against the same table. Postgres grants ACCESS EXCLUSIVE to one and blocks the rest; the blocked ones hit their boot timeout and crash-loop, or the change lands half-applied — scalar column created, join table not — and the instance that won the race passes its health check. You then ship it, because nothing in the pipeline distinguishes a healthy process from a correct database.
There is no supported flag that turns the reconciler off, so the fix is sequencing rather than configuration. Run one migrator container to completion — it boots Strapi, lets the sync finish, exits — and only then start the app tier. On ECS that is a one-off task gating the service update; on Kubernetes a Job the Deployment waits on. If you cannot gate the deploy, wrap bootstrap in a `pg_advisory_lock` so the second and third processes wait instead of racing.
Then take the destructive changes away from the reconciler entirely. Renaming a field is a drop-and-add to the diff engine: the old column goes, the new one arrives empty, and three years of product descriptions are gone with no error raised. Write it as a migration in `database/migrations`, which runs once and is recorded in `strapi_migrations`, and keep the schema files matching the end state. The same logic kills content-type editing in production: the builder writes to disk and needs a restart to take effect, and in a container the disk is ephemeral and the restart is a new image. Editors change content; developers change shape.
By default uploads land in `public/uploads` and the `files` table stores a relative path with `provider` set to `local`. On one VPS that works. On anything with two replicas, roughly half of image requests hit the container that never received the file, and every deploy deletes the library while leaving the rows intact — so the admin keeps rendering thumbnails that 404 forever, which reads like a CDN misconfiguration and is actually a storage decision made at `npm create strapi`.
Move to S3, R2, or GCS before there is real content, because migrating later means rewriting `url` and the `formats` JSON on every row in `files`, plus every image path inlined into rich-text fields and component tables. Point the CDN at the bucket, not at Node. And set `breakpoints` deliberately: Strapi generates thumbnail, small, medium, and large derivatives through Sharp inside the upload request, so a marketing team dropping fifty 8 MB hero images produces 200 synchronous resize jobs on the event loop and times out unrelated content reads while it grinds.
Strapi populates nothing unless asked, and how you ask decides whether a page render is one query or forty. `populate=*` goes exactly one level deep, so dynamic zones full of nested components come back as bare component records and the frontend renders blank sections with no error anywhere. The usual reaction is a deep populate string, which fans out per relation per entry: six relations across twenty entries is 120-odd queries, and a listing that answered in 180 ms at twenty entries takes nine seconds at two thousand — long enough to start colliding with load balancer idle timeouts. Define the populate shape per route, keep it explicit, and cache the anonymous endpoints.
For a statically generated frontend, the better answer is to have no read path at all. Publish fires a webhook, the webhook triggers a build or an on-demand revalidation, and public traffic never reaches the CMS. Strapi becomes a build-time dependency, so an outage costs you an editing window instead of a website. Keep a live API only for search, personalisation, and preview — and give each one its own scoped read-only token, separate from the full-access one that has been sitting in a Slack thread since the first sprint.
A software studio that ships and maintains its own products — KeepChats, Gwora and Cairn — and builds the same way for clients. Founded and led by codewithumar.
Talk to the studio →Send the problem. You get one fixed number and a plan back within a business day.
We build software worth keeping — for clients, and for ourselves.
Founded & led by codewithumar