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
- Get a cert: run the Certbot docker run command with the new domain
- Create a site directory with its own docker-compose.yml joining proxy_net
- Add a new .conf file to proxy/conf.d/
- Start the site and reload Nginx:
docker exec proxy_nginx nginx -s reload
Lessons Learned
- daemon.json is additive, not replaceable. Always merge new settings into the existing JSON object – replacing it wipes existing Docker config.
- Certbot needs –network host. Bridge networks cause DNS resolution failures when reaching Let’s Encrypt on this server.
- docker compose restart does not apply network changes. Always use up -d –force-recreate when adding a container to a new network.
- YAML indentation breaks silently. A single extra space causes validation errors. All keys at the same service level must have identical indentation.
- Frigate ports need explicit 0.0.0.0 binding. Without it, container-to-container connections are refused even when the container name resolves correctly.
- APACHE_LOG_DIR is Debian-only. On Rocky Linux use the hardcoded path /etc/httpd/logs/ in all VirtualHost configs.
- 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
Leave a Reply