Skip to main content

How to Self-Host Strapi — Headless CMS 2026

·OSSAlt Team
strapiheadless-cmsself-hostingcontentful-alternativenodejs

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:

  1. Content-Type Builder → Create new collection type
  2. Name: Post
  3. 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 a Tag component with name: Text)
  4. 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., find and findOne on 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

PlanContentfulStrapi 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 limit10M/month (paid)Unlimited
Content types48 (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

Comments