Skip to content

Twelve containers on a laptop I'd stopped using

11 min read
#homelab#docker#caddy#self-hosted

This is the first version of a home media server, and it’s shaped by its hardware.

The machine is a Dell Inspiron 7567 I bought in 2017 and hadn’t opened in three years. It wasn’t broken, it had just been superseded, and it had reached the stage where it either became something or went in a drawer permanently. So it got a fresh Debian install and this.

Nothing here was chosen for the job; it was chosen for the machine I happened to have. When it moves to hardware picked on purpose, some of these answers change, and I’ve tried to be clear about which ones.

What it does: media library and playback, audiobooks, a request interface for the people who use it, automated library management, and remote access. Twelve containers, one compose file, one bridge network.

The whole thing is at kcaashish/arrstack, with an .env.example and a Caddyfile.example in place of my real ones.

The decision everything else depends on

The single most consequential thing in the file is not a service. It’s how paths are mounted.

sonarr:
  volumes:
    - ${MEDIA_DIR}/appdata/sonarr:/config
    - ${MEDIA_DIR}/library:/data

One mount, not two. I ran it as two for weeks, and it was wrong in a way that looks entirely correct.

This matters because of how Sonarr and Radarr import files. Given a finished download and a library to file it into, they hardlink: the file gets a second name in the library, occupies no additional space, and keeps seeding under its original one. Failing that they copy, and every file exists twice on disk. On a laptop with one disk, that’s the difference between the thing being viable and not.

The condition is usually stated as “both paths on the same filesystem.” That’s necessary and it isn’t sufficient. What the kernel compares is the mount:

error = -EXDEV;
if (old_path.mnt != new_path.mnt)
    goto out_dput;

do_linkat is looking at vfsmount pointers, not device numbers. Every -v line in a compose file creates its own bind mount, so two volume lines are two mounts regardless of where they came from on the host or what they’re called inside the container.

Which is exactly what my first version did:

# what I had, and what most guides show
- ${MEDIA_DIR}/torrents:/data/torrents
- ${MEDIA_DIR}/media/tv:/data/media/tv

Both under /data. Both from one directory on the host. Both on one ext4 volume, which the container will confirm if you ask it:

$ docker exec sonarr mount | grep /data
/dev/mapper/inspiring--dell--007--vg-root on /data/torrents type ext4 (rw,relatime,errors=remount-ro)
/dev/mapper/inspiring--dell--007--vg-root on /data/media/tv type ext4 (rw,relatime,errors=remount-ro)

$ docker exec sonarr sh -c 'touch /data/torrents/.hltest &&
    ln /data/torrents/.hltest /data/media/tv/.hltest'
ln: failed to create hard link '/data/media/tv/.hltest' => '/data/torrents/.hltest': Cross-device link

Same volume, same filesystem, refused. Every import had been quietly copying, and nothing anywhere said so. You find out from df.

The fix is one directory level. appdata holds each service’s configuration and shouldn’t be visible to anything but its own container, so it can’t sit inside the tree that gets mounted. Everything that does get mounted moves under library/, which is then mounted once:

Two bind mounts versus one, and where the hardlink failsIn the first arrangement, the torrent directory and the television directory are bind mounted separately into the container. They land under a shared /data prefix and sit on one filesystem, but because they are two mount points the kernel refuses a hardlink between them with EXDEV. In the second, a single library directory is mounted once at /data and contains both, so a hardlink between them stays inside one mount and succeeds. The appdata directory is kept outside library so that mounting library does not expose every service's configuration.Two mountsdata/torrentsdata/media/tv/data/torrents/data/media/tvEXDEVrefuses the linkOne mountdata/libraryappdata/ sits outside it,so it is never mounted here/datatorrents/media/hardlinkstays inside one mount
One disk and one filesystem in both cases. The kernel compares mount points, not devices.

The container-side paths didn’t change, so nothing inside the applications needed reconfiguring. It was a directory move and two edited lines.

The services that don’t hardlink take narrower mounts. qBittorrent only needs the download directory, Jellyfin only needs the media directory, and Bazarr only needs to find the files Sonarr tells it about:

bazarr:
  volumes:
    - ${MEDIA_DIR}/library/media:/data/media

That last one is a different requirement wearing similar clothes. Bazarr isn’t linking anything, so mount count is irrelevant to it; it just has to agree that /data/media/tv names the same directory Sonarr meant when it reported a path. Container path strings have to match. Treating that as the same rule as the hardlink one is how I talked myself into the first version.

Updates: pinned tags, and the auto-updater switched off

Every image is pinned to an exact version.

image: linuxserver/sonarr:4.0.19
image: caddy:2.11.4
image: ghcr.io/seerr-team/seerr:v3.4.1

Watchtower, which would pull and restart containers automatically, is in the file and commented out. In its place:

wud:
  image: ghcr.io/getwud/wud:8.4.0
  environment:
    - WUD_WATCHER_LOCAL_CRON=0 12 * * *
    - WUD_TRIGGER_DISCORD_MYDISCORD_URL=${WUD_DISCORD_WEBHOOK_URL}

What’s Up Docker checks once a day and posts to Discord when something has a newer version. It doesn’t touch anything. I read the release notes and bump the tag by hand.

The reasoning is about failure timing. With :latest and an auto-updater, a breaking change arrives at 4am while nobody is watching, and the first symptom is someone telling you playback is broken. With pinned tags, the same breaking change arrives when I choose, with the release notes open, and rolling back is editing one line.

The filtering is done with a label on each service:

labels:
  - "wud.tag.include=^\\d+\\.\\d+\\.\\d+$"

That regex restricts notifications to plain semver tags. Without it, WUD reports every latest, nightly, develop and 2.11 tag as an available update and the notifications become noise you stop reading. Services that prefix their tags with v get ^v\\d+\\.\\d+\\.\\d+$ instead.

What Caddy fronts

Caddy fronts the three services that other people actually use:

http://jf.example.com {
    reverse_proxy jellyfin:8096
}

http://rq.example.com {
    reverse_proxy seerr:5055
}

http://abs.example.com {
    reverse_proxy audiobookshelf:80
}

Note the explicit http://. Caddy provisions certificates automatically by default, and naming the scheme turns that off. For hostnames that only resolve on the LAN there’s no public challenge to satisfy, so this is deliberate rather than an omission. Proxying by service name works because they share media-network and Docker’s embedded DNS resolves container names.

The approach I built first and then deleted

Before the tunnel, the plan was to give the internal hostnames real certificates. That meant a custom Caddy build:

FROM caddy:2.11.4-builder-alpine AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare

FROM caddy:2.11.4-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

The stock Caddy image ships with no DNS provider modules, so getting one means compiling your own binary with xcaddy. Compose built it inline and passed a scoped Cloudflare token:

caddy:
  build:
    context: .
    dockerfile: inline-caddy.Dockerfile
  environment:
    CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}

The reason for all of it is the ACME challenge type. The default, HTTP-01, requires Let’s Encrypt to reach port 80 on your server from the public internet, which internal-only hostnames by definition can’t satisfy. DNS-01 proves control by writing a TXT record instead, so it works for services that are never publicly reachable, and the Cloudflare DNS module is what lets Caddy write that record.

It worked. Then the remote access problem killed it.

Why the tunnel, specifically

Certificates were never the hard part. Reaching the machine was.

Residential connections here don’t come with a static IP and you can’t buy one. The usual answer is dynamic DNS, which handles an address that changes but assumes there’s a publicly routable address to point at in the first place. When the connection doesn’t give you one, port forwarding has nothing to forward to and no amount of DNS configuration helps.

A Cloudflare tunnel sidesteps the whole question by reversing the direction:

cloudflared:
  command: tunnel --no-autoupdate run
  environment:
    TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN}

The daemon dials out to Cloudflare and holds the connection open. Traffic arrives back down a link the server established itself. Nothing is forwarded, the router is untouched, and there’s no inbound port to expose or protect.

Which then made the custom Caddy build pointless. TLS now terminates at Cloudflare’s edge, so nothing on the box needs a certificate, and Caddy drops back to the stock image serving plain http:// internally. The CLOUDFLARE_API_TOKEN line and the inline-caddy.Dockerfile reference are still sitting commented out in my compose file.

That’s the shape of the decision worth recording. The first approach wasn’t wrong and it didn’t fail. Solving a different problem properly made it unnecessary, and the work I threw away was the work that showed me which problem actually mattered.

Three ways in, on purpose

The machine has no desktop. Nothing on it was ever reachable at localhost, the setup wizards included, so remote access wasn’t a feature to add once it was working. It was a precondition for configuring anything at all.

That’s what Tailscale does here, and it’s the one piece of this that isn’t in the compose file at all. It installs on the host rather than as a container, joins the machine to a WireGuard mesh, and gives it an address that works from my Mac whether I’m on the same network or not. Sonarr, Radarr, Prowlarr, qBittorrent and the rest are administered entirely over that, including their first run.

The two remote paths aren’t redundant, they’re aimed at different people. The tunnel exists because the people who use Jellyfin and Seerr can’t reasonably be asked to join a tailnet. Tailscale exists because the alternative is putting admin panels on the public internet in order to configure them. The LAN is just what’s left when I’m sitting next to the thing.

Three things that cost me time

The two Jellyfin images disagree about /config. LinuxServer keeps the XML configuration at the top level of it; the official image expects that one directory further down and treats /config itself as data. Jellyfin 10.11 drops a marker file in the directories it owns and refuses to start when it finds the wrong one, so swapping the image against a directory the other one had already initialised produces this and then a crash loop:

Unhandled exception. System.InvalidOperationException: Expected to find only
.jellyfin-data but found marker for /config/.jellyfin-config.

A fresh /config works with either image, which is why it reads as though the swap broke something that used to work. The rule I’d actually give myself is not “use the linuxserver image.” It’s that image lineage can’t change under a /config that has already been claimed.

Hardware transcoding needs the device passed through.

jellyfin:
  devices:
    - /dev/dri:/dev/dri

This is what makes an old laptop workable at all. Without it every transcode is software, the CPU pins, and the fans announce it from another room. With the integrated GPU exposed, transcodes are cheap. It’s one line, and without it this machine wouldn’t be up to the job.

Two different ways to not run as root. The linuxserver images take PUID and PGID as environment variables and drop privileges in their own init. Audiobookshelf isn’t a linuxserver image, so the same variables do nothing and it needs Compose’s own directive:

audiobookshelf:
  user: "${PUID}:${PGID}"

Get this wrong and files land owned by root, which you notice weeks later when something can’t write.

What’s wrong with it

Being honest about a first pass is most of its value.

One disk, no redundancy, no backups. The appdata directory holds every service’s configuration and database and is backed up nowhere. This is the most embarrassing item on the list and the cheapest to fix.

Media lives under /home. Which is what makes a single mount possible, and also means the library shares a filesystem with the operating system. Fine at this size, wrong as a permanent arrangement. On the next box, storage gets its own filesystem and the layout gets recreated there.

depends_on doesn’t mean ready. It orders startup and nothing more. Compose starts Sonarr the moment the qBittorrent container exists, not when it’s accepting connections, so a cold boot produces a spread of connection errors that resolve themselves. Health checks with condition: service_healthy would fix it properly.

It’s a nine-year-old laptop. Lid behaviour, thermals, and a 2017 battery now serving as an accidental UPS. It works, but nothing about the chassis was designed to run continuously, and the failure I should plan for is hardware rather than software.

WUD holds the Docker socket. Read-only would be enough for what it does, and it currently has more than it needs.

No monitoring. I know something broke when someone tells me. There’s no metrics, no uptime check, no alerting beyond update notices.

What the next version looks like

Real hardware, which turns most of the list above from a constraint into a design decision.

The path layout stays exactly as it is. That’s the one decision here I’m confident survives the hardware.

Read next