Self-Hosting Strapi: A Headless CMS for Your Frontend Stack
You're building a website with Next.js, Astro, or Nuxt. You need a CMS so non-developers can manage content, but WordPress is a monolith tied to PHP, and you don't want to pay $300/month for Contentful. You need a headless CMS — something that manages content through an API while your frontend handles the presentation.
Strapi is the most popular open-source headless CMS. It gives you an admin panel for content editors, a flexible content modeling system, and REST + GraphQL APIs for your frontend. Self-hosting it means no per-seat fees, no API call limits, and full control over your data.
What Strapi Does
- Content type builder — Design your data structure visually (blog posts, products, pages, anything)
- Admin panel — A polished UI for content editors to create and manage content
- REST and GraphQL APIs — Auto-generated APIs for every content type
- Role-based access — Control who can create, edit, and publish content
- Media library — Upload and manage images, files, and videos
- Internationalization — Multi-language content support out of the box
The key concept: you define content types (like "Blog Post" with fields for title, body, author, featured image), and Strapi automatically generates the admin interface and API endpoints. No code required for basic setups.
Docker Deployment
# docker-compose.yml
services:
strapi:
image: strapi/strapi:latest
ports:
- "1337:1337"
volumes:
- strapi_data:/srv/app
environment:
DATABASE_CLIENT: postgres
DATABASE_HOST: db
DATABASE_PORT: 5432
DATABASE_NAME: strapi
DATABASE_USERNAME: strapi
DATABASE_PASSWORD: strapipass
DATABASE_SSL: "false"
APP_KEYS: "key1,key2,key3,key4"
API_TOKEN_SALT: "your-api-token-salt"
ADMIN_JWT_SECRET: "your-admin-jwt-secret"
JWT_SECRET: "your-jwt-secret"
TRANSFER_TOKEN_SALT: "your-transfer-token-salt"
depends_on:
- db
restart: unless-stopped
db:
image: postgres:16
volumes:
- strapi_db:/var/lib/postgresql/data
environment:
POSTGRES_DB: strapi
POSTGRES_USER: strapi
POSTGRES_PASSWORD: strapipass
restart: unless-stopped
volumes:
strapi_data:
strapi_db:
docker compose up -d
Strapi is now available at http://your-server:1337/admin. The first visit prompts you to create an admin account.
Important: Generate real random strings for APP_KEYS, API_TOKEN_SALT, ADMIN_JWT_SECRET, and JWT_SECRET. Use openssl rand -base64 32 to generate each one.
Building Content Types
The content type builder is Strapi's core feature. Access it at Content-Type Builder in the admin panel.
Example: Blog post content type
- Click Create new collection type
- Name it "Article"
- Add fields:
- title (Text, required)
- slug (UID, attached to title)
- body (Rich text)
- excerpt (Text, short)
- featuredImage (Media, single)
- category (Relation, many-to-one with Categories)
- publishedDate (Date)
Strapi generates the database tables and API endpoints automatically. You now have:
GET /api/articles— List all articlesGET /api/articles/:id— Get a single articlePOST /api/articles— Create an articlePUT /api/articles/:id— Update an articleDELETE /api/articles/:id— Delete an article
API Access and Permissions
By default, Strapi's API endpoints are locked down. You need to configure permissions:
- Go to Settings > Users & Permissions > Roles
- Click Public
- Under your content type, enable
findandfindOne - Click Save
Now unauthenticated requests can read your content:
curl 'http://localhost:1337/api/articles?populate=*'
The populate=* parameter includes related content (like the featured image and category).
API tokens for authenticated access
For write operations or private content, create an API token:
- Go to Settings > API Tokens
- Click Create new API Token
- Set permissions (full access or custom)
- Copy the generated token
curl 'http://localhost:1337/api/articles' \
-X POST \
-H 'Authorization: Bearer your-api-token' \
-H 'Content-Type: application/json' \
-d '{"data": {"title": "My First Post", "body": "Hello world"}}'
GraphQL Support
Install the GraphQL plugin (it's built-in, just needs enabling):
# Inside the Strapi container
npm run strapi install graphql
Then query your content with GraphQL:
query {
articles {
data {
attributes {
title
body
publishedDate
category {
data {
attributes {
name
}
}
}
}
}
}
}
Access the GraphQL playground at http://localhost:1337/graphql.
Media Handling
By default, Strapi stores media files locally. For production, you'll want external storage:
S3-compatible storage
Install the AWS S3 provider:
npm install @strapi/provider-upload-aws-s3
Configure in config/plugins.js:
module.exports = {
upload: {
config: {
provider: 'aws-s3',
providerOptions: {
s3Options: {
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
region: process.env.AWS_REGION,
params: {
Bucket: process.env.AWS_BUCKET,
},
},
},
},
},
};
This works with any S3-compatible storage: AWS S3, Backblaze B2, MinIO, or Cloudflare R2.
Strapi vs Other Headless CMS Options
| Feature | Strapi | Directus | Ghost | WordPress (headless) |
|---|---|---|---|---|
| Content modeling | Visual builder | Database-driven | Fixed blog schema | Plugins |
| API output | REST + GraphQL | REST + GraphQL | Content API | REST |
| Admin UI | Modern | Modern | Excellent (writing) | Dated |
| Self-hosted | Yes (free) | Yes (free) | Yes (free) | Yes (free) |
| Media handling | Built-in | Built-in | Built-in | Built-in |
| Roles/permissions | Granular | Granular | Basic | Plugin-dependent |
| Use case | Any content | Any content | Blogs/newsletters | Blogs/general |
| Extensibility | Plugins + code | Extensions | Themes + API | Massive ecosystem |
When to choose Strapi
- You need custom content types (not just blog posts)
- You're building a frontend with a modern JS framework
- You want non-developers to manage content through an admin panel
- You need both REST and GraphQL APIs
When to choose something else
- Directus if you want to put a CMS layer on an existing database
- Ghost if you're specifically building a blog or newsletter
- WordPress if you need the massive plugin ecosystem
Production Tips
Reverse proxy configuration
Put Strapi behind Caddy or Nginx:
cms.yourdomain.com {
reverse_proxy strapi:1337
}
Environment-specific configuration
Strapi reads from environment variables for production settings. Key ones:
NODE_ENV=production
HOST=0.0.0.0
PORT=1337
URL=https://cms.yourdomain.com
Backup strategy
Back up two things:
- PostgreSQL database —
pg_dumpon a schedule - Upload directory — Media files (if using local storage)
# Database backup
docker exec strapi-db-1 pg_dump -U strapi strapi > strapi-backup.sql
The Bottom Line
Strapi is the go-to self-hosted headless CMS for teams that need flexible content modeling and a clean admin interface. It sits between "just use a database" and "pay for a managed CMS service." If you're building a content-driven site with a modern frontend framework and want non-developers to manage the content, Strapi gives you the admin panel, the API, and the content modeling tools without monthly fees or vendor lock-in.