How to Self-Host Strapi — Headless CMS 2026
How to Self-Host Strapi — Headless CMS 2026
TL;DR
Strapi is the most popular open source headless CMS — 65K+ GitHub stars, used by tens of thousands of teams to build content APIs that power websites, mobile apps, and digital experiences. Self-hosting with Docker and PostgreSQL takes under 20 minutes. You get a full REST and GraphQL API, a visual content type builder, role-based access control, media library, webhooks, and a plugin marketplace — all without paying Contentful's $500+/month enterprise tier or Sanity's 10M API requests/month limitation.
Key Takeaways
- Strapi v5 (current stable): complete rewrite with TypeScript-first architecture, improved performance, Document Service API
- No vendor lock-in: all content is stored in your PostgreSQL/MySQL/SQLite database — export and migrate freely
- Content types: build any schema visually — no code for simple types, custom code when needed
- Dual API: REST and GraphQL out of the box, with automatic filtering, sorting, pagination, and population of relations
- Alternatives: Directus (data-first, works with existing DBs), Payload CMS (code-first TypeScript), Ghost (opinionated blogging), KeystoneJS (GraphQL-native)
Why Self-Host Strapi?
Contentful charges $300-$600+/month for content management at scale. Sanity limits API requests and bandwidth on free tiers. Strapi Community Edition is MIT-licensed and free forever — you only pay for the server it runs on.
Other reasons to self-host:
- Data sovereignty: GDPR compliance, keep content in your jurisdiction
- Customization: write custom plugins, middleware, and lifecycle hooks in Node.js
- Performance: co-locate with your frontend on the same VPS to eliminate API latency
- Integration control: connect to any database, any authentication provider, any media storage
Strapi is particularly compelling if you're already running Next.js, Nuxt, or Astro — the Strapi SDK works natively with all three.
Prerequisites
- Node.js 20+ or Docker
- PostgreSQL 14+ (recommended for production; SQLite is built-in for development)
- VPS: 1 vCPU / 2GB RAM minimum (Hetzner CX21 at €3.79/mo)
- Domain with SSL (optional for local dev, required for production)
Docker Compose Setup
Create /opt/strapi/docker-compose.yml:
version: '3.8'
services:
strapi:
image: strapi/strapi:latest
restart: unless-stopped
environment:
DATABASE_CLIENT: postgres
DATABASE_HOST: db
DATABASE_PORT: 5432
DATABASE_NAME: strapi
DATABASE_USERNAME: strapi
DATABASE_PASSWORD: ${DB_PASSWORD}
DATABASE_SSL: "false"
JWT_SECRET: ${JWT_SECRET}
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET}
APP_KEYS: ${APP_KEYS}
API_TOKEN_SALT: ${API_TOKEN_SALT}
TRANSFER_TOKEN_SALT: ${TRANSFER_TOKEN_SALT}
NODE_ENV: production
ports:
- "127.0.0.1:1337:1337"
volumes:
- ./uploads:/opt/app/public/uploads
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: strapi
POSTGRES_USER: strapi
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U strapi"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres:
Create .env:
# .env
DB_PASSWORD=change-me-strong-password
# Generate these with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
JWT_SECRET=your-jwt-secret-here
ADMIN_JWT_SECRET=your-admin-jwt-secret-here
APP_KEYS=key1,key2,key3,key4
API_TOKEN_SALT=your-api-token-salt
TRANSFER_TOKEN_SALT=your-transfer-token-salt
Start:
docker compose up -d
# Admin panel available at http://localhost:1337/admin
Creating Your First Admin Account
On first launch, navigate to http://your-server:1337/admin — Strapi prompts you to create the first admin account. This is the superadmin with full access to all content types, settings, and API configuration.
After registering, you'll land in the Content-Type Builder.
Building Content Types
Strapi's visual Content-Type Builder is the core feature. Create a Post content type:
- Content-Type Builder → Create new collection type
- Name:
Post - Add fields:
title(Text, required)slug(UID, attached to title, auto-generates slugs)content(Rich Text — Blocks format in v5)cover(Media, single)author(Relation → User)publishedAt(Date — Strapi's built-in draft/publish uses this)tags(Component: create aTagcomponent withname: Text)
- Save — Strapi restarts and generates the REST/GraphQL endpoints automatically
Your new endpoints are live:
GET /api/posts - list all posts
GET /api/posts/:id - get post by ID
POST /api/posts - create post
PUT /api/posts/:id - update post
DELETE /api/posts/:id - delete post
REST API Usage
Strapi's REST API supports complex queries via URL parameters:
// Fetch posts with author and cover image populated, filtered and sorted
const response = await fetch(
'https://cms.example.com/api/posts?' + new URLSearchParams({
'populate[author][fields][0]': 'username',
'populate[author][fields][1]': 'email',
'populate[cover][fields][0]': 'url',
'populate[cover][fields][1]': 'alternativeText',
'filters[publishedAt][$notNull]': 'true',
'sort[0]': 'publishedAt:desc',
'pagination[page]': '1',
'pagination[pageSize]': '10',
}),
{
headers: {
Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
},
}
)
const { data, meta } = await response.json()
// data: array of posts
// meta.pagination: { page, pageSize, pageCount, total }
Generate API tokens in Settings → API Tokens. Use token type Read-only for frontend clients.
GraphQL API
Enable GraphQL via plugin:
# Inside the container
docker compose exec strapi npm run strapi install @strapi/plugin-graphql
docker compose restart strapi
GraphQL playground available at /graphql:
query GetPosts($page: Int!, $limit: Int!) {
posts(
pagination: { page: $page, pageSize: $limit }
sort: "publishedAt:desc"
filters: { publishedAt: { notNull: true } }
) {
data {
id
attributes {
title
slug
content
publishedAt
author {
data {
attributes {
username
}
}
}
cover {
data {
attributes {
url
alternativeText
}
}
}
}
}
meta {
pagination { total pageCount }
}
}
}
Media Storage with S3
For production, configure media to upload to S3-compatible storage:
docker compose exec strapi npm install @strapi/provider-upload-aws-s3
Add to your .env:
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_REGION=us-east-1
AWS_BUCKET=strapi-media
# For MinIO or Backblaze B2:
AWS_ENDPOINT=https://s3.us-east-005.backblazeb2.com
Create config/plugins.ts inside the Strapi project:
export default ({ env }) => ({
upload: {
config: {
provider: 'aws-s3',
providerOptions: {
accessKeyId: env('AWS_ACCESS_KEY_ID'),
secretAccessKey: env('AWS_SECRET_ACCESS_KEY'),
region: env('AWS_REGION'),
params: {
Bucket: env('AWS_BUCKET'),
},
...(env('AWS_ENDPOINT') && {
endpoint: env('AWS_ENDPOINT'),
s3ForcePathStyle: true,
}),
},
actionOptions: {
upload: {},
uploadStream: {},
delete: {},
},
},
},
})
Role-Based Access Control
Strapi has two RBAC systems:
Public API (content endpoints): Settings → Users & Permissions Plugin → Roles:
- Public: what unauthenticated users can access (e.g.,
findandfindOneon published posts) - Authenticated: what logged-in users can do (e.g., create comments)
Admin panel: Settings → Administration Panel → Roles:
- Super Admin: full access
- Editor: create/edit/publish content
- Author: create own content, can't publish
Nginx Config
server {
listen 80;
server_name cms.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name cms.example.com;
ssl_certificate /etc/letsencrypt/live/cms.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cms.example.com/privkey.pem;
client_max_body_size 100m;
location / {
proxy_pass http://127.0.0.1:1337;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
Webhooks for ISR and Cache Invalidation
Strapi webhooks fire on content events — perfect for triggering Next.js ISR revalidation:
Settings → Webhooks → Add a webhook:
- Name:
Next.js ISR - URL:
https://your-nextjs-site.com/api/revalidate - Events:
Entry.publish,Entry.unpublish,Entry.update
Next.js route handler:
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const secret = req.headers.get('authorization')
if (secret !== `Bearer ${process.env.REVALIDATION_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const contentType = body.model // e.g., 'post', 'page'
// Revalidate all pages of this content type
revalidateTag(contentType)
// Also revalidate the specific item if slug is available
if (body.entry?.slug) {
revalidatePath(`/blog/${body.entry.slug}`)
}
return NextResponse.json({ revalidated: true, model: contentType })
}
Set the webhook authorization header in Strapi to Bearer <your-revalidation-secret>.
Next.js Integration
// lib/strapi.ts
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337'
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN
async function fetchStrapi<T>(
endpoint: string,
params: Record<string, string> = {}
): Promise<T> {
const url = new URL(`${STRAPI_URL}/api/${endpoint}`)
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value))
const res = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${STRAPI_TOKEN}`,
'Content-Type': 'application/json',
},
next: { tags: [endpoint.split('/')[0]] }, // Next.js cache tag for revalidation
})
if (!res.ok) throw new Error(`Strapi API error: ${res.status} ${endpoint}`)
return res.json()
}
// Usage in a page component
export async function generateStaticParams() {
const { data } = await fetchStrapi<StrapiResponse<Post[]>>('posts', {
'fields[0]': 'slug',
'pagination[pageSize]': '100',
})
return data.map(post => ({ slug: post.attributes.slug }))
}
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
const { data } = await fetchStrapi<StrapiResponse<Post[]>>('posts', {
'filters[slug][$eq]': params.slug,
'populate[author][fields][0]': 'username',
'populate[cover][fields][0]': 'url',
})
if (!data.length) notFound()
const post = data[0].attributes
return (
<article>
<h1>{post.title}</h1>
{post.cover?.data && (
<img src={post.cover.data.attributes.url} alt={post.cover.data.attributes.alternativeText} />
)}
<BlocksRenderer content={post.content} />
</article>
)
}
The @strapi/blocks-react-renderer package renders Strapi's rich text blocks format into React components with full TypeScript support.
Upgrading Strapi
Strapi releases frequently — check GitHub releases for changelogs.
# Pull the latest Strapi image
docker compose pull strapi
# Run database migrations (Strapi v5 does this automatically on startup)
docker compose run --rm strapi node_modules/.bin/strapi migrate
# Restart with new image
docker compose up -d --force-recreate strapi
For major version upgrades (e.g., v4 → v5), consult the migration guide — breaking changes require code updates for custom plugins and lifecycle hooks.
Custom API Endpoints and Lifecycle Hooks
When the auto-generated REST endpoints aren't enough, add custom routes:
// src/api/post/routes/custom-post.ts
export default {
routes: [
{
method: 'GET',
path: '/posts/featured',
handler: 'post.getFeatured',
config: { auth: false }, // public endpoint
},
],
}
// src/api/post/controllers/post.ts
import { factories } from '@strapi/strapi'
export default factories.createCoreController('api::post.post', ({ strapi }) => ({
async getFeatured(ctx) {
const posts = await strapi.entityService.findMany('api::post.post', {
filters: { featured: true, publishedAt: { $notNull: true } },
sort: { publishedAt: 'desc' },
limit: 5,
populate: { cover: true, author: true },
})
return this.transformResponse(posts)
},
}))
Lifecycle hooks let you run code before/after database operations — useful for slug generation, email notifications, or cache invalidation:
// src/api/post/content-types/post/lifecycles.ts
export default {
async beforeCreate(event) {
const { data } = event.params
if (!data.slug && data.title) {
data.slug = data.title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
}
},
async afterPublish(event) {
// Notify Slack, trigger CDN purge, etc.
await fetch(process.env.SLACK_WEBHOOK!, {
method: 'POST',
body: JSON.stringify({ text: `New post published: ${event.result.title}` }),
})
},
}
Backup Strategy
#!/bin/bash
# /opt/strapi/backup.sh — run daily via cron
DATE=$(date +%Y%m%d-%H%M)
BACKUP_DIR=/opt/backups/strapi
mkdir -p $BACKUP_DIR
# Database dump
docker compose exec -T db pg_dump -U strapi strapi | gzip > "$BACKUP_DIR/db-$DATE.sql.gz"
# Media uploads (if not using S3)
tar -czf "$BACKUP_DIR/uploads-$DATE.tar.gz" /opt/strapi/uploads/
# Keep 14 days of backups
find $BACKUP_DIR -name "*.gz" -mtime +14 -delete
echo "Backup complete: $DATE"
If you configured S3 for media, the database backup alone is sufficient — media is already in object storage with its own versioning.
Cost vs Contentful
| Plan | Contentful | Strapi Self-Hosted |
|---|---|---|
| Entry-level | $300/month | ~€5/month (VPS) |
| Mid-tier | $1,000/month | ~€15/month (VPS) |
| Enterprise | $3,000+/month | ~€30/month (VPS) |
| API request limit | 10M/month (paid) | Unlimited |
| Content types | 48 (paid) | Unlimited |
The only cost of self-hosting Strapi is the VPS and your time for maintenance — typically 30-60 minutes/month for updates.
Methodology
- Strapi documentation: docs.strapi.io
- GitHub: github.com/strapi/strapi (65K+ stars)
- Tested with Strapi v5, Node.js 20, PostgreSQL 16, Docker Compose v2
Browse open source alternatives to Contentful and headless CMS tools on OSSAlt.
Related: How to Self-Host Directus — Open Source CMS and Data Platform 2026 · Best Open Source Alternatives to Firebase 2026