Skip to main content
Adzbyte
DevelopmentSecurityWordPress

Deploying Headless WordPress: Docker, Nginx, and SSL

Adrian Saycon
Adrian Saycon
March 28, 20263 min read
Deploying Headless WordPress: Docker, Nginx, and SSL

Running headless WordPress in production means two services that need to coexist: WordPress serving the REST API, and your frontend (Next.js, Nuxt, whatever) serving the actual site. Docker Compose makes this manageable, and Nginx ties everything together with SSL and reverse proxying.

Docker Compose: The Foundation

Here’s the full docker-compose.yml I use for production headless WordPress. Three services: MariaDB, WordPress, and Nginx as the reverse proxy.

version: "3.8"

services:
  db:
    image: mariadb:11
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wp_user
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - db_data:/var/lib/mysql
    networks:
      - backend

  wordpress:
    image: wordpress:6.7-php8.3-fpm
    restart: always
    depends_on:
      - db
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: wp_user
      WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
      WORDPRESS_DB_NAME: wordpress
    volumes:
      - wp_data:/var/www/html
      - ./wp-content/themes:/var/www/html/wp-content/themes
      - ./wp-content/plugins:/var/www/html/wp-content/plugins
      - ./php.ini:/usr/local/etc/php/conf.d/custom.ini
    networks:
      - backend

  nginx:
    image: nginx:alpine
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d
      - ./certbot/www:/var/www/certbot
      - ./certbot/conf:/etc/letsencrypt
      - wp_data:/var/www/html
    depends_on:
      - wordpress
    networks:
      - backend

volumes:
  db_data:
  wp_data:

networks:
  backend:

Note the wordpress:6.7-php8.3-fpm image — the FPM variant doesn’t include Apache, which is what you want when Nginx handles HTTP.

Nginx Configuration

Nginx serves two roles: reverse proxy for WordPress (API backend) and optionally for your Next.js frontend. Here’s the WordPress backend config:

# nginx/conf.d/wordpress.conf
server {
    listen 443 ssl;
    server_name wp.example.com;

    ssl_certificate /etc/letsencrypt/live/wp.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/wp.example.com/privkey.pem;

    root /var/www/html;
    index index.php;

    # Allow REST API
    location /wp-json/ {
        try_files $uri $uri/ /index.php?$args;

        # CORS for your frontend domain
        add_header Access-Control-Allow-Origin "https://example.com" always;
        add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
    }

    # Allow wp-admin access (restrict by IP in production)
    location /wp-admin {
        try_files $uri $uri/ /index.php?$args;
    }

    # PHP-FPM handling
    location ~ .php$ {
        fastcgi_pass wordpress:9000;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    # Block everything else from public access
    location / {
        # Redirect frontend requests to the actual frontend
        return 301 https://example.com$request_uri;
    }

    # Block xmlrpc (common attack vector)
    location = /xmlrpc.php {
        deny all;
    }
}

SSL With Let’s Encrypt

I use Certbot in standalone mode for initial certificate generation, then a cron job for renewals:

# Initial certificate (run before starting nginx with SSL)
docker run --rm -p 80:80 
  -v ./certbot/conf:/etc/letsencrypt 
  -v ./certbot/www:/var/www/certbot 
  certbot/certbot certonly 
    --standalone 
    -d wp.example.com 
    --email you@example.com 
    --agree-tos

# Auto-renewal cron (add to crontab)
0 3 * * * docker run --rm 
  -v ./certbot/conf:/etc/letsencrypt 
  -v ./certbot/www:/var/www/certbot 
  certbot/certbot renew --quiet 
  && docker compose exec nginx nginx -s reload

Security Hardening

A headless WordPress backend is a smaller attack surface than a traditional WP site, but it still needs protection:

# Rate limiting for wp-login.php
limit_req_zone $binary_remote_addr zone=login:10m rate=3r/m;

server {
    # ... SSL config ...

    # Rate limit login attempts
    location = /wp-login.php {
        limit_req zone=login burst=3 nodelay;
        fastcgi_pass wordpress:9000;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    # Hide WordPress version
    location ~ /readme.html$ {
        deny all;
    }

    # Disable file listing
    autoindex off;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}

For additional lockdown, restrict /wp-admin access to specific IP addresses using allow/deny directives. In a headless setup, only your content editors need wp-admin access — the public never touches it.

Environment Variables

Store secrets in a .env file that’s never committed to git:

# .env
DB_ROOT_PASSWORD=your-secure-root-password
DB_PASSWORD=your-secure-wp-password
WORDPRESS_AUTH_KEY=generate-with-wp-salt-api
WORDPRESS_SECURE_AUTH_KEY=generate-with-wp-salt-api

Generate WordPress salts at https://api.wordpress.org/secret-key/1.1/salt/ and pass them as environment variables in the WordPress service.

Deployment Checklist

  • Database backups automated (daily mysqldump via cron)
  • SSL certificates auto-renewing
  • xmlrpc.php blocked
  • wp-admin IP-restricted or behind VPN
  • Rate limiting on login endpoints
  • WordPress debug mode off (WP_DEBUG=false)
  • File permissions: wp-content writable by www-data only
  • REST API CORS configured for your frontend domain only

This setup has been running my production headless WordPress for over a year. The Docker containers restart automatically, SSL renews itself, and the Nginx config keeps the attack surface minimal. Once it’s running, you rarely need to touch it.

Adrian Saycon

Written by

Adrian Saycon

A developer with a passion for emerging technologies, Adrian Saycon focuses on transforming the latest tech trends into great, functional products.

Discussion (0)

Sign in to join the discussion

No comments yet. Be the first to share your thoughts.