Deploying Docker Containers with Nix

Published:


I’ve slowly been migrating my ansible-based homelab provisioning setup to NixOS.

I was worried at first since I wasn’t sure how well it’d support docker and docker-compose, but it’s been almost* flawless so far.

The magic lies in virtualisation.oci-containers.containers.

Setup #

The first thing we need to do is enable an oci backend, either docker or podman. I’m used to docker so I went with the rootless version.

virtualisation = {
    docker.rootless.enable = true;
    docker.rootless.setSocketVariable = true;
    docker.autoPrune.enable = true;
    containerd.enable = true;

    oci-containers.backend = "docker"; # defaults to podman
};

environment.sessionVariables = {
    DOCKER_HOST = "unix:///run/docker.sock"; # fix for rootless docker
};
Note

There is the virtualisation.docker.rootless.setSocketVariable option but it didn’t work for me, so I set DOCKER_HOST manually.

Our First Container #

{...}: {
  virtualisation.oci-containers.containers.whoami = {
    autoStart = true;
    ports = ["8080"];
    image = "docker.io/andrewzah/whoami";
  };
}

The options available here map to docker compose options. I just looked at the old docker-compose.yml and translated it over to Nix’s syntax.

With this and the above docker setup, you should be able to run nixos-rebuild switch <...> and have a whoami container.

Now this is all well and good, but I imagine most selfhosters want to make services available. This is where docker networks and traefik come in.

Traefik #

sops.secrets."traefik/env" = {};

virtualisation.oci-containers.containers.traefik = {
    autoStart = true;
    image = "docker.io/library/traefik:v3.1.4@sha256:6215528042906b25f23fcf51cc5bdda29e078c6e84c237d4f59c00370cb68440";
    cmd = [
      "--api.insecure=false"
      "--api.dashboard=false"

      "--log.level=INFO" # ERROR default

      ## providers
      "--providers.docker=true"
      "--providers.docker.exposedbydefault=false"

      ## entrypoints
      "--entrypoints.web.address=:80"
      "--entrypoints.websecure.address=:443"
      "--entrypoints.ssh.address=:22"
      "--entrypoints.web.forwardedHeaders.insecure"
      "--entrypoints.websecure.forwardedHeaders.insecure"

      ## entrypoint redirections
      "--entrypoints.web.http.redirections.entryPoint.to=websecure"
      "--entrypoints.web.http.redirections.entryPoint.scheme=https"
      "--entrypoints.web.http.redirections.entrypoint.permanent=true"

      ## generic resolver
      "--certificatesresolvers.generic.acme.tlschallenge=true"
      "[email protected]"
      "--certificatesresolvers.generic.acme.storage=/letsencrypt/acme.json"
      #"--certificatesResolvers.generic.acme.caServer=https://acme-staging-v02.api.letsencrypt.org/directory"

      ## cloudflare resolver
      "--certificatesresolvers.cloudflare.acme.storage=/letsencrypt/cloudflare-acme.json"
      "--certificatesresolvers.cloudflare.acme.email=admin@andrewzah.com"
      "--certificatesresolvers.cloudflare.acme.dnschallenge=true"
      "--certificatesresolvers.cloudflare.acme.dnsChallenge.provider=cloudflare"
      "--certificatesresolvers.cloudflare.acme.dnsChallenge.delayBeforeCheck=0"
      "--certificatesresolvers.cloudflare.acme.dnsChallenge.resolvers=1.1.1.1:53"
      #"--certificatesResolvers.cloudflare.acme.caServer=https://acme-staging-v02.api.letsencrypt.org/directory"
    ];
    ports = [
      "80:80"
      "443:443"
      "8080:8080"
    ];
    extraOptions = ["--net=external"];
    environmentFiles = [config.sops.secrets."traefik/env".path];
    labels = {
      "traefik.enable" = "true";
      "traefik.http.routers.http-catchall.rule" = "hostregexp(`{host:.+}`)";
      "traefik.http.routers.http-catchall.entrypoints" = "web";
      "traefik.http.routers.http-catchall.middlewares" = "redirect-to-https@docker";
      "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme" = "https";
      "traefik.http.middlewares.redir.redirectScheme.scheme" = "https";
    };
    volumes = [
      "/your/data/dir/traefik/letsencrypt/:/letsencrypt/:rw"
      "/run/docker.sock:/var/run/docker.sock:ro"
    ];
};
Note

The full context can be found in my github repository. Traefik also has comprehensive documentation.

Notice the line with extraOptions = ["--net=external"];. Nix won’t automatically make docker networks for us, so I ended up adding a system oneshot service to do so.

Depending on your traefik configuration, you may need to pass credentials. I use dns authentication with Cloudflare so I encrypted the env vars with sops-nix and pointed to a file with virtualisation.oci-containers.containers.<name>.environmentFiles. Dealing with secrets (sops + nix-sops) will be a separate article in the future.

systemd.services.create-docker-networks = {
    description = "Create docker networks manually";
    after = ["docker.service"];
    wants = ["docker.service"];
    wantedBy = [
      "docker-traefik.service"
      "docker-postgres.service"
    ];

    serviceConfig = {
      Type = "oneshot";
      RemainAfterExit = true;
    };

    script = ''
      ${pkgs.docker}/bin/docker network inspect internal || ${pkgs.docker}/bin/docker network create internal
      ${pkgs.docker}/bin/docker network inspect external || ${pkgs.docker}/bin/docker network create external
    '';
};

Containers deployed with oci-containers.containers.<name> will have a corresponding docker-<name>.service definition. So here we can run the oneshot before e.g. traefik and postgres.

Our First Container (Again) #

Now that Traefik is running and we have our networks, we can link up the whoami container.

{...}: let
  fqdn = "whoami.example.com";
  router = "whoami";
in {
    virtualisation.oci-containers.containers.whoami = {
        autoStart = true;
        ports = ["8080"];
        image = "docker.io/andrewzah/whoami";
        dependsOn = ["traefik"];
        extraOptions = ["--net=external"];
        labels = {
            "traefik.enable" = "true";
            "traefik.http.routers.authentik.rule" = "Host(`${fqdn}`) && PathPrefix(`/outpost.goauthentik.io/`)";
            "traefik.http.routers.${router}.rule" = "Host(`${fqdn}`)";
        };
    };
}

And that’s it! Soon I’ll have some articles on managing secrets with sops/nix-sops, and OIDC with Forward Auth via Keycloak / Authentik.

564 words — under nix, docker, selfhosting, traefik