Docker Multi-Site Hosting with Nginx Proxy, WordPress, Home Assistant & Frigate

Written by

in

This post covers the full migration of a Rocky Linux server from a traditional Apache virtual host setup to a Docker-based stack. The goal was to host multiple sites cleanly, with a single Nginx reverse proxy handling SSL termination and routing, while also running Home Assistant and Frigate as isolated containers that communicate over a private internal network.

What started as a simple “move WordPress to Docker” turned into a full network architecture exercise. This post covers every step including the gotchas and there were plenty.

Architecture

The final setup uses three Docker networks to isolate concerns:

Cloudflare --> Host :80/:443 --> proxy_nginx (container)
                                         |
                                     proxy_net
                                         |
                          ---------------+---------------
                          |                             |
                    adminwins_wp                 homeassistant
                          |                             |
                       internal                      ha_net
                          |                    ---------+----------
                    adminwins_db               |                   |
                                           frigate             mosquitto
Network Containers Purpose
proxy_net proxy_nginx, adminwins_wp, homeassistant External traffic routing
internal adminwins_wp, adminwins_db WordPress to MariaDB only
ha_net homeassistant, frigate, mosquitto HA, Frigate and MQTT private comms

Prerequisites

  • Rocky Linux with Docker and Docker Compose installed and working
  • Domain managed in Cloudflare with a scoped API token (Zone DNS Edit + Zone Read)
  • Apache stopped and disabled: sudo systemctl stop httpd && sudo systemctl disable httpd
  • Existing Docker daemon.json preserved – merge new settings into it, do not replace it

Directory Structure

mkdir -p /mnt/raid5/docker/proxy/{conf.d,certbot/{conf,www}}
mkdir -p /mnt/raid5/docker/adminwins/{wordpress,db}

/mnt/raid5/docker/
├── proxy/
│   ├── docker-compose.yml
│   ├── renew-certs.sh
│   ├── conf.d/
│   │   └── adminwins.conf
│   └── certbot/
│       ├── cloudflare.ini
│       ├── conf/
│       └── www/
└── adminwins/
    ├── docker-compose.yml
    ├── wordpress/
    └── db/

Create Docker Networks

docker network create proxy_net
docker network create ha_net

SSL Certificates via Cloudflare DNS Challenge

Since the server is behind NAT, the HTTP challenge method will not work reliably. The DNS-01 challenge via Cloudflare is far cleaner – no port 80 dependency, works behind any firewall.

Important: Certbot containers must use –network host on this server. Bridge networks cause DNS resolution failures when reaching Let’s Encrypt.

dns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKEN
docker run --rm   --network host   -v /mnt/raid5/docker/proxy/certbot/conf:/etc/letsencrypt   -v /mnt/raid5/docker/proxy/certbot/www:/var/www/certbot   -v /mnt/raid5/docker/proxy/certbot/cloudflare.ini:/cloudflare.ini:ro   certbot/dns-cloudflare certonly   --dns-cloudflare   --dns-cloudflare-credentials /cloudflare.ini   --dns-cloudflare-propagation-seconds 60   -d adminwins.com -d www.adminwins.com   --email [email protected]   --agree-tos --no-eff-email

Shared Nginx Reverse Proxy

proxy/docker-compose.yml

services:
  nginx:
    image: nginx:alpine
    container_name: proxy_nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./conf.d:/etc/nginx/conf.d
      - ./certbot/conf:/etc/letsencrypt
      - ./certbot/www:/var/www/certbot
    networks:
      - proxy_net

networks:
  proxy_net:
    external: true

proxy/conf.d/adminwins.conf

server {
    listen 80;
    server_name adminwins.com www.adminwins.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name adminwins.com www.adminwins.com;

    ssl_certificate     /etc/letsencrypt/live/adminwins.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/adminwins.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;
    client_max_body_size 64M;

    location / {
        proxy_pass          http://adminwins_wp:80;
        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;
    }
}

WordPress

Key Setting: FS_METHOD direct must be set or WordPress will prompt for FTP credentials every time you install a theme or plugin. This is a common Docker gotcha.

services:
  db:
    image: mariadb:11
    container_name: adminwins_db
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: changeme_root
      MYSQL_DATABASE: adminwins
      MYSQL_USER: adminwins
      MYSQL_PASSWORD: changeme_db
    volumes:
      - ./db:/var/lib/mysql
    networks:
      - internal

  wordpress:
    image: wordpress:latest
    container_name: adminwins_wp
    restart: unless-stopped
    depends_on:
      - db
    environment:
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_NAME: adminwins
      WORDPRESS_DB_USER: adminwins
      WORDPRESS_DB_PASSWORD: changeme_db
      WORDPRESS_CONFIG_EXTRA: |
        define('FS_METHOD', 'direct');
    volumes:
      - ./wordpress:/var/www/html
    networks:
      - internal
      - proxy_net

networks:
  internal:
    driver: bridge
  proxy_net:
    external: true

Home Assistant

Home Assistant was previously running with network_mode: host. Moving it to named networks requires explicitly joining both proxy_net and ha_net.

Gotcha: docker compose restart does NOT apply network changes. Always use docker compose up -d –force-recreate.

services:
  homeassistant:
    container_name: homeassistant
    image: "ghcr.io/home-assistant/home-assistant:stable"
    volumes:
      - /mnt/raid5/docker/homeassistant/config:/config
      - /etc/localtime:/etc/localtime:ro
      - /run/dbus:/run/dbus:ro
    restart: unless-stopped
    privileged: true
    networks:
      - proxy_net
      - ha_net

networks:
  proxy_net:
    external: true
  ha_net:
    external: true

Common Mistake: Do not nest external_url and internal_url under default_config. They must sit under their own homeassistant: key.

default_config:

homeassistant:
  external_url: "https://YOUR_HA_SUBDOMAIN.yourdomain.com"
  internal_url: "http://YOUR_SERVER_LOCAL_IP:8123"

http:
  use_x_forwarded_for: true
  trusted_proxies:
    - 172.16.0.0/12
    - 127.0.0.1

Frigate

Gotcha: Frigate ports must be explicitly bound to 0.0.0.0. Without this, HA can resolve the container name but the connection is refused at the port level.

services:
  frigate:
    container_name: frigate
    image: ghcr.io/blakeblackshear/frigate:stable
    restart: unless-stopped
    shm_size: "256mb"
    volumes:
      - /etc/localtime:/etc/localtime
      - /mnt/raid5/docker/frigate/config/:/config/
      - /mnt/raid5/docker/frigate/cctv:/media/frigate
      - type: tmpfs
        target: /tmp/cache
        tmpfs:
          size: 1000000000
    ports:
      - "0.0.0.0:5000:5000"
      - "0.0.0.0:1935:1935"
      - "0.0.0.0:8554:8554"
      - "0.0.0.0:8555:8555/tcp"
      - "0.0.0.0:8555:8555/udp"
    environment:
      FRIGATE_RTSP_PASSWORD: "your_password"
    networks:
      - ha_net

networks:
  ha_net:
    external: true

Mosquitto MQTT

services:
  mosquitto:
    image: eclipse-mosquitto
    container_name: mosquitto
    restart: unless-stopped
    volumes:
      - /mnt/raid5/docker/mosquitto/config:/mosquitto/config
      - /mnt/raid5/docker/mosquitto/data:/mosquitto/data
      - /mnt/raid5/docker/mosquitto/log:/mosquitto/log
    ports:
      - 1883:1883
      - 9001:9001
    networks:
      - ha_net

networks:
  ha_net:
    external: true

SSL Certificate Autorenewal

#!/bin/bash
docker run --rm   --network host   -v /mnt/raid5/docker/proxy/certbot/conf:/etc/letsencrypt   -v /mnt/raid5/docker/proxy/certbot/www:/var/www/certbot   -v /mnt/raid5/docker/proxy/certbot/cloudflare.ini:/cloudflare.ini:ro   certbot/dns-cloudflare renew   --dns-cloudflare   --dns-cloudflare-credentials /cloudflare.ini   --quiet

docker exec proxy_nginx nginx -s reload
0 3 * * * /mnt/raid5/docker/proxy/renew-certs.sh >> /var/log/certbot-renew.log 2>&1

Adding a New Site

  1. Get a cert: run the Certbot docker run command with the new domain
  2. Create a site directory with its own docker-compose.yml joining proxy_net
  3. Add a new .conf file to proxy/conf.d/
  4. Start the site and reload Nginx: docker exec proxy_nginx nginx -s reload

Lessons Learned

  1. daemon.json is additive, not replaceable. Always merge new settings into the existing JSON object – replacing it wipes existing Docker config.
  2. Certbot needs –network host. Bridge networks cause DNS resolution failures when reaching Let’s Encrypt on this server.
  3. docker compose restart does not apply network changes. Always use up -d –force-recreate when adding a container to a new network.
  4. YAML indentation breaks silently. A single extra space causes validation errors. All keys at the same service level must have identical indentation.
  5. Frigate ports need explicit 0.0.0.0 binding. Without it, container-to-container connections are refused even when the container name resolves correctly.
  6. APACHE_LOG_DIR is Debian-only. On Rocky Linux use the hardcoded path /etc/httpd/logs/ in all VirtualHost configs.
  7. SELinux blocks non-standard document roots. Fix with semanage fcontext and restorecon before assuming a permissions issue.

Final Checklist

  • Apache stopped and disabled
  • proxy_net and ha_net created
  • cloudflare.ini created and chmod 600
  • SSL certs obtained for all domains
  • Proxy stack running: proxy_nginx
  • AdminWins stack running: adminwins_wp + adminwins_db
  • Home Assistant on both proxy_net and ha_net
  • Frigate on ha_net with 0.0.0.0 port binding
  • Mosquitto on ha_net
  • HA configuration.yaml URLs and trusted proxies set correctly
  • MQTT broker set to container name in HA config storage
  • renew-certs.sh created and chmod +x
  • Cron job set for nightly cert renewal

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *