Moving Transmission into Docker on Rocky Linux

Transmission was running fine as a native service on Rocky Linux — until we moved everything else into Docker and the web interface started throwing a “No exit found” error. The fix was straightforward: containerise Transmission too, point it at the existing config and downloads, and sort out a few ownership quirks along the way.

This post covers moving an existing Transmission installation into Docker while preserving all existing torrents, resume data, and download locations. No re-downloading, no lost progress.

Why Bother

With everything else running in Docker, having Transmission as a native service felt inconsistent. More practically, Home Assistant was previously running with network_mode: host which meant it had access to all ports — including 9091. Once HA moved to bridge networking, requests to port 9091 started hitting the wrong place. Containerising Transmission and giving it an explicit port binding sorted this cleanly.

Prerequisites

  • Docker and Docker Compose running
  • Existing Transmission installation with config at /var/lib/transmission/.config/transmission-daemon
  • Existing downloads at their current path (in this case /mnt/raid5/Torrent/Downloads)
  • Know which user owns your config vs downloads — they may differ

Check Ownership First

This is the most important step and the one most likely to cause problems if skipped. The Docker container runs as a specific user defined by PUID and PGID — if those don’t match the ownership of your config and download directories, Transmission either won’t start or won’t find its data.

# Check config ownership
ls -la /var/lib/transmission/.config/transmission-daemon/

# Check downloads ownership
ls -la /mnt/raid5/Torrent/Downloads/

# Get the IDs of your chosen user
id patto

In this setup the resume and torrent files were owned by patto but the downloads directory was owned by transmission. The fix was to align everything to patto:

sudo chown -R patto:patto /mnt/raid5/Torrent/Downloads

Create Required Subdirectories

The linuxserver Transmission image expects complete and incomplete subdirectories inside the downloads volume. If they don’t exist the container logs permission errors on every start.

mkdir -p /mnt/raid5/Torrent/Downloads/complete
mkdir -p /mnt/raid5/Torrent/Downloads/incomplete
chown -R patto:patto /mnt/raid5/Torrent/Downloads/complete
chown -R patto:patto /mnt/raid5/Torrent/Downloads/incomplete

Stop the Existing Service

Stop and disable the native Transmission service before starting the container — both cannot bind to port 9091 at the same time.

sudo systemctl stop transmission-daemon
sudo systemctl disable transmission-daemon

Docker Compose File

mkdir -p /mnt/raid5/docker/transmission/watch
vi /mnt/raid5/docker/transmission/docker-compose.yml
services:
  transmission:
    image: lscr.io/linuxserver/transmission:latest
    container_name: transmission
    restart: unless-stopped
    environment:
      - PUID=1000    # replace with output of: id patto
      - PGID=1000    # replace with output of: id patto
      - TZ=Europe/London
    volumes:
      - /var/lib/transmission/.config/transmission-daemon:/config
      - /mnt/raid5/Torrent/Downloads:/mnt/raid5/Torrent/Downloads
      - /mnt/raid5/docker/transmission/watch:/watch
    ports:
      - 9091:9091
      - 51413:51413/tcp
      - 51413:51413/udp

Key: The downloads volume must be mapped to the same path inside the container as it is on the host. The resume files store absolute paths — if the container path differs from the host path, Transmission shows “No data found” for every torrent even though the files exist.

Start the Container

cd /mnt/raid5/docker/transmission
docker compose up -d
docker logs transmission --tail 30

What the Logs Should Show

A healthy start looks like this:

[custom-init] No custom files found, skipping...
Connection to localhost (127.0.0.1) 9091 port [tcp/*] succeeded!

These messages are harmless and expected:

ln: failed to create symbolic link '/transmissionic/index.html': File exists
ln: failed to create symbolic link '/combustion-release/index.html': File exists

These mean the UI symlinks already exist from a previous run — not a problem.

Warning: If you see permission errors on /downloads/complete or /downloads/incomplete it means those subdirectories don’t exist or have wrong ownership. Create them and fix ownership as shown above, then restart.

Access the Web Interface

Access Transmission locally at:

http://[ServerIP]:9091/transmission/web/

Note the trailing slash — without it some browsers redirect incorrectly.

This is intentionally local-only. The container has no proxy_net membership and no Nginx config, so it is not accessible externally. Port 9091 is only reachable from inside the local network.

Browser Cache Gotcha

Chrome in particular caches redirects aggressively. If Chrome shows the wrong page after the move, either clear the cache for that IP or test in an incognito window first. Firefox tends to behave better here.

Local DNS Resolution

If you want to access Transmission via a hostname rather than an IP internally, add an A record to your local BIND DNS server rather than routing it through Cloudflare. Since Transmission is local-only there is no reason for it to be publicly accessible.

Lessons Learned

  1. Map volumes to the same path as the host. The linuxserver image lets you choose the container path freely — always use the identical absolute path so resume files resolve correctly.
  2. Check ownership before anything else. Mismatched PUID/PGID between config and downloads causes two separate classes of failure that look unrelated.
  3. Create complete and incomplete subdirectories manually. The container expects them to exist and logs misleading permission errors if they don’t.
  4. Stop the native service first. Two processes cannot bind to 9091 simultaneously — the container will start but immediately fail to bind.
  5. Chrome caches redirects. Always test in incognito after moving services between ports or hosts.

Final Checklist

  • Config and downloads ownership aligned to same user
  • complete and incomplete subdirectories created
  • transmission-daemon service stopped and disabled
  • PUID and PGID set correctly in compose file
  • Downloads volume mapped to identical host path
  • Container started and logs show port 9091 succeeded
  • Existing torrents visible in web UI
  • Access confirmed at http://[ServerIP]:9091/transmission/web/

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!