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.phpblocked- wp-admin IP-restricted or behind VPN
- Rate limiting on login endpoints
- WordPress debug mode off (
WP_DEBUG=false) - File permissions:
wp-contentwritable 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.
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.


