← All posts

NixOS + Rootless Podman: My Secure Homelab

  • nixos
  • podman
  • homelab
  • security

This post was translated from the German original using AI.

Starting Point

I currently run a Proxmox server at home with a Debian VM. In the past, I always did this the classic way — logging in via SSH and typing everything by hand. As part of my work at dev-threads GmbH, I got to work more closely with Ansible two years ago and have been gradually rebuilding my homelab ever since.

With Ansible, the entire configuration is described in text files, which allowed me to use git for version control. This is particularly helpful since you tend to forget things over time, and the repository serves as documentation. And if I ever lose a VM, I can restore everything — even if something is wrong with the VM backups.

The various services running on the VM are containers executed with Docker, which creates separation and greatly improves maintainability. For example, I can pin exact versions and perform rollbacks without affecting other services. Since everything is stored in text files, it also fits nicely with Ansible and a git repository.

Debian as a host operating system has served me well since my first steps in the 2000s. It is known for its well-aged packages and high stability — practically perfect!

However, there are occasional problems — like when the Nvidia driver stops working after a kernel upgrade because the kernel headers were missing. This is admittedly a broken configuration on my part, but it apparently is not part of my Ansible repository, which means I have some configuration drift somewhere.

NixOS promises to solve all of these problems with its declarative configuration. The entire state corresponds exactly to what we define. Unfortunately there is no NixOS package that supports my existing Docker Compose setup. Since I want to keep the effort as low as possible and don’t want to fully commit to the NixOS ecosystem, my approach must support my existing Compose files.

The Plan

NixOS as the operating system, Podman instead of Docker, and a dedicated service user per stack.

NixOS

NixOS gives me a declarative system description: a single configuration.nix defines everything — packages, users, firewall rules, services. If it is not in the file, it does not exist. If I format the machine, restore my data, and apply the config, I get back exactly the same state.

Podman Instead of Docker

The Docker daemon runs with root privileges by default. If a compromised application manages to escape a container, no further exploits are needed — full system access is immediately available.

While Docker now also has a rootless mode, this is a core aspect of Podman — a design principle the tool was built around.

Isolated Services

I could take the easy route and use a single system user for all Podman containers, but thanks to NixOS it is not much more effort to create a dedicated user per service. This gives us isolation not just from root, but also between services.

Each service gets its own user with a fixed UID (starting at 60000). Caddy is 60002, Jellyfin is 60003, and so on. The UIDs are explicitly defined, not randomly assigned, so services can be migrated between servers.

The Implementation

Example — Homepage

Homepage is a dashboard I use in my homelab to quickly navigate to the various services. It is a great example because it is essentially just a static website and can be configured easily.

Compose

This simple file describes the Homepage service.

# /var/docker/homepage/docker-compose.yaml
---
services:
  homepage:
    image: ghcr.io/gethomepage/homepage:latest
    restart: unless-stopped
    ports:
      - "3000:3000"

Rootless Podman

Podman starts unprivileged by default, so rootless mode does not need to be explicitly enabled.

virtualisation.podman.enable = true;

Since I want my existing docker-compose.yaml files to be supported, I also need to enable dockerCompat and DNS. dockerCompat allows the use of Docker Compose, and DNS enables container hostname resolution, which I use in many places.

  virtualisation.podman = {
    enable = true;
    dockerCompat = true;
    defaultNetwork.settings.dns_enabled = true;
  };

Service User

For maximum isolation, I create a dedicated user for each service.

  users.users."svc-homepage" = {
    isSystemUser = true;
    uid = 60000;
    group = "svc-homepage";
    home = "/var/docker/homepage";
    createHome = false;
    subUidRanges = [{ startUid = 165536; count = 65536; }];
    subGidRanges = [{ startGid = 165536; count = 65536; }];
    linger = true;
  };
  users.groups."svc-homepage" = { gid = 60000; };

The user svc-homepage may only access /var/docker/homepage, where all relevant configuration files are placed (in this case just the docker-compose.yaml). linger = true keeps the systemd user session alive even when nobody is logged in — so the service starts automatically at boot.

Service Installation

Each compose stack needs a corresponding systemd service to start and stop the containers. Here is the full definition — let us walk through it in detail.

systemd.services."podman-compose-homepage" = {
  description = "Podman Compose for homepage";
  after = [ "network-online.target" "user@60000.service" ];
  wants = [ "network-online.target" ];
  requires = [ "user@60000.service" ];
  wantedBy = [ "multi-user.target" ];
  path = [ pkgs.docker-compose "/run/wrappers" ];
  unitConfig = {
    ConditionPathExists = "/var/docker/homepage";
  };
  serviceConfig = {
    Type = "oneshot";
    RemainAfterExit = true;
    User = "svc-homepage";
    Group = "svc-homepage";
    WorkingDirectory = "/var/docker/homepage";
    Environment = "XDG_RUNTIME_DIR=/run/user/60000";
    ExecStart = "${pkgs.podman}/bin/podman compose up -d --remove-orphans";
    ExecStop = "${pkgs.podman}/bin/podman compose down";
  };
};

path = [ pkgs.docker-compose "/run/wrappers" ];

Makes docker-compose available to the service user. The systemd service uses podman compose, but this is just a wrapper around docker compose.


User = "svc-homepage";
Group = "svc-homepage";

The compose stack must be started as the svc-homepage user.


The compose command expects the files in the current directory, so we switch to the home of svc-homepage where everything lives.

WorkingDirectory = "/var/docker/homepage";

Environment = "XDG_RUNTIME_DIR=/run/user/60000";

When defined, Podman stores temporary files under ${XDG_RUNTIME_DIR}/containers. This ensures that temporary files are properly attributed to each user.


ExecStart = "${pkgs.podman}/bin/podman compose up -d --remove-orphans";
ExecStop = "${pkgs.podman}/bin/podman compose down";

Finally, a service must be able to start and stop. In NixOS, stopping is at least as important as starting — otherwise a service might still be running after a NixOS configuration update. While that would be resolved after a reboot, it should still be avoided.

Networking

To access the dashboard from outside, we need to open port 3000 in the firewall.

⚠️ If the service is meant to be accessible from the internet, secure it with HTTPS at a minimum — a reverse proxy (e.g. nginx or Caddy) is the way to go. Opening a port without any security measures is only intended as an example.

networking.firewall.enable = true;
networking.firewall.allowedTCPPorts = [ 3000 ];

Drawbacks

Watch Out for Networking

Without root privileges, Podman cannot create its own bridge network between Compose stacks. Services communicate over the host network instead. This limitation only exists in my setup because I run a separate Podman instance per service.

In the end, this means the firewall plays a bigger role than with classic Docker using a single user (root), where containers are connected via an isolated network.

The firewall configuration in NixOS is straightforward though, so it is manageable — but worth keeping in mind.

Reading Logs Is More Cumbersome

With Docker, a simple docker logs <container> with root rights is enough. With rootless Podman and separate service users, you first need to log in as the corresponding user. This is fairly tedious.

In my setup I work around this with Dozzle — a log viewer that natively supports rootless Podman and makes all container logs accessible via a web interface. Dozzle must be configured in agent mode, and each compose stack needs a Dozzle agent that exports its logs.

Conclusion

Docker with a root daemon is convenient, and as we know, security is never convenient. The strict separation of services in Podman and the absence of root access feel right, and with NixOS the implementation was manageable.

I have not yet migrated my production server, but I will report back here once I have gathered some long-term experience.