Docker image with Nix and Guix

2025-04-17 published | 2025-04-17 edited | 2461 words

We want to compare how to build a Docker image using Nix and Guix. We want to make it simple, thereof a simple example:

Desired result:

Technologies used:

The repository with files is damn-docker-nix-guix:

git clone https://git.sr.ht/~qeef/damn-docker-nix-guix
cd damn-docker-nix-guix
docker-compose up

and see http://localhost:8000/docs.

🐋 Docker

Bigger picture: docker-compose.yml contains two important services: db and api.
version: '2.2'
services:
  db:
    build: damndb
    restart: always
    cpus: '0.8'
    volumes:
      - damndb-volume:/var/lib/postgresql/data/
    command: ["postgres", "-c", "shared_buffers=256MB", "-c", "wal_buffers=32MB", "-c", "work_mem=32MB"]
    # shared_buffers=409MB should work for 1 GB RAM
    networks:
      default:
        aliases:
          - damndb_server
  api:
    build: server
    restart: always
    cpus: '0.8'
    volumes:
      - ./server/tmp:/app/tmp
    links:
      - db
    environment:
      - DB_HOST=damndb_server
    ports:
      - 8000:80
    working_dir: /app # Remove for Nix!
volumes:
  damndb-volume:

Along with the docker-compose.yml, there comes two Dockerfiles. First is for the db Docker image, for the database.
FROM postgis/postgis:15-3.3-alpine

ENV POSTGRES_PASSWORD ${POSTGRES_PASSWORD:-pass}
ENV POSTGRES_USER ${POSTGRES_USER:-damnuser}
ENV POSTGRES_DB ${POSTGRES_DB:-damndb}

ADD --chown=postgres https://git.sr.ht/~qeef/damn-server/blob/master/20_create_damn_db.sql /docker-entrypoint-initdb.d/

Second is for the api Docker image, for the application. Inside the Dockerfile for the api service, there are the following steps:

The goal is to replace this file.
FROM python:3.11

RUN git clone \
    --depth=1 \
    --single-branch \
    -b v0.30.0 \
    https://git.sr.ht/~qeef/damn-server /app

WORKDIR /app
RUN pip install --upgrade pip
RUN pip install -r /app/requirements.dev.txt
RUN pip install -r /app/requirements.doc.txt

CMD ["uvicorn", "damn_server.api:app", "--host", "0.0.0.0", "--port", "80"]

When the docker-compose up is executed and Docker images are missing, they are built from corresponding Dockerfiles. We are going to build them using Nix and Guix before the execution, so docker-compose up can use them.

Main difference between using Dockerfile and Nix or Guix is that Dockerfile contains commands to execute (to build Docker image) but relevant files of Nix or Guix contain relations of creation (of Docker image).

The steps to follow are in the damn-docker-nix-guix repository, here are notes about Nix and Guix approach.

❄ Nix

We use Nix flakes, so we write what we want into flake.nix.
{
  inputs.damn-server = {
      url = "git+https://git.sr.ht/~qeef/damn-server";
      flake = false;
  };

  outputs = {self, nixpkgs, damn-server}: let
    system = "x86_64-linux";
    pkgs = nixpkgs.legacyPackages.${system};
  in
    {
      docker =
        pkgs.dockerTools.buildImage
        {
          name = "damn-docker-nix-guix_api";
          tag = "latest";
          config = {
            Cmd = [
              "uvicorn"
              "damn_server.api:app"
              "--host" "0.0.0.0"
              "--port" "80"
            ];
            WorkingDir = damn-server;
            ExposedPorts = {
              "80/tcp" = {};
            };
          };
          copyToRoot =
            pkgs.buildEnv
            {
              name = "run-damn-server";
              paths = [
                (pkgs.python3.withPackages
                  (pypkgs: with pypkgs; [
                    uvicorn
                    fastapi
                    pyjwt
                    requests-oauthlib
                    itsdangerous
                    asyncpg
                  ]))
              ];
            };
        };
    };
}

flake.lock pins the state of Nix and is generated automatically when there is none.
{
  "nodes": {
    "damn-server": {
      "flake": false,
      "locked": {
        "lastModified": 1735238811,
        "narHash": "sha256-miOkxoLRF4LdmnYlPowZ5zT6S14FgZocpYxVeI2BjRs=",
        "ref": "refs/heads/master",
        "rev": "6e7d6c94eabfa9f740547322d2fb04be0a426622",
        "revCount": 796,
        "type": "git",
        "url": "https://git.sr.ht/~qeef/damn-server"
      },
      "original": {
        "type": "git",
        "url": "https://git.sr.ht/~qeef/damn-server"
      }
    },
    "nixpkgs": {
      "locked": {
        "lastModified": 1744536153,
        "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
        "owner": "NixOS",
        "repo": "nixpkgs",
        "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
        "type": "github"
      },
      "original": {
        "id": "nixpkgs",
        "type": "indirect"
      }
    },
    "root": {
      "inputs": {
        "damn-server": "damn-server",
        "nixpkgs": "nixpkgs"
      }
    }
  },
  "root": "root",
  "version": 7
}

There are conventions about flake.nix content; there are many things flake.nix may contain. We are here today to see how Docker image is built, so I will skip explanation of Nix language oddities and rather aim on the goal. I wrote about Nix already:

To build a Docker image, we will use

nix build .#docker

Therefore, we need to add docker output. The function to create a docker image is buildImage from the dockerTools package.

docker =
  pkgs.dockerTools.buildImage
  {

Recall the steps from the Dockerfile: clone repo, install requirements, set working directory, set default command.

To clone repo, we add damn-server input.

inputs.damn-server = {
  url = "git+https://git.sr.ht/~qeef/damn-server";
  flake = false;
};

Then, we need to use this input in the attribute set parameter of the outputs function.

outputs = {self, nixpkgs, damn-server}: let

Finally, we set the damn-server as the WorkingDir.

WorkingDir = damn-server;

Two things done!

Setting default Cmd is copying the CMD from Dockerfile and removing commas.

Cmd = [
  "uvicorn"
  "damn_server.api:app"
  "--host" "0.0.0.0"
  "--port" "80"
];

Last remaining is requirements install. For this, we use our favorite

(pkgs.python3.withPackages (pypkgs: with pypkgs; [

followed by Python packages to install. Exact versions of Python packages and damn-server input are stored in flake.lock.

To build and load Docker image and run the application:

nix build .#docker
docker load < result
docker-compose up

and see http://localhost:8000/docs.

flake.nix yet again.
{
  inputs.damn-server = {
      url = "git+https://git.sr.ht/~qeef/damn-server";
      flake = false;
  };

  outputs = {self, nixpkgs, damn-server}: let
    system = "x86_64-linux";
    pkgs = nixpkgs.legacyPackages.${system};
  in
    {
      docker =
        pkgs.dockerTools.buildImage
        {
          name = "damn-docker-nix-guix_api";
          tag = "latest";
          config = {
            Cmd = [
              "uvicorn"
              "damn_server.api:app"
              "--host" "0.0.0.0"
              "--port" "80"
            ];
            WorkingDir = damn-server;
            ExposedPorts = {
              "80/tcp" = {};
            };
          };
          copyToRoot =
            pkgs.buildEnv
            {
              name = "run-damn-server";
              paths = [
                (pkgs.python3.withPackages
                  (pypkgs: with pypkgs; [
                    uvicorn
                    fastapi
                    pyjwt
                    requests-oauthlib
                    itsdangerous
                    asyncpg
                  ]))
              ];
            };
        };
    };
}

🦬 Guix

Guix needs two files. First, manifest.scm, contains packages to consider. We can understand the Guix’s manifest as a list of packages.
(use-modules (guix packages)
             (guix git-download)
             (guix build-system copy)
             (guix licenses))

(define damn-server
  (package
    (name "damn-server")
    (version "master")
    (source
      (origin
        (method git-fetch)
        (uri
          (git-reference
            (url "https://git.sr.ht/~qeef/damn-server")
            (commit version)))
        (sha256
          (base32 "06wdh66phmccllf9m085br5zld773663w9bnkbfq45yihb3a88ws"))))
    (build-system copy-build-system)
    (arguments
      '(#:install-plan '(("." "."))))
    (synopsis "server part of the damn project")
    (description "server part of the damn project")
    (home-page "https://damn-project.org/")
    (license agpl3)))

(concatenate-manifests
  (list
    (specifications->manifest
      (list "python"
            "python-uvicorn"
            "python-fastapi"
            "python-pyjwt"
            "python-requests-oauthlib"
            "python-asyncpg"))
    (packages->manifest
      (list damn-server))))

Second, channels.scm, generated by

guix describe -f channels > channels.scm
pins the state of Guix.
(list (channel
        (name 'guix)
        (url "https://git.savannah.gnu.org/git/guix.git")
        (branch "master")
        (commit
          "287aec56a53fbd66492711c375d0b39ccb6c1590")
        (introduction
          (make-channel-introduction
            "9edb3f66fd807b096b48283debdcddccfea34bad"
            (openpgp-fingerprint
              "BBB0 2DDF 2CEA F6A8 0D1D  E643 A2A0 6DF2 A33A 54FA")))))

I did not write about Guix, yet. Probably, I should say at least:

Because of the last point, we start our tour of Guix’s manifest in manifest.scm from the bottom line, respective from the beginning of the last expression.

(concatenate-manifests
  (list

takes a list of manifests, creating a single manifest from them.

    (specifications->manifest
      (list "python"
            "python-uvicorn"
            "python-fastapi"
            "python-pyjwt"
            "python-requests-oauthlib"
            "python-asyncpg"))

is how a manifest is created from the list of package names.

    (packages->manifest
      (list damn-server))))

is how a manifest is created from the list of packages, here only a single package stored in the damn-server variable.

Because Guix’s manifest is a list of packages and nothing more, we need to describe repository cloning as a package.

(define damn-server
  (package

says that the value of the damn-server variable is a package.

    (name "damn-server")
    (version "master")

specify package’s properties, which can be later used as variables.

    (source
      (origin
        (method git-fetch)
        (uri
          (git-reference
            (url "https://git.sr.ht/~qeef/damn-server")
            (commit version)))
        (sha256
          (base32 "06wdh66phmccllf9m085br5zld773663w9bnkbfq45yihb3a88ws"))))

is where the repository is cloned. We can see that we need to specify the hash of the result. channels.scm keeps track of current Guix state, but not the state of our new package.

    (build-system copy-build-system)
    (arguments
      '(#:install-plan '(("." "."))))

describes how to build the package. In our case, “to build package” means “to copy content of cloned repository somewhere”, therefore (build-system copy-build-system).

That (arguments ... expression is a bit cryptic, though. Let’s say that #:install-plan is a keyword argument for the copy-build-system and that its value, '(("." ".")), describes copying the content of the cloned repo (first ".") into manifest’s base directory (second ".").

(A manifest’s base directory is not. The true is that the second "." is a profile’s base directory and that Guix’s manifest is not just a list of packages, but a thing from which a profile with a list of packages available is created. However, I’m not yet ready to say what that means.)

Finally,

    (synopsis "server part of the damn project")
    (description "server part of the damn project")
    (home-page "https://damn-project.org/")
    (license agpl3)))

are package’s metadata.

Recall the steps from the Dockerfile: clone repo, install requirements, set working directory, set default command.

First two are described in manifest.scm. Last two need to be described as command line arguments, because Guix’s manifest only specifies a list of packages.

It looks like it’s time for command line.

docker load < $( \
  guix time-machine -C channels.scm -- pack \
  -m manifest.scm \
  -f docker \
  --image-tag=damn-docker-nix-guix_api \
  --entry-point=bin/uvicorn \
  -A 'damn_server.api:app' \
  -A '--host' -A '0.0.0.0' \
  -A '--port' -A '80' \
  -S /damn_server=damn_server)

This is fine… Wait, what?

docker load < $( \

means that the result of subshell evaluation $( ... is loaded as Docker image.

  guix time-machine -C channels.scm -- pack \

is a combination of guix time-machine – the utility for reproducible builds, which uses channels.scm created earlier, and guix pack – the utility for bundling together Guix packages.

  -m manifest.scm \

says that guix pack utility should use Guix’s manifest we created earlier as the source for a list of packages to bundle.

  -f docker \
  --image-tag=damn-docker-nix-guix_api \
  --entry-point=bin/uvicorn \
  -A 'damn_server.api:app' \
  -A '--host' -A '0.0.0.0' \
  -A '--port' -A '80' \

specifies the output format of the bundle – it will be Docker image; then Docker image name and Docker image entrypoint with entrypoint’s arguments.

  -S /damn_server=damn_server)

creates symlink from /damn_server to the damn_server in the manifest’s base directory. We need this because / is Docker image’s working directory.

When the command succeeds, run

docker-compose up

and see http://localhost:8000/docs.

manifest.scm yet again.
(use-modules (guix packages)
             (guix git-download)
             (guix build-system copy)
             (guix licenses))

(define damn-server
  (package
    (name "damn-server")
    (version "master")
    (source
      (origin
        (method git-fetch)
        (uri
          (git-reference
            (url "https://git.sr.ht/~qeef/damn-server")
            (commit version)))
        (sha256
          (base32 "06wdh66phmccllf9m085br5zld773663w9bnkbfq45yihb3a88ws"))))
    (build-system copy-build-system)
    (arguments
      '(#:install-plan '(("." "."))))
    (synopsis "server part of the damn project")
    (description "server part of the damn project")
    (home-page "https://damn-project.org/")
    (license agpl3)))

(concatenate-manifests
  (list
    (specifications->manifest
      (list "python"
            "python-uvicorn"
            "python-fastapi"
            "python-pyjwt"
            "python-requests-oauthlib"
            "python-asyncpg"))
    (packages->manifest
      (list damn-server))))

☯ Conclusion

We compare two approaches to build Docker image – Nix and Guix. Our use-case is to replace a Dockerfile in the working docker-compose up deployment.

We use Nix flake to specify how Docker image is built; there is a dedicated function to build a Docker image. We specify desired Git repository as input and use it directly as Docker image’s working directory.

Guix’s manifest is a list of packages. Therefore, we need to make new package from desired Git repository; new package describes cloning and copying the repository content. Then, new package is converted to Guix’s manifest.

Installing the requirements – Python packages – means, at the end, enumerating them for both, Nix and Guix. For Nix, it needs to be specified that the packages should be copied into the Docker image.

For Guix, the list of packages is converted to manifest. Then, both manifests, requirements’ and new package’s, are concatenated.

flake.nix:

{
  inputs.damn-server = {
      url = "git+https://git.sr.ht/~qeef/damn-server";
      flake = false;
  };

  outputs = {self, nixpkgs, damn-server}: let
    system = "x86_64-linux";
    pkgs = nixpkgs.legacyPackages.${system};
  in
    {
      docker =
        pkgs.dockerTools.buildImage
        {
          name = "damn-docker-nix-guix_api";
          tag = "latest";
          config = {
            Cmd = [
              "uvicorn"
              "damn_server.api:app"
              "--host" "0.0.0.0"
              "--port" "80"
            ];
            WorkingDir = damn-server;
            ExposedPorts = {
              "80/tcp" = {};
            };
          };
          copyToRoot =
            pkgs.buildEnv
            {
              name = "run-damn-server";
              paths = [
                (pkgs.python3.withPackages
                  (pypkgs: with pypkgs; [
                    uvicorn
                    fastapi
                    pyjwt
                    requests-oauthlib
                    itsdangerous
                    asyncpg
                  ]))
              ];
            };
        };
    };
}

manifest.scm:

(use-modules (guix packages)
             (guix git-download)
             (guix build-system copy)
             (guix licenses))

(define damn-server
  (package
    (name "damn-server")
    (version "master")
    (source
      (origin
        (method git-fetch)
        (uri
          (git-reference
            (url "https://git.sr.ht/~qeef/damn-server")
            (commit version)))
        (sha256
          (base32 "06wdh66phmccllf9m085br5zld773663w9bnkbfq45yihb3a88ws"))))
    (build-system copy-build-system)
    (arguments
      '(#:install-plan '(("." "."))))
    (synopsis "server part of the damn project")
    (description "server part of the damn project")
    (home-page "https://damn-project.org/")
    (license agpl3)))

(concatenate-manifests
  (list
    (specifications->manifest
      (list "python"
            "python-uvicorn"
            "python-fastapi"
            "python-pyjwt"
            "python-requests-oauthlib"
            "python-asyncpg"))
    (packages->manifest
      (list damn-server))))

No arguments are needed for Nix to build Docker image, all is in flake.nix. Also, flake.lock for pinning Nix package versions is used automatically.

Guix needs to know what to do with the manifest, the list of packages: pack them into Docker image format. It also needs Docker image-specific options like image name or entrypoint to be given as arguments. I did not find out how to set Docker image’s working directory, it’s /. And pinning of the Guix package versions needs to be done manually.

for Nix:

nix build .#docker
docker load < result
docker-compose up

for Guix:

docker load < $( \
  guix time-machine -C channels.scm -- pack \
  -m manifest.scm \
  -f docker \
  --image-tag=damn-docker-nix-guix_api \
  --entry-point=bin/uvicorn \
  -A 'damn_server.api:app' \
  -A '--host' -A '0.0.0.0' \
  -A '--port' -A '80' \
  -S /damn_server=damn_server)
docker-compose up

At the end, I am not saying if Nix or Guix is better. But I have opinion on which one feels better.

go back | CC BY-NC-SA 4.0 Jiri Vlasak