Running WordPress behind a modern, automatic HTTPS reverse proxy is more accessible than ever with Docker, Portainer, and Caddy. What used to require manual certificate management, complex nginx configurations, and careful coordination between services can now be accomplished with a single Docker Compose file and a handful of shell commands.
In this guide, you will deploy a complete, production-ready WordPress stack consisting of four components:
- Caddy β a reverse proxy that automatically provisions and renews TLS certificates from Let’s Encrypt, with zero manual certificate management required.
- WordPress β the application container, isolated from the public internet and accessible only through Caddy.
- MariaDB β the database container, running on a private internal network with no public exposure whatsoever.
- Portainer β a web-based Docker management interface that makes it easy to deploy, monitor, and update your stack without using the command line for every operation.
By the end of this guide, you will have a secure, production-ready WordPress site running at your domain, with automatic certificate issuance and renewal, clean container isolation between services, and persistent data volumes that survive container restarts and upgrades.
Architecture Overview
The stack uses three application services with a carefully designed network boundary between them:
- Caddy β the only public-facing service. It listens on ports 80 and 443, handles all TLS termination, automatically provisions and renews Let’s Encrypt certificates, and reverse-proxies decrypted traffic to the WordPress container on the internal network.
- WordPress β sits between the two networks. It is reachable from Caddy via the shared
webnetwork, and it connects to MariaDB via the privatewpnetwork. It is not directly reachable from the public internet. - MariaDB β exists only on the private
wpnetwork. It is completely invisible to the public internet and reachable only by the WordPress container. This is the correct security posture for a database server.
Networking Model
Two separate Docker networks enforce the service boundary:
webβ shared by Caddy and WordPress. This is the path public traffic takes after TLS termination.wpβ private network shared only by WordPress and MariaDB. No other container can reach the database.
Three persistent Docker volumes preserve data across container restarts and image upgrades:
caddy_dataβ stores Caddy’s TLS certificates and ACME account state. If this volume is lost, Caddy will re-issue certificates from Let’s Encrypt on the next startup, which may temporarily hit rate limits.db_dataβ stores all MariaDB database files. Losing this volume means losing all WordPress content. Back this up regularly.wp_dataβ stores WordPress core files, themes, plugins, and uploaded media. This volume must also be included in any backup strategy.
Prerequisites
Complete and verify the following before beginning. Skipping these checks is the most common reason this deployment fails on the first attempt.
1. Domain and DNS
Decide on your canonical hostname before deploying. This matters because WordPress stores its site URL in the database during installation, and changing it later requires additional database edits. Choose one of the following and use it consistently throughout this guide:
example.com(apex domain β the Caddyfile below redirectswwwto this)www.example.com(if you preferwwwas the canonical form)
Create the following DNS records at your domain registrar or DNS provider, pointing to your server’s public IP address:
Arecord forexample.comβ your server’s public IPv4 addressArecord forwww.example.comβ your server’s public IPv4 addressAAAArecord (optional) β your server’s IPv6 address, if available
Confirm that DNS is resolving correctly before proceeding. Caddy uses Let’s Encrypt’s HTTP-01 challenge to issue certificates, which requires your domain to resolve to your server’s public IP at the time of issuance:
dig example.com
dig www.example.com
Both should return your server’s public IP in the answer section. DNS propagation can take anywhere from a few minutes to several hours depending on your provider and TTL settings. Do not proceed until this resolves correctly β Caddy’s certificate issuance will fail silently if DNS is not yet pointing to the right server.
2. Server Requirements
- A Linux server running Ubuntu or Debian (other distributions will work but these are the most tested with this stack).
- A public IP address reachable from the internet.
- The following ports open in your firewall or cloud security group:
80/tcpβ required for Let’s Encrypt HTTP-01 certificate challenges and HTTP-to-HTTPS redirects.443/tcpβ required for all HTTPS traffic.9443/tcpβ required for the Portainer web UI. Consider restricting this to your IP address only using your firewall, since Portainer has full Docker control.
Verify that no existing web server (Apache, nginx, or another Caddy instance) is already bound to ports 80 or 443 on the host. Docker will fail to start the Caddy container if those ports are already in use:
sudo ss -tlnp | grep -E ':80|:443'If anything is returned, stop the conflicting service before continuing.
Step 1 β Install Docker
Use Docker’s official installation script, which automatically detects your Linux distribution and installs the correct version:
curl -fsSL https://get.docker.com -o get-docker.sh sudo sh get-docker.shAdd your user to the
dockergroup so you can run Docker commands withoutsudo. Then apply the group membership to your current session without logging out:sudo usermod -aG docker $USER newgrp dockerVerify the installation succeeded:
docker --versionStep 2 β Install Portainer
Portainer runs as a Docker container itself and manages all other Docker containers on the host through the Docker socket. Create a persistent volume for its data first, then run the container:
docker volume create portainer_data docker run -d \ -p 8000:8000 \ -p 9443:9443 \ --name portainer \ --restart=always \ -v /var/run/docker.sock:/var/run/docker.sock \ -v portainer_data:/data \ portainer/portainer-ce:latestPort 8000 is used for Portainer’s agent communication (only needed if you are managing remote Docker hosts). Port 9443 is the HTTPS web UI. The
--restart=alwaysflag ensures Portainer starts automatically if the server reboots.Open the Portainer web UI in your browser:
https://YOUR_SERVER_IP:9443You will see a browser warning about Portainer’s self-signed certificate β this is expected and safe to bypass for the initial setup. Complete the admin account creation form and select Local as the Docker environment when prompted. Portainer will then connect to your local Docker daemon and display all running containers.
Step 3 β Create Docker Networks and Volumes
The networks and volumes used by the stack are declared as
external: truein the Docker Compose file, which means they must be created manually before the stack is deployed. Docker Compose will error out if it cannot find them at deploy time.Create the networks:
docker network create web docker network create wpCreate the persistent volumes:
docker volume create caddy_data docker volume create db_data docker volume create wp_dataStep 4 β Create the Caddyfile
The Caddyfile is Caddy’s configuration file. It defines which domains to serve, how to handle TLS, and where to forward traffic. Create the directory and file:
sudo mkdir -p /opt/caddy sudo nano /opt/caddy/CaddyfilePaste the following configuration, replacing
example.comandyou@example.comwith your actual domain and email address:{ email you@example.com # Used by Let's Encrypt for certificate expiry notifications } www.example.com { redir https://example.com{uri} permanent # Redirect www to apex with 301 } example.com { encode zstd gzip # Enable response compression for faster page loads header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" Referrer-Policy "strict-origin-when-cross-origin" } reverse_proxy wordpress:80 { header_up X-Forwarded-Proto {scheme} header_up X-Forwarded-Host {host} } }A few notes on this configuration:
- TLS is fully automatic. You do not need to specify a
tlsblock β Caddy provisions and renews Let’s Encrypt certificates automatically for any site block that uses a public domain name. The email address in the global block is passed to Let’s Encrypt and used only for certificate expiry warnings. - The security headers (
Strict-Transport-Security,X-Content-Type-Options,Referrer-Policy) are applied to all responses and provide meaningful browser-level security improvements with no performance cost. reverse_proxy wordpress:80β Caddy resolves the hostnamewordpressusing Docker’s internal DNS, which resolves container names to their internal IP addresses. This works because both the Caddy and WordPress containers are on the samewebDocker network.
Step 5 β Deploy the WordPress Stack in Portainer
In the Portainer web UI, navigate to Stacks β Add Stack. Give the stack a descriptive name:
wordpress-with-caddySelect Web editor and paste the following Docker Compose file. Replace all placeholder passwords before deploying β use a password manager or
openssl rand -base64 32to generate strong random values:version: "3.8" services: caddy: image: caddy:2 container_name: caddy restart: unless-stopped ports: - "80:80" - "443:443" volumes: - caddy_data:/data - /opt/caddy/Caddyfile:/etc/caddy/Caddyfile:ro networks: - web db: image: mariadb:11 container_name: wp-db restart: unless-stopped environment: MARIADB_DATABASE: wordpress MARIADB_USER: wp MARIADB_PASSWORD: CHANGE_ME_STRONG MARIADB_ROOT_PASSWORD: CHANGE_ME_VERY_STRONG volumes: - db_data:/var/lib/mysql networks: - wp wordpress: image: wordpress:6-php8.2-apache container_name: wordpress restart: unless-stopped depends_on: - db environment: WORDPRESS_DB_HOST: db:3306 WORDPRESS_DB_USER: wp WORDPRESS_DB_PASSWORD: CHANGE_ME_STRONG WORDPRESS_DB_NAME: wordpress volumes: - wp_data:/var/www/html networks: - web - wp volumes: caddy_data: external: true db_data: external: true wp_data: external: true networks: web: external: true wp: external: trueNote that
MARIADB_PASSWORDandWORDPRESS_DB_PASSWORDmust be identical β WordPress uses this credential to authenticate to the database.MARIADB_ROOT_PASSWORDis separate and should be a different, equally strong value stored securely.Click Deploy the Stack. Portainer will pull the images and start all three containers. The initial image pull may take a minute or two depending on your server’s internet connection speed.
Step 6 β Verify Certificate Issuance
In Portainer, navigate to Containers β caddy β Logs. Caddy begins attempting to provision certificates immediately on startup. Within 30β60 seconds of the container starting, you should see log lines similar to the following, confirming successful certificate issuance:
{"level":"info","msg":"certificate obtained successfully","identifier":"example.com"}If you see errors instead, work through the following in order β each is a separate root cause with a distinct fix:
- DNS not resolving to this server β run
dig example.comfrom a machine outside your network and confirm the returned IP matches your server. Wait for propagation if needed. - Ports 80 or 443 blocked β Let’s Encrypt’s HTTP-01 challenge requires port 80 to be reachable from the internet. Check your cloud provider’s security group or firewall rules.
- Another service is using port 80 or 443 β run
sudo ss -tlnp | grep -E ':80|:443'on the host to identify any conflicting processes.
After resolving any issue, restart only the Caddy container (not the entire stack) to trigger a new certificate issuance attempt.
Step 7 β Configure WordPress for HTTPS Behind a Reverse Proxy
This step is critical and must not be skipped. Because Caddy terminates TLS before forwarding requests to WordPress, the WordPress container itself receives plain HTTP traffic on port 80. Without additional configuration, WordPress does not know that the original client request arrived over HTTPS, and it will generate HTTP-prefixed URLs for its own assets, admin links, and login redirects.
The symptoms of skipping this step are: mixed content warnings in the browser console, the admin login page redirecting in a loop, and secure cookies not being set correctly.
To fix this, you need to edit
wp-config.phpinside the WordPress container. The file lives in thewp_datavolume. The easiest way to access it is via the Portainer console or by executing a shell in the WordPress container:docker exec -it wordpress bashOnce inside the container, open
wp-config.php:nano /var/www/html/wp-config.phpAdd the following block immediately above the line that reads
/* That's all, stop editing! Happy publishing. */:if ( ( ! empty( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https' ) || ( ! empty( $_SERVER['HTTP_X_FORWARDED_SSL'] ) && $_SERVER['HTTP_X_FORWARDED_SSL'] === 'on' ) ) { $_SERVER['HTTPS'] = 'on'; $_SERVER['SERVER_PORT'] = 443; } define( 'FORCE_SSL_ADMIN', true );This code reads the
X-Forwarded-Protoheader that Caddy sets on every proxied request (as configured in the Caddyfile) and uses it to tell PHP β and therefore WordPress β that the original connection was HTTPS.FORCE_SSL_ADMINadditionally ensures that the WordPress admin panel always enforces HTTPS for its own redirects.Step 8 β Complete the WordPress Installation
Visit your domain in a browser:
https://example.comYou should see the WordPress installation wizard. Complete the following steps:
- Select your preferred language.
- Enter a site title and create an admin account with a strong, unique password. Do not use common usernames like
adminoradministrator. - Enter a valid email address for the admin account. WordPress uses this for password recovery and notification emails.
After completing the installation wizard and logging into the admin dashboard, perform the following checks:
- Navigate to Settings β General and confirm that both the WordPress Address (URL) and Site Address (URL) fields begin with
https://. If either showshttp://, update them manually and save. - Navigate to Settings β Permalinks and click Save Changes without modifying anything. This flushes the rewrite rules and resolves many 404 errors that appear on fresh installations.
- Upload a test image via Media β Add New and confirm that the resulting image URL in the browser address bar begins with
https://. A mixed-content warning at this stage indicates the proxy HTTPS snippet inwp-config.phpwas not saved correctly.
Backups
A WordPress installation has two independent components that must both be backed up: the database (which contains all posts, pages, settings, users, and plugin data) and the file volume (which contains themes, plugins, and uploaded media). Backing up only one of them is not sufficient to restore a working site.
Database Backup
Use
mysqldumpvia the running MariaDB container to create a dated SQL dump file on the host:docker exec wp-db sh -c 'exec mysqldump -u wp -p"$MARIADB_PASSWORD" wordpress' > wp-$(date +%F).sqlThis command reads the
MARIADB_PASSWORDenvironment variable from inside the container, so you do not need to hardcode the password in the command. Run this on a schedule using a cron job and transfer the resulting SQL files to offsite storage.WordPress File Backup
The
wp_datavolume contains everything WordPress needs beyond the database: the core WordPress files, all installed themes and plugins, and all uploaded media. Archive this volume regularly using a tool appropriate for your hosting environment. For a simple approach:docker run --rm -v wp_data:/source -v $(pwd):/backup alpine \ tar czf /backup/wp-files-$(date +%F).tar.gz -C /source .Hardening Recommendations
This stack is secure by default in its network design, but the following additional measures are worth implementing before treating the site as production-ready:
- Use strong, unique database passwords. Generate them with
openssl rand -base64 32and store them in a password manager. Never reuse passwords between the WordPress database user and the MariaDB root account. - Keep Docker images updated regularly. Run
docker compose pullin Portainer or via the command line periodically to pull updated images for WordPress, MariaDB, and Caddy. Redeploy the stack after pulling. - Restrict Portainer access by IP. Port 9443 gives full Docker control to anyone who can authenticate. If your firewall supports it, restrict this port to your own IP address or VPN subnet.
- Enable two-factor authentication in WordPress. Install a 2FA plugin for the admin account before the site goes live. A compromised admin account gives full access to the WordPress database and file system.
- Remove unused plugins and themes. Every installed plugin and theme is a potential attack surface, even if deactivated. Remove anything that is not actively in use.
- Disable XML-RPC if you are not using it. XML-RPC is a legacy remote publishing interface that is frequently targeted by brute-force attacks. If you are not using the WordPress mobile app or Jetpack, disabling it reduces your attack surface at no cost.
Troubleshooting
Caddy Cannot Issue Certificates
Certificate issuance fails when Let’s Encrypt cannot reach your server to complete the HTTP-01 challenge. Check the following in order:
- DNS has not yet propagated or points to the wrong IP. Verify with
dig example.comfrom an external machine. - Ports 80 or 443 are blocked in your firewall or cloud security group. Confirm with
sudo ss -tlnpon the host. - Another service on the host is already bound to port 80 or 443, preventing Caddy from starting. Identify it with
sudo ss -tlnp | grep -E ':80|:443'and stop it.
502 or 504 Gateway Errors
These errors mean Caddy is running but cannot reach the WordPress container. Check the following:
- The WordPress container has exited or is still starting up. Check its status in Portainer and review its logs for errors.
- The Caddy and WordPress containers are not on the same Docker network. Both must be connected to the
webnetwork. Verify withdocker network inspect web. - The database credentials in the Docker Compose file are incorrect, causing WordPress to fail on startup. Verify that
WORDPRESS_DB_PASSWORDmatchesMARIADB_PASSWORDexactly.
Mixed Content Warnings
Mixed content warnings appear when an HTTPS page loads resources (images, scripts, stylesheets) over HTTP. There are two common causes in this setup:
- The proxy HTTPS snippet was not added to
wp-config.php, or was added in the wrong location. Verify it appears above the “stop editing” comment line and that the file was saved correctly. - Hardcoded
http://URLs exist in the WordPress database β for example, from a previous installation or a migrated site. Use the Better Search Replace plugin or WP-CLI’ssearch-replacecommand to update all occurrences in the database.
What You Have Built
You now have a complete, production-ready WordPress deployment with the following properties:
- Automatic HTTPS via Caddy and Let’s Encrypt, with zero manual certificate management and automatic renewal before expiry.
- Network-isolated database that is completely unreachable from the public internet and accessible only to the WordPress container.
- Persistent data volumes that survive container restarts, image upgrades, and stack redeployments.
- Centralized stack management via Portainer, allowing you to update images, view logs, and restart services without SSH access.
- Security headers applied to all responses by Caddy, improving your site’s browser security posture with no plugin required.
This architecture is designed to be extended. When you are ready to scale or add capabilities, consider the following additions:
- Redis object caching β add a Redis container and connect it to WordPress using the Redis Object Cache plugin to dramatically reduce database query load on high-traffic sites.
- Staging environment β deploy a second WordPress stack on the same host using a different domain and volume set, giving you a safe environment to test plugin updates and theme changes before applying them to production.
- Automated backups β schedule the database dump and file archive commands from the Backups section above using cron or a dedicated backup tool, with automatic offsite transfer to S3-compatible storage.
- CI/CD deployment pipeline β connect your WordPress theme or plugin development repository to an automated deployment pipeline that pushes changes to the container on merge to your main branch.
Richard Applegate
Comments (0)
No comments yet. Be the first!