Managing Docker containers with Docker Compose in NixOS
I have been warping my head around the easiest implementation of using docker compose in NixOS to best suit my needs for deployments. Turns out there are a lot of ways to do this, but most were not something that would fit my needs that in general include reliable, revertable updates to docker compose files. Thanks to the Jupiter Broadcasting community I have stumbled upon a project conveniently named compose2nix that converts just like the names says docker-compose files into NixOS configurations. So this is a small guide is dedicated to explaining how I use this implementation of it and why I like it. I hope this will help some of you get an idea on if this is a good solution for you as well.
How it works
Implementing docker compose files into Nix configuration so it is defined on system level is very simple using compose2nix TUI tool. Simply take a docker compose file and run the tool. It will output a docker-compose.nix file that will start a systemd service on startup and deploy the containers. When you want to update the version of your stack you simply change the version of the images in Nix file and commit that to your configuration Nix repository. If there are changes in the underlying compose structure you simply rerun compose2nix to add new changes. Keep in mind that simply updating the container versions would not count as a reliable and safe upgrade of a service so it would be good to think about backing up the volumes first. For this I will explain in more detail at the end as well.
Example implementation
Using Mealie.io docker compose stack as an example and converting it to Nix configuration:
---
version: "3.7"
services:
mealie:
image: ghcr.io/mealie-recipes/mealie:v1.3.2 #
container_name: mealie
ports:
- "9925:9000" #
deploy:
resources:
limits:
memory: 1000M #
depends_on:
- postgres
volumes:
- mealie-data:/app/data/
environment:
# Set Backend ENV Variables Here
- ALLOW_SIGNUP=true
- PUID=1000
- PGID=1000
- TZ=America/Anchorage
- MAX_WORKERS=1
- WEB_CONCURRENCY=1
- BASE_URL=https://mealie.yourdomain.com
# Database Settings
- DB_ENGINE=postgres
- POSTGRES_USER=mealie
- POSTGRES_PASSWORD=mealie
- POSTGRES_SERVER=postgres
- POSTGRES_PORT=5432
- POSTGRES_DB=mealie
restart: always
postgres:
container_name: postgres
image: postgres:15
restart: always
volumes:
- mealie-pgdata:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: mealie
POSTGRES_USER: mealie
volumes:
mealie-data:
driver: local
mealie-pgdata:
driver: local
Note that compose2nix has a few other parameters it accepts that I recommend you take a look before running.
Now for the magic words:
compose2nix --project mealieio --runtime docker
Generated NixOS config in 2.284698ms
Wrote NixOS config to docker-compose.nix
Now you have a file named docker-compose.nix in your directory as it says that looks like so:
# Auto-generated using compose2nix v0.1.9.
{ pkgs, lib, ... }:
{
# Runtime
virtualisation.docker = {
enable = true;
autoPrune.enable = true;
};
virtualisation.oci-containers.backend = "docker";
# Containers
virtualisation.oci-containers.containers."mealie" = {
image = "ghcr.io/mealie-recipes/mealie:v1.4.0";
environment = {
ALLOW_SIGNUP = "true";
BASE_URL = "https://mealie.yourdomain.com";
DB_ENGINE = "postgres";
MAX_WORKERS = "1";
PGID = "1000";
POSTGRES_DB = "mealie";
POSTGRES_PASSWORD = "mealie";
POSTGRES_PORT = "5432";
POSTGRES_SERVER = "postgres";
POSTGRES_USER = "mealie";
PUID = "1000";
TZ = "America/Anchorage";
WEB_CONCURRENCY = "1";
};
volumes = [
"mealie-data:/app/data:rw"
];
ports = [
"9925:9000/tcp"
];
dependsOn = [
"postgres"
];
log-driver = "journald";
extraOptions = [
"--memory=1048576000b"
"--network-alias=mealie"
"--network=mealieio_default"
];
};
systemd.services."docker-mealie" = {
serviceConfig = {
Restart = lib.mkOverride 500 "always";
RestartMaxDelaySec = lib.mkOverride 500 "1m";
RestartSec = lib.mkOverride 500 "100ms";
RestartSteps = lib.mkOverride 500 9;
};
after = [
"docker-network-mealieio_default.service"
"docker-volume-mealieio_mealie-data.service"
];
requires = [
"docker-network-mealieio_default.service"
"docker-volume-mealieio_mealie-data.service"
];
partOf = [
"docker-compose-mealieio-root.target"
];
unitConfig.UpheldBy = [
"docker-postgres.service"
];
wantedBy = [
"docker-compose-mealieio-root.target"
];
};
virtualisation.oci-containers.containers."postgres" = {
image = "postgres:15";
environment = {
POSTGRES_PASSWORD = "mealie";
POSTGRES_USER = "mealie";
};
volumes = [
"mealie-pgdata:/var/lib/postgresql/data:rw"
];
log-driver = "journald";
extraOptions = [
"--health-cmd='[\"pg_isready\"]'"
"--health-interval=30s"
"--health-retries=3"
"--health-timeout=20s"
"--network-alias=postgres"
"--network=mealieio_default"
];
};
systemd.services."docker-postgres" = {
serviceConfig = {
Restart = lib.mkOverride 500 "always";
RestartMaxDelaySec = lib.mkOverride 500 "1m";
RestartSec = lib.mkOverride 500 "100ms";
RestartSteps = lib.mkOverride 500 9;
};
after = [
"docker-network-mealieio_default.service"
"docker-volume-mealieio_mealie-pgdata.service"
];
requires = [
"docker-network-mealieio_default.service"
"docker-volume-mealieio_mealie-pgdata.service"
];
partOf = [
"docker-compose-mealieio-root.target"
];
wantedBy = [
"docker-compose-mealieio-root.target"
];
};
# Networks
systemd.services."docker-network-mealieio_default" = {
path = [ pkgs.docker ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStop = "${pkgs.docker}/bin/docker network rm -f mealieio_default";
};
script = ''
docker network inspect mealieio_default || docker network create mealieio_default
'';
partOf = [ "docker-compose-mealieio-root.target" ];
wantedBy = [ "docker-compose-mealieio-root.target" ];
};
# Volumes
systemd.services."docker-volume-mealieio_mealie-data" = {
path = [ pkgs.docker ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
docker volume inspect mealieio_mealie-data || docker volume create mealieio_mealie-data
'';
partOf = [ "docker-compose-mealieio-root.target" ];
wantedBy = [ "docker-compose-mealieio-root.target" ];
};
systemd.services."docker-volume-mealieio_mealie-pgdata" = {
path = [ pkgs.docker ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
docker volume inspect mealieio_mealie-pgdata || docker volume create mealieio_mealie-pgdata
'';
partOf = [ "docker-compose-mealieio-root.target" ];
wantedBy = [ "docker-compose-mealieio-root.target" ];
};
# Root service
# When started, this will automatically create all resources and start
# the containers. When stopped, this will teardown all resources.
systemd.targets."docker-compose-mealieio-root" = {
unitConfig = {
Description = "Root target generated by compose2nix.";
};
wantedBy = [ "multi-user.target" ];
};
}
Big scary block of code, right?
Under the hood it will implement oci-containers module from Nix and make a docker-compose-mealieio-root service that can be used to stopping and starting the deployment.
To incorporate this into our Nix configuration we simply add the the nix file into the include block of the main configuration.nix like so:
{ config, pkgs, ... }:
{
imports =
[
./docker-compose/mealieio/docker-compose.nix
#other imported files...
];
...
Now to apply the new Nix OS configuration with Mealie docker implementation.
sudo nixos-rebuild switch --flake .#jarvis
Automation
What I have demonstrated is all great but it lacks the sort of automated feel that you would expect is possible and that would include a backing up in case of a disaster that can happen on upgrades. Where it is possible to automate the following way in nix as well I decided to go the simpler route as with just a shell script that is run independently from the configuration but is still part of the base git repository.
So lets look at the following simple script using BTRFS subvolume snapshoting as a base of the backup of docker volumes.
#! /usr/bin/env nix-shell
#! nix-shell -i bash -p bash
VERSION="v1.100.0"
HOST="Server1"
echo "Stopping stack"
sudo systemctl stop docker-compose-mealie-root.target
echo "creating a snapshot"
sudo btrfs subvolume snapshot /data/mealieio /data/.snapshots/mealieio_PRE-${VERSION}
echo "Recreating nix configuration from docker compose"
compose2nix -check_systemd_mounts -project="mealieio" -runtime="docker"
echo "Rebuilding system"
sudo nixos-rebuild switch --flake .#${HOST}
Just in case you are not familiar with the above commands lets go over them:
first we stop the service running the docker compose that will effectively gracefully shut down all containers create a snapshot of the docker volume that holds all the data from the containers recreate the docker-compose.nix from docker-compose.yml that you have edited to fit the upgrade rebuild your Nix OS configuration(this command is using flakes) Ending thoughts This way of going about managing docker compose stacks in Nix OS is by no means the only way you can do it, but I have found this to work best for me and my needs. I hope it can server as an inspiration for your setup.
Special thanks to Assil Ksiksi for creating this library and the Jupiter Broadcasting community.
Happy Nixing!