Running Self-Hosted Services the NixOS Way

NixOS Modules vs Docker: a Quick Take

A lot of homelab guides default to Docker Compose. It's portable and familiar, but on NixOS it's somewhat redundant for services that have proper NixOS modules. When a module exists, you get a systemd unit managed by the system, proper user/group setup, automatic dependency ordering, and integration with the rest of your config. No YAML, no bind mount gymnastics, and no "my compose file assumes /opt/docker exists" surprises.

That said, OCI containers are a reasonable fallback for software that doesn't have a NixOS module yet or where you need a specific image version the module doesn't support.

Jellyfin

services.jellyfin = {
  enable = true;
  openFirewall = true;  # opens 8096/8920 in networking.firewall
  user = "jellyfin";
  group = "jellyfin";
};

Media files should live on your ZFS pool. Add the jellyfin user to whatever group owns your media directory, or set the directory permissions accordingly. I usually do this by adding jellyfin to a media group:

users.groups.media = {};
users.users.jellyfin.extraGroups = [ "media" ];

Hardware transcoding on Intel iGPUs requires the intel-media-driver package and render device access:

hardware.opengl = {
  enable = true;
  extraPackages = with pkgs; [ intel-media-driver ];
};
users.users.jellyfin.extraGroups = [ "media" "render" "video" ];

Nextcloud

Nextcloud's NixOS module is one of the more opinionated ones, which is actually a good thing. It handles the PHP-FPM pool, the database (PostgreSQL or MariaDB), the cron job, and the redis cache, all from a single block:

services.nextcloud = {
  enable = true;
  hostName = "nextcloud.home.example.com";
  package = pkgs.nextcloud29;
  https = true;

  config = {
    dbtype = "pgsql";
    adminpassFile = config.age.secrets.nextcloud-admin-pass.path;
  };

  settings = {
    trusted_domains = [ "nextcloud.home.example.com" ];
    default_phone_region = "US";
  };

  extraAppsEnable = true;
  extraApps = with config.services.nextcloud.package.packages.apps; [
    calendar
    contacts
    notes
  ];
};

services.postgresql = {
  enable = true;
  ensureDatabases = [ "nextcloud" ];
  ensureUsers = [{
    name = "nextcloud";
    ensureDBOwnership = true;
  }];
};

Pin the package to a specific major version and bump it intentionally. Nextcloud has strict upgrade path requirements (no skipping major versions), and the NixOS module will warn you if you try to jump too far.

Custom systemd Services

For software without a NixOS module, writing a systemd unit directly in Nix is straightforward. Here's a minimal example for a Go binary you've built:

systemd.services.my-exporter = {
  description = "Custom Prometheus exporter";
  wantedBy = [ "multi-user.target" ];
  after = [ "network.target" ];

  serviceConfig = {
    ExecStart = "${pkgs.my-exporter}/bin/my-exporter --port 9200";
    User = "nobody";
    Restart = "on-failure";
    RestartSec = "5s";
    # Hardening
    PrivateTmp = true;
    ProtectSystem = "strict";
    NoNewPrivileges = true;
  };
};

Include the hardening options (PrivateTmp, ProtectSystem, NoNewPrivileges) for anything network-facing. They're systemd security features that limit what the process can touch, and they cost nothing to add.

OCI Containers

When you do need containers, NixOS wraps Docker or Podman through virtualisation.oci-containers:

virtualisation.oci-containers = {
  backend = "podman";
  containers = {
    homeassistant = {
      image = "ghcr.io/home-assistant/home-assistant:2024.11";
      volumes = [
        "/var/lib/homeassistant:/config"
      ];
      environment = {
        TZ = "America/New_York";
      };
      extraOptions = [
        "--network=host"
      ];
    };
  };
};

Each entry in containers becomes a systemd unit (podman-homeassistant.service). You can manage it with systemctl like any other service. Podman is the better choice over Docker on NixOS since it runs rootless by default and doesn't require a daemon.

One thing to know: the image tag doesn't pin to a specific digest. If you want reproducible image pulls, use a full digest reference (ghcr.io/home-assistant/home-assistant@sha256:...). For home server use where you want to follow upstream releases, the tag is usually fine.

Enabling and Disabling Services

The declarative approach makes it trivial to toggle services. Comment out the block, rebuild, and the service is stopped and disabled. No systemctl disable, no leftover config files to clean up.

If you want a service present but not running (for manual activation), set enable = false but keep the config around:

services.grafana = {
  enable = false;  # flip to true when ready
  settings.server = {
    http_port = 3000;
    domain = "grafana.home.example.com";
  };
};

This pattern is useful during setup: you can draft the config, verify the syntax with nix flake check, and enable the service when you're ready without losing the work.

Next up: keeping secrets out of the Nix store with agenix.