Adding a Homelab Service Declaratively with NixOS and Podman

The base NixOS host guide stops at a machine that can boot, accept SSH, and rebuild cleanly. That is where it should stop first. If the host itself is still wobbly, adding apps is not progress. It is just more debris.

The next step is not “install a bunch of apps.” The next step is to add one service without pretending that the service has no state, no startup expectations, and no backup consequences.

If you worked through Building a Minimal NixOS Homelab Host, keep the same host repo and the same host shape. In practice, you are editing that same host repo and adding one small service module to it. The Minimal NixOS Homelab Base still owns the machine; the companion repo here only shows the pattern.

This guide uses linkding as the example because it is a real app, small enough to stay narrow, and documented publicly. It is also stateful, which is exactly why it is useful here. A static hello-world container proves that Podman can spell hello. It does not teach where the data lives or how you will recover it later.

What This Guide Covers

This is a follow-up to the base host guide and companion repo, not a second host-install article.

In scope:

  • building directly on the repo-backed host pattern from Building a Minimal NixOS Homelab Host
  • one real service: linkding
  • one persistent host data path
  • one env file
  • one NixOS module using virtualisation.oci-containers
  • one generated systemd service
  • basic validation and backup thinking

Out of scope:

  • reverse proxy implementation
  • OIDC
  • secrets management
  • a second database container
  • a full stack of apps because the server has spare RAM

Linkding itself documents a normal single-container install and uses SQLite by default, with its durable data mounted into /etc/linkding/data inside the container. That is the useful boundary for this article: one service with one obvious state path and no elaborate dependencies. The upstream install, options, and backup docs are here:

Why Podman Here, Not Docker

This guide uses Podman because it follows the built-in NixOS virtualisation.oci-containers path that sits cleanly on top of the base host from Building a Minimal NixOS Homelab Host.

Docker can work too. Adding a second runtime story this early would widen the guide without helping the reader.

Decide Where State Lives First

Before declaring the container, decide where the durable state should live on the host.

For this linkding example, the important container path is:

  • /etc/linkding/data

That is the path the official install docs mount from the host. It holds the SQLite database and the related bookmark assets. If that path matters, then the host path mounted to it matters. If you do not make that decision deliberately, the service is still disposable no matter how declarative the container definition looks.

For the public example, I use:

  • host path: /srv/linkding
  • container path: /etc/linkding/data

That is not the only valid host path. It is just a boring one.

Keep The Service Module Small

The intended repo flow is simple:

  • keep the base-host repo as the machine definition
  • adapt the reviewed linkding module pattern into that repo
  • use this companion repo as a small reference template

The companion example keeps the service repo narrow:

suite-despair-nixos-podman-service-template/
  flake.nix
  modules/
    linkding.nix
  examples/
    linkding.env.example
  docs/
    service-intake.md
    validation.md
    privacy-notes.md

The base host repo is here: suite-despair-nixos-homelab-base.

The reviewed service-layer companion repo is here: suite-despair-nixos-podman-service-template.

The module owns the runtime details. The host configuration only sets the values that are specific to this service on this machine.

Here is the host-side shape:

{
  imports = [
    ./modules/linkding.nix
  ];

  suiteDespair.services.linkding = {
    enable = true;
    dataDir = "/srv/linkding";
    envFile = "/etc/linkding/linkding.env";
    publicBaseUrl = "https://bookmarks.example.test";
  };
}

That should feel smaller than a large Compose file because the service is small. If the first service definition already needs a forest of conditionals and sidecar containers, you have chosen the wrong teaching example.

The base-host repo still owns the machine. The service template contributes one reviewed pattern. If that line gets blurry, you are halfway to growing a second pile of infrastructure by accident.

The Module Should Express The Real Boundaries

Containers are cheap. State mistakes are not. The module is doing four operational jobs: enabling the runtime, creating the host state directory, binding that directory into the container, and handing the result to a normal systemd unit you can inspect.

{ config, lib, ... }:
let
  cfg = config.suiteDespair.services.linkding;
in
{
  config = lib.mkIf cfg.enable {
    virtualisation.containers.enable = true;
    virtualisation.podman.enable = true;
    virtualisation.oci-containers.backend = "podman";

    systemd.tmpfiles.rules = [
      "d ${cfg.dataDir} 0750 root root -"
    ];

    virtualisation.oci-containers.containers.linkding = {
      image = "ghcr.io/sissbruecker/linkding:latest";
      autoStart = true;
      ports = [ "${cfg.listenAddress}:${toString cfg.listenPort}:9090" ];
      environmentFiles = [ cfg.envFile ];
      environment = {
        LD_CSRF_TRUSTED_ORIGINS = cfg.publicBaseUrl;
      };
      volumes = [ "${cfg.dataDir}:/etc/linkding/data" ];
    };
  };
}

The important parts are:

  • virtualisation.oci-containers.backend = "podman" keeps the runtime aligned with the path this guide is teaching
  • systemd.tmpfiles.rules creates the host state directory before the service starts
  • environmentFiles keeps credentials and first-user placeholders out of the Nix file
  • the bind mount makes the durable data boundary explicit
  • the generated podman-linkding unit gives you something normal to inspect with systemctl
  • the current NixOS OCI-container module wires the generated unit into network-online.target, so the example does not need a second hand-written service wrapper

The default listenAddress in the example module is 127.0.0.1. That is deliberate. Local-only first is easier to validate and harder to expose accidentally. If you later bind it to a wider address or put a proxy in front, treat that as a second decision with firewall consequences.

Use An Env File, But Treat It As A Placeholder

The example env file is intentionally small:

LD_SUPERUSER_NAME=replace-me
LD_SUPERUSER_PASSWORD=replace-me-with-a-long-random-password
LD_CSRF_TRUSTED_ORIGINS=https://bookmarks.example.test

That file is a convenience, not a security model. It is acceptable as a bootstrap mechanism. It is not a decision to keep credentials living there forever.

It means you still have to decide:

  • whether you are comfortable bootstrapping the first user through environment variables
  • whether the env file belongs in a secrets system later
  • whether the public URL and CSRF settings stay aligned if you add a reverse proxy

If you do not want the first-user credentials in an env file, remove those values and create the user manually after first start:

podman exec -it linkding python manage.py createsuperuser --username=replace-me --email=replace-me@example.test

That is the same upstream pattern linkding documents for container installs, adapted here from docker exec to podman exec. Either way, make a decision. Do not leave the placeholder password in place and then act surprised when the service joins the list of future regrets.

Validate The Service In Two Passes

First, validate the Nix side before deployment.

From the repo root:

nix flake check

On a Linux machine, or with a Linux remote builder available:

nix build .#nixosConfigurations.example-host.config.system.build.toplevel

On macOS or another non-Linux machine, use evaluation rather than pretending you built a Linux closure:

nix eval .#nixosConfigurations.example-host.config.system.build.toplevel.drvPath

Then deploy the host config and validate the running service:

systemctl status podman-linkding.service --no-pager
podman ps --format 'table {{.Names}}\t{{.Status}}'
curl -fsSI http://127.0.0.1:9090/
podman logs --tail=50 linkding

What counts as healthy:

  • podman-linkding.service is active
  • the linkding container is running
  • the local HTTP check returns a normal response
  • the logs do not show immediate migration, env-file, or permission failures

Do not confuse “a container exists” with “the service is usable.”

Routing Is Optional; Backup Scope Is Not

You can stop at a local-only service and still have a useful pattern. A reverse proxy is a separate choice. That is why the example defaults to loopback and treats the public base URL as a placeholder rather than pretending ingress is free.

Backup is not optional in the same way. The moment the bookmarks matter, the mounted host directory matters. In this example, that is /srv/linkding.

That is the boundary to protect:

  • not the image tag
  • not the generated unit
  • not the rebuild command
  • the host data path mounted into /etc/linkding/data

The broader 3-2-1 backup article covers how to think about protection layers. The narrower point here is simpler: if you add state to a rebuildable host, you have also added a backup obligation, whether you felt like doing that admin work or not.

The official linkding backup docs include a full-backup command from inside the container. In this Podman-shaped example, the runtime command becomes:

podman exec -it linkding python manage.py full_backup /etc/linkding/data/backup.zip

That is useful, but only after you have already decided what the host-side backup boundary is. A service-specific export is not a substitute for knowing which host path became real.

What This Pattern Buys You

This pattern is enough for a first real service:

  • the host still rebuilds from the same repo
  • the service state boundary is visible instead of implied
  • the validation path is boring enough to repeat after changes

It does not buy container sophistication. It buys fewer places for the real configuration to hide.

If you want to extend this pattern later, do it one decision at a time: proxy, auth, monitoring, backup automation, then harder app shapes. The mistake is not starting small. The mistake is thinking “it is only one container” means the operational work somehow does not count.