Strapi Self-Hosted Setup
Self-hosting Strapi trades vendor convenience for control over data residency, plugins, and deployment — and for the operational overhead that comes with owning all three. This guide is the production blueprint: runtime prerequisites, environment-driven config, perimeter controls, and containerized deployment, within Platform Integration Deep Dives.
Integration Contract
A Strapi integration rests on a few explicit agreements. Model as code: content types and components live in the repository and change through pull requests. Delivery access: a read-only API token used on the server, never the admin JWT or a full-access token. Queries: REST requests with explicit fields and populate, or GraphQL with selected fields, in one data layer. Drafts: draft and publish enabled on routable types, with preview requesting drafts using a separate token on the server. Media: an upload provider that stores files in object storage behind a CDN, since container file systems are ephemeral. Events: webhooks with a shared secret header to the frontend’s revalidation endpoint.
# .env: frontend side of a Strapi integration
STRAPI_URL=https://cms.example.com
STRAPI_READ_TOKEN=read_only_api_token
STRAPI_PREVIEW_TOKEN=token_with_draft_access
STRAPI_WEBHOOK_SECRET=shared_secret_for_webhook_header
Architecture & Runtime Prerequisites
Strapi v5 needs an active Node.js LTS — v20 or v22 (v18 is end-of-life and unsupported), per the Node.js LTS schedule, a relational database, and optionally Redis for cache and sessions. Use PostgreSQL: its JSONB support and concurrent-write handling directly raise API throughput under heavy ingestion (see the PostgreSQL JSONB docs for indexing complex content). The build decouples admin from API: /admin compiles to static assets while the Node process serves REST/GraphQL — the same UI/delivery split as Sanity Studio Customization.
The self-hosted topology puts a hardened perimeter in front of the Strapi process, which serves both the static admin and the API off one database:
Environment Configuration & Schema Management
Configuration must be strictly environment-driven. Inject variables via your orchestrator or CI/CD pipeline rather than committing .env files to version control.
NODE_ENV=production
DATABASE_CLIENT=postgres
DATABASE_HOST=db.internal
DATABASE_PORT=5432
DATABASE_NAME=strapi_prod
DATABASE_USERNAME=strapi_user
DATABASE_PASSWORD=${DB_SECRET}
JWT_SECRET=${JWT_SECRET}
ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET}
APP_KEYS=${APP_KEYS}
Content types are code: the Content-Type Builder writes schema files under src/api/*/content-types/ in development, and those files are committed and deployed. In production the builder is disabled, so model changes flow only through Git and deploys, and Strapi applies the corresponding database changes on startup. Build the admin with strapi build, start with strapi start, and automate that sequence in CI so staging and production run the same code and schema. Teams coming from a managed platform can compare against the Contentful Integration Guide to see how explicit schema management differs from a vendor-locked model and how to map existing schemas onto Strapi content types.
Security & Perimeter Controls
A public headless CMS needs a hard perimeter. Set CORS in config/middlewares.js to allow only your frontend domains, and rate-limit at the reverse proxy (NGINX, Traefik, Cloudflare), in addition to the built-in rate limiting of the Users & Permissions auth routes, to blunt credential stuffing and API abuse.
Strapi’s permissions engine needs careful scoping or it leaks data. Strapi Role-Based Access Control Configuration covers least-privilege editing, API-token scopes, and publication workflows. Don’t use Super Admin for routine content work — map roles to specific content types with restricted lifecycle actions (create, update, publish).
Containerization & Deployment Patterns
Containers give the cleanest dev-to-prod parity. A minimal docker-compose.yml for local and staging (Docker Compose specification):
version: '3.8'
services:
strapi:
build: .
environment:
- NODE_ENV=production
- DATABASE_CLIENT=postgres
- DATABASE_HOST=db
- DATABASE_PORT=5432
- DATABASE_NAME=${DATABASE_NAME}
- DATABASE_USERNAME=${DATABASE_USERNAME}
- DATABASE_PASSWORD=${DATABASE_PASSWORD}
- JWT_SECRET=${JWT_SECRET}
- ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET}
- APP_KEYS=${APP_KEYS}
ports:
- "1337:1337"
depends_on:
- db
db:
image: postgres:15-alpine
environment:
- POSTGRES_USER=${DATABASE_USERNAME}
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
- POSTGRES_DB=${DATABASE_NAME}
volumes:
- strapi-data:/var/lib/postgresql/data
volumes:
strapi-data:
For production, orchestrate via Kubernetes or ECS. The Self-hosting Strapi on AWS for enterprise apps blueprint covers IaC templates, auto-scaling, RDS Proxy, and the connection-pool tuning that prevents the most common production failure. Have CI run npm ci, npm run build, and a health check against /admin and /api before promoting the image.
Querying from the Frontend
Strapi’s REST API returns only top-level fields unless relations and components are requested with populate, and returns all fields unless restricted with fields. Build every request with both, per template, and keep the query objects in one data layer. The qs library turns nested objects into Strapi’s bracketed query syntax, which keeps complex requests readable.
// lib/strapi.ts
import qs from "qs";
export async function getArticle(slug: string, opts: { preview?: boolean } = {}) {
const query = qs.stringify({
filters: { slug: { $eq: slug } },
fields: ["title", "slug", "summary", "publishedAt"],
populate: { cover: { fields: ["url", "width", "height", "alternativeText"] }, author: { fields: ["name"] } },
status: opts.preview ? "draft" : "published",
}, { encodeValuesOnly: true });
const res = await fetch(`${process.env.STRAPI_URL}/api/articles?${query}`, {
headers: { Authorization: `Bearer ${opts.preview ? process.env.STRAPI_PREVIEW_TOKEN : process.env.STRAPI_READ_TOKEN}` },
...(opts.preview ? { cache: "no-store" as const } : { next: { tags: [`article:${slug}`, "article:list"] } }),
});
if (!res.ok) throw new Error(`Strapi error ${res.status}`);
return ((await res.json()) as { data: unknown[] }).data[0] ?? null;
}
Draft and Publish
With draft and publish enabled, Strapi 5 keeps draft and published versions of each document, and the API returns the published version by default, or the draft with status=draft. Preview requests use a token that may read drafts, bypass caches and run only in the frontend’s draft mode. The draft and preview guide connects Strapi’s preview button to the frontend.
Media and Upload Providers
The default upload provider stores files on the server’s local disk, which disappears with every container restart and does not scale across instances. Configure an object storage provider such as S3 or a compatible service, serve files through a CDN, and store image dimensions, which Strapi records for images, with every media reference. The upload provider guide covers configuration and caching.
Webhooks and Revalidation
Strapi webhooks fire on entry create, update, publish, unpublish and delete, and on media events. For public caches, act on publish, unpublish and delete events. Strapi does not sign webhook bodies, but lets you add custom headers, so configure a long random secret header and compare it in constant time in the handler, as described in securing Strapi webhooks.
Modeling Content in Strapi
Strapi’s building blocks map well onto headless modeling practices. Collection types hold repeatable content such as articles and products; single types hold one-off content such as site settings and the homepage. Components are reusable field groups embedded in entries, such as an SEO object or a link, and dynamic zones are arrays of components that editors arrange freely, which is Strapi’s page builder. Each component in a dynamic zone carries a __component value such as blocks.hero, the discriminator for rendering, as described in modeling page-builder blocks. Relations connect collection types for shared content like authors and categories. Populate dynamic zones explicitly per component type, using the on syntax, so each block returns only its own fields and nested media.
Localization
Strapi’s internationalization creates a localized version of each document per locale, sharing the document id. Requests take a locale parameter and return that locale’s version, or nothing when it does not exist, so the frontend handles fallbacks explicitly, as described in configuring fallback chains. Decide per field whether it is localized, since non-localized fields share one value across locales, which suits prices or SKUs but not text. Slugs are usually localized for translated URLs; enforce uniqueness per locale with a unique attribute or a lifecycle hook. For hreflang, query which locales exist for a document and build clusters from them.
Deployment and Scaling
Strapi runs as a stateless Node process once uploads live in object storage and sessions are not stored in memory, which lets you run several instances behind a load balancer. Build a container image in CI with the admin already built, run database migrations on startup of a single instance or as a release step, and roll out new instances with health checks against the API. Size each instance’s database pool so the total across instances stays below the database’s connection limit. The AWS guide shows a complete setup; the same principles apply on other clouds and container platforms.
Extending Strapi Safely
Strapi’s extensibility is one of its main attractions: custom routes, controllers and services, lifecycle hooks on content types, document service middlewares and plugins. Use extensions for logic that belongs next to the data, such as computing derived fields on save, enforcing validation rules that span fields, or exposing a composed endpoint for a page that needs several content types. Keep extensions small, typed and tested, since they run inside the CMS process and a failure can affect editing as well as delivery. Avoid putting frontend-specific rendering logic into Strapi; it couples the CMS to one consumer. Before each Strapi upgrade, run the extension tests against the new version, because internal APIs change between major versions more often than the public REST API does.
Security Hardening
Beyond CORS and rate limits, a few settings matter for every self-hosted Strapi. Serve the admin panel on a separate hostname or path protected by single sign-on or network restrictions, since it is the most valuable target. Keep API tokens scoped: read-only for delivery, a narrowly scoped custom token for preview, and full-access tokens only for migration scripts in CI, rotated and never stored on the frontend. Disable public registration in the Users & Permissions plugin unless the site needs end-user accounts. Restrict upload file types and sizes, and serve uploads from a separate domain. Keep Strapi, Node.js and plugins up to date, subscribe to security advisories, and back up the database and upload bucket on a schedule with tested restores. The RBAC guide covers roles in detail.
Error Handling & Resilience
A self-hosted CMS makes availability your responsibility. Keep reader traffic at the CDN and in the frontend’s data cache, so a Strapi restart or deploy never affects the public site. Serve stale content when Strapi is unreachable, alert on 5xx rates and response times, and distinguish permission errors, which usually mean a token or role change, from infrastructure errors. Size the database connection pool for the number of Strapi instances, since exhausting connections is the most common production failure.
Testing & Observability
Run Strapi in CI with a test database seeded from fixtures, apply the current schema from the repository, and run the frontend’s integration tests against it. Generate TypeScript types for content types, with Strapi’s built-in type generation, and use them in the data layer. In production, monitor Node process health, database load, request latency per route and webhook delivery failures, which Strapi shows per webhook.
Worked Example
An events company self-hosted Strapi 4 on a single virtual machine with local uploads and a frontend that requested populate=* everywhere. During a ticket launch, traffic went straight to Strapi, which ran out of database connections, and a server restart lost a week of uploaded images that had not been backed up. The rebuilt setup ran Strapi 5 in containers behind a proxy with S3 uploads and a CDN, explicit fields and populate per template, a read-only API token on the frontend server, tag-based revalidation from secured webhooks, and preview through draft mode. The next launch served entirely from the CDN, with Strapi handling only the handful of revalidation requests caused by editors’ last-minute changes.
Choosing Strapi
Strapi suits teams that want an open-source, code-first CMS they can extend with custom controllers, services and plugins in JavaScript or TypeScript, and host wherever they choose. Its trade-offs are operational: hosting, upgrades, security patches, backups and scaling are the team’s work, unless Strapi Cloud is used. Compared with Directus, Strapi defines the model in code rather than wrapping an existing database; compared with SaaS platforms, it gives more control and fewer usage-based costs in exchange for running the service yourself.
Caching Strategy
Strapi does not ship a delivery CDN, so caching is the frontend’s and the infrastructure’s job. Two layers work well together. The frontend’s data cache stores responses tagged with document ids and content types, revalidated by webhooks, which gives immediate updates after publishing. A CDN in front of the API, or at least in front of the frontend, absorbs traffic spikes; if the API itself is exposed through a CDN for client-side requests, cache only responses to requests made with the read-only token or the public role, and never cache draft or authenticated responses. Media served from object storage through a CDN can be cached for a long time, since Strapi gives replaced files new URLs. With these layers, Strapi’s own load becomes proportional to publishing activity rather than to reader traffic, which is what makes a modest self-hosted instance sufficient for large sites.
Ownership and Operations
Self-hosting spreads responsibility across roles. The platform team owns the runtime: containers, database, backups, upgrades and monitoring. The frontend team owns the content model in code, the data layer, tokens used by the site, webhooks and caching. Editors own content, and an administrator owns roles and permissions in the admin panel. Agree on upgrade windows, since Strapi upgrades can involve database migrations, and rehearse them on a staging environment with a copy of production data.
Preview & Draft Workflow
Preview in Strapi 5 connects the admin panel’s preview button to the frontend: configure a preview handler that builds a URL for each content type, pointing at the frontend’s draft route with the document id, locale and status. The frontend validates a preview secret, enables draft mode and fetches with status=draft using the preview token, bypassing caches. Editors see unpublished changes rendered by the real frontend, and published pages stay unaffected. For content types without a page of their own, such as authors, link the preview to a representative page where the item appears.
Frequently Asked Questions
Strapi Cloud or self-hosted?
Strapi Cloud removes infrastructure work; self-hosting gives full control over data location, scaling and plugins. The integration patterns on the frontend are the same.
REST or GraphQL?
REST with explicit populate and fields is simple and caches well; the GraphQL plugin suits component-driven pages. Both respect roles and tokens.
How do we migrate from Strapi 4 to 5?
Strapi provides an upgrade tool and codemods; the frontend must adapt to the flattened response format and document ids. The upgrade guide covers the frontend side.
Can editors change the model in production?
No. The Content-Type Builder is disabled in production; model changes go through development, Git and deploys.
Does Strapi need Redis?
Not necessarily. Redis helps for caching custom endpoints, rate limiting across instances and some plugins; a single instance without custom caching runs fine without it.
Can Strapi serve multiple sites?
Yes, with a site field on shared content types that every frontend query filters by, or with separate instances when sites need different models or strict isolation. Tokens cannot restrict access to individual entries, so a shared instance suits sites that may share content.
How many API tokens do we need?
At least one read-only token per environment for builds and server rendering, plus a separate preview token that can read drafts. Keep both on the server and rotate them on a schedule.