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 web network, and it connects to MariaDB via the private wp network. It is not directly reachable from the public internet.
  • MariaDB β€” exists only on the private wp network. 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 redirects www to this)
  • www.example.com (if you prefer www as the canonical form)

Create the following DNS records at your domain registrar or DNS provider, pointing to your server’s public IP address:

  • A record for example.com β†’ your server’s public IPv4 address
  • A record for www.example.com β†’ your server’s public IPv4 address
  • AAAA record (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.sh

    Add your user to the docker group so you can run Docker commands without sudo. Then apply the group membership to your current session without logging out:

    sudo usermod -aG docker $USER
    newgrp docker

    Verify the installation succeeded:

    docker --version

    Step 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:latest

    Port 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=always flag ensures Portainer starts automatically if the server reboots.

    Open the Portainer web UI in your browser:

    https://YOUR_SERVER_IP:9443

    You 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: true in 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 wp

    Create the persistent volumes:

    docker volume create caddy_data
    docker volume create db_data
    docker volume create wp_data

    Step 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/Caddyfile

    Paste the following configuration, replacing example.com and you@example.com with 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 tls block β€” 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 hostname wordpress using 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 same web Docker 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-caddy

    Select Web editor and paste the following Docker Compose file. Replace all placeholder passwords before deploying β€” use a password manager or openssl rand -base64 32 to 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: true

    Note that MARIADB_PASSWORD and WORDPRESS_DB_PASSWORD must be identical β€” WordPress uses this credential to authenticate to the database. MARIADB_ROOT_PASSWORD is 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.com from 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.php inside the WordPress container. The file lives in the wp_data volume. The easiest way to access it is via the Portainer console or by executing a shell in the WordPress container:

    docker exec -it wordpress bash

    Once inside the container, open wp-config.php:

    nano /var/www/html/wp-config.php

    Add 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-Proto header 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_ADMIN additionally 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.com

    You should see the WordPress installation wizard. Complete the following steps:

    1. Select your preferred language.
    2. Enter a site title and create an admin account with a strong, unique password. Do not use common usernames like admin or administrator.
    3. 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 shows http://, 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 in wp-config.php was 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 mysqldump via 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).sql

    This command reads the MARIADB_PASSWORD environment 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_data volume 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 32 and 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 pull in 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.com from an external machine.
    • Ports 80 or 443 are blocked in your firewall or cloud security group. Confirm with sudo ss -tlnp on 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 web network. Verify with docker network inspect web.
    • The database credentials in the Docker Compose file are incorrect, causing WordPress to fail on startup. Verify that WORDPRESS_DB_PASSWORD matches MARIADB_PASSWORD exactly.

    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’s search-replace command 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.