Deploying Docker Containers with Nix
Feb 25, 2025 - ⧖ 6 minI'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.
# virtualization.nix
{...}: {
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
# containers/whoami.nix
{...}: {
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
# containers/traefik.nix
{config, ...}: {
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.
# virtualisation.nix
{pkgs, ...}: {
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.
# containers/whoami.nix
{...}: {
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.whoami.rule" = "Host(`whoami.example.com`)";
};
};
}
And that's it! Soon I'll have some articles on managing secrets with nix-sops, and OIDC with forward auth middleware via traefik + keycloak.