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:
A Python repository with a FastAPI application. It’s not a Python
package, just run uvicorn inside the repository
root.
Known deploy with docker-compose.
Desired result:
docker-compose build. Today, because
docker-compose build could break tomorrow.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-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:
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:
Clone the application repository into the /app
directory.
Install the requirements.
Set the working directory.
Set the default command to run.
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.
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}: letFinally, 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
]))
];
};
};
};
}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:
Guix uses programming language Guile – implementation of Scheme Lisp dialect. See Scheme primer.
In Scheme, ( and ) denote
expression.
First element after ( is a function name.
All the other elements are the function arguments.
The result of the .scm file is the result of the
evaluation of the last expression.
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
(listtakes 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
(packagesays 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))))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