
Docker on NAS: network architecture and best practices
Why Docker on a NAS
A modern NAS is also a media server, a photo manager, a download aggregator and a home automation dashboard. Installing all of that natively on Debian means piling up dependency conflicts. Docker isolates each service in its own container with its own dependencies.
What remains is deciding where to store the data, how to isolate networks, and how to stop Docker from bypassing the firewall.
Installation: Docker CE, not docker.io
The docker.io package in Debian's repos always trails 2-3 versions behind. Use the official repository:
# Add the Docker CE repo
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker.gpg
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker.gpg] \
https://download.docker.com/linux/debian trixie stable" \
> /etc/apt/sources.list.d/docker.list
apt update
apt install docker-ce docker-ce-cli containerd.io docker-compose-plugin
The Compose v2 plugin replaces the old Python docker-compose: the command becomes docker compose, no hyphen, and it's noticeably faster.
daemon.json: data root and hardening
By default Docker stores everything in /var/lib/docker. When the system runs off a 4 GB USB stick, the data root has to move onto the Btrfs RAID. The rest of the file handles hardening:
{
"data-root": "/mnt/data/docker",
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"default-ulimits": {
"nofile": {
"Name": "nofile",
"Hard": 65536,
"Soft": 65536
}
},
"userns-remap": "default",
"no-new-privileges": true,
"storage-driver": "overlay2",
"live-restore": true
}
- data-root: all layers, volumes, images and containers live on
/mnt/data/docker, protected by the RAID. - log-driver + log-opts: automatic rotation (10 MB max, 3 files). Without it, a chatty container fills the disk in two days.
- default-ulimits: raises the open-file limit, useful for intensive services.
- userns-remap: containers run with a shifted UID mapping, so root inside the container is never root on the host. A genuine mitigation.
- no-new-privileges: processes can't gain privileges through setuid/setgid.
- live-restore: containers survive a daemon restart, handy for updates with no interruption.
Network architecture: isolation by stack
Instead of putting everything on the default bridge network, create separate Docker networks per service group:
docker network create proxy_net
docker network create arr_net
docker network create photos_net
docker network create monitoring_net
docker network create auth_net
The point is blast radius. If a container is compromised, it can only talk to its immediate neighbours: Radarr can't see Immich, Grafana can't see Jellyfin. Only the reverse proxy is attached to several networks to route traffic. The equivalent of VLANs, for containers.
A container can join several networks when needed: Sonarr sits on arr_net to talk to Prowlarr and the downloaders, and on proxy_net to be reachable through the reverse proxy.
The UFW + Docker trap
The classic trap: Docker manipulates iptables directly, bypassing UFW entirely. Blocking port 8080 in UFW does nothing if a container publishes it — it will be open.
The fix has two parts. First the DOCKER-USER chain, which Docker evaluates before its own rules:
# /etc/ufw/after.rules (at the end of the file)
*filter
:DOCKER-USER - [0:0]
# Only allow the LAN to reach Docker ports
-A DOCKER-USER -s 192.168.1.0/24 -j ACCEPT
-A DOCKER-USER -s 172.16.0.0/12 -j ACCEPT
-A DOCKER-USER -j DROP
COMMIT
Then restricting the default listen address in daemon.json:
{
"ip": "192.168.1.50"
}
Published ports then only listen on the NAS's LAN IP. Combined, the two make containers reachable only from the local network, whatever Docker does with iptables.
The PUID/PGID pattern
linuxserver.io images accept PUID and PGID environment variables instead of running as root:
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Paris
With 1000:1000 matching the nasadmin user on the host, files created by containers get the right owners. No permission headaches.
Directory layout
/mnt/data/
├── apps/
│ ├── traefik/config/
│ ├── authelia/config/
│ ├── jellyfin/config/
│ ├── immich/config/
│ ├── sonarr/config/
│ ├── radarr/config/
│ ├── prowlarr/config/
│ └── grafana/config/
├── media/
│ ├── movies/
│ ├── tv/
│ └── music/
├── downloads/
│ ├── complete/
│ └── incomplete/
├── photos/
└── docker/ # Docker data-root
Each service gets its directory under /mnt/data/apps/<service>/config. Shared data (media, downloads, photos) lives in common directories bind-mounted into the containers that need them.
Compose file organisation
One docker-compose.yml per functional stack:
compose/
├── auth/ # Authelia, LLDAP
├── media/ # Jellyfin
├── arr/ # Sonarr, Radarr, Prowlarr, qBittorrent
├── photos/ # Immich
├── files/ # Syncthing, Samba
├── monitoring/ # Grafana, Prometheus, node-exporter
└── homelab/ # Homepage, Uptime Kuma
The monitoring example:
# compose/monitoring/docker-compose.yml
services:
grafana:
image: grafana/grafana:latest
container_name: grafana
restart: unless-stopped
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
volumes:
- /mnt/data/apps/grafana/config:/var/lib/grafana
networks:
- monitoring_net
- proxy_net
prometheus:
image: prom/prometheus:latest
container_name: prometheus
restart: unless-stopped
volumes:
- /mnt/data/apps/prometheus/config:/etc/prometheus
- prometheus_data:/prometheus
networks:
- monitoring_net
node-exporter:
image: prom/node-exporter:latest
container_name: node-exporter
restart: unless-stopped
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.sysfs=/host/sys'
- '--path.rootfs=/rootfs'
networks:
- monitoring_net
networks:
monitoring_net:
external: true
proxy_net:
external: true
volumes:
prometheus_data:
Grafana is the only one on proxy_net, because it needs to be reachable through the reverse proxy. Prometheus and node-exporter stay confined to monitoring_net, deliberately.
Ansible toggles
Each stack is enabled or disabled through a variable:
# group_vars/nas.yml
docker_apps_auth_enabled: true
docker_apps_media_enabled: true
docker_apps_arr_enabled: true
docker_apps_photos_enabled: true
docker_apps_files_enabled: true
docker_apps_monitoring_enabled: true
docker_apps_homelab_enabled: true
The Ansible role only deploys enabled stacks. Setting docker_apps_photos_enabled: false and re-running the playbook stops the containers and cleanly removes the compose files.
The result
Network isolation, a hardened daemon, RAID storage, firewall coexistence: the architecture takes upfront work. Once it's in place, adding a service is just creating a compose file, attaching it to the right network and flipping the Ansible toggle. The NAS becomes a maintainable, secure service platform.
Debian NAS from scratch series — This article is part of a complete series on building a Debian NAS.
Previous: Installing Debian on a NAS fully automatically | Next: Firewall and Fail2ban: locking down NAS network access
Related articles