Category: Uncategorized

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

    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
  • Hello world!

    Welcome to WordPress. This is your first post. Edit or delete it, then start writing!