A User Per Container: A Second Layer of Isolation

I run most of my homelab services using docker-compose on a single VM and for a while, the only isolation between the containers was the network. Each app sits on its own private bridge with its database, and the only thing that can reach it is Traefik. Two apps can’t talk to each other laterally.

The thing that nagged at me is that Docker runs containers as root if you don’t configure it otherwise. Unless an image is built to drop privileges, or you set a user: yourself, the process inside runs as UID 0, the same UID 0 as root on the host. It’s only the container boundary keeping them apart. So a compromised container running as root could happily read the other service’s data.

So I added a second layer: every container now runs as its own user.

What I Had: Network Isolation

The existing network setup is still there. Each app gets a private bridge on its own subnet for talking to its own database. On top of that, each app gets its own traefik-<app> ingress network, and Traefik is the only container attached to all of them. An app can reach its own dependencies and nothing else, and only Traefik can reach the app.

Adding a User Per Container

The big change was making every container runs as its own dedicated, non-root UID. Nothing runs as root that doesn’t have to, and no two services share a UID unless I have a specific reason.

I allocate them in blocks of ten per app, so it’s easy to keep straight at a glance: the app ends in 0, the database in 1, redis in 2, a worker in 3. n8n is 3060 for the app, 3061 for Postgres, 3063 for the task runners.

These UIDs only exist inside the containers. There are no matching entries in the host’s /etc/passwd, and I deliberately keep all of them clear of UID 1000, which is my deploy user on the host.

Alongside the user, every service drops all capabilities and gets no-new-privileges. In a compose file it looks like this:

n8n:
  image: n8nio/n8n:latest
  user: "3060:3060"
  security_opt:
    - no-new-privileges:true
  cap_drop:
    - ALL

I keep a UIDS.md file that lists every service and its UID.

Getting the Permissions Right

Adding the user: lines was only half the battle. Updating everything on disk that those users now need to own was the challenging part. Secrets get written out owned by the UID that consumes them, with tight modes, so a password file is readable by exactly the container that needs it and nothing else. When a service shares a secret between its app and its database, I make it group-readable rather than world-readable.

Updating existing volumes was another battle. Data that used to be owned by root had to be handed over to the new UID, so each app had a one-time task that ran a chown -R inside a throwaway container over its volumes.

The Images That Fight Back

Plenty of images made the setup easy, some did not.

changedetection ignores the usual PUID/PGID variables entirely, so user: is the only lever that works. Photoprism only supports a narrow range of UIDs and needs a writable /run, so it gets a tmpfs mount just for that. n8n has no passwd entry for its UID, which means no home directory, so I have to set HOME explicitly or it writes config to /.

A handful of images simply refuse to run as anything but root, usually because their entrypoint does setup before dropping privileges. Those I left as root but locked down anyway, with no-new-privileges and all capabilities dropped. The one real exception is Forgejo, which needs a genuine host git user for SSH passthrough, so it gets an actual account instead of a container-only UID.

Trade-offs

This is more fiddly than just letting everything run as root.

  • Per-image quirks: Every image that fights non-root is its own little way.
  • A UID to track: Every new service needs a number assigned and recorded.

Final Thoughts

This is the same instinct as my VM per client setup, just at a different layer.

Network isolation limits what a container can reach. User isolation limits what it can read once it’s there. Neither one is expensive once it’s set up.

I might look into podman in the future to avoid docker itself running as root, but that’s a bigger change.