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
.
'2.2'
version:
services:
db:
build: damndb
restart: always'0.8'
cpus:
volumes:
- damndb-volume:/var/lib/postgresql/data/"postgres", "-c", "shared_buffers=256MB", "-c", "wal_buffers=32MB", "-c", "work_mem=32MB"]
command: [# shared_buffers=409MB should work for 1 GB RAM
networks:
default:
aliases:
- damndb_server
api:
build: server
restart: always'0.8'
cpus:
volumes:
- ./server/tmp:/app/tmp
links:
- db
environment:
- DB_HOST=damndb_server
ports:
- 8000:80# Remove for Nix!
working_dir: /app
volumes: damndb-volume:
docker-compose.yml
, there comes two
Dockerfile
s. 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 Dockerfile
s.
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 =
.dockerTools.buildImage
pkgs{
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 =
.buildEnv
pkgs{
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 =.dockerTools.buildImage
pkgs{
Recall the steps from the Dockerfile
: clone repo,
install requirements, set working directory, set default command.
To clone repo, we add damn-server
input.
.damn-server = {
inputsurl = "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.
{self, nixpkgs, damn-server}: let outputs =
Finally, we set the damn-server
as the
WorkingDir
.
-server; WorkingDir = damn
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 =
.dockerTools.buildImage
pkgs{
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 =
.buildEnv
pkgs{
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"damn-server")
(name "master")
(version
(source
(origin
(method git-fetch)
(uri
(git-reference"https://git.sr.ht/~qeef/damn-server")
(url
(commit version)))
(sha256"06wdh66phmccllf9m085br5zld773663w9bnkbfq45yihb3a88ws"))))
(base32
(build-system copy-build-system)
(arguments"." "."))))
'(#:install-plan '(("server part of the damn project")
(synopsis "server part of the damn project")
(description "https://damn-project.org/")
(home-page
(license agpl3)))
(concatenate-manifestslist
(
(specifications->manifestlist "python"
("python-uvicorn"
"python-fastapi"
"python-pyjwt"
"python-requests-oauthlib"
"python-asyncpg"))
(packages->manifestlist 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-manifestslist (
takes a list of manifests, creating a single manifest from them.
(specifications->manifestlist "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->manifestlist 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.
"damn-server")
(name "master") (version
specify package’s properties, which can be later used as variables.
(source
(origin
(method git-fetch)
(uri
(git-reference"https://git.sr.ht/~qeef/damn-server")
(url
(commit version)))
(sha256"06wdh66phmccllf9m085br5zld773663w9bnkbfq45yihb3a88ws")))) (base32
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,
"server part of the damn project")
(synopsis "server part of the damn project")
(description "https://damn-project.org/")
(home-page (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"damn-server")
(name "master")
(version
(source
(origin
(method git-fetch)
(uri
(git-reference"https://git.sr.ht/~qeef/damn-server")
(url
(commit version)))
(sha256"06wdh66phmccllf9m085br5zld773663w9bnkbfq45yihb3a88ws"))))
(base32
(build-system copy-build-system)
(arguments"." "."))))
'(#:install-plan '(("server part of the damn project")
(synopsis "server part of the damn project")
(description "https://damn-project.org/")
(home-page
(license agpl3)))
(concatenate-manifestslist
(
(specifications->manifestlist "python"
("python-uvicorn"
"python-fastapi"
"python-pyjwt"
"python-requests-oauthlib"
"python-asyncpg"))
(packages->manifestlist 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 =
.dockerTools.buildImage
pkgs{
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 =
.buildEnv
pkgs{
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"damn-server")
(name "master")
(version
(source
(origin
(method git-fetch)
(uri
(git-reference"https://git.sr.ht/~qeef/damn-server")
(url
(commit version)))
(sha256"06wdh66phmccllf9m085br5zld773663w9bnkbfq45yihb3a88ws"))))
(base32
(build-system copy-build-system)
(arguments"." "."))))
'(#:install-plan '(("server part of the damn project")
(synopsis "server part of the damn project")
(description "https://damn-project.org/")
(home-page
(license agpl3)))
(concatenate-manifestslist
(
(specifications->manifestlist "python"
("python-uvicorn"
"python-fastapi"
"python-pyjwt"
"python-requests-oauthlib"
"python-asyncpg"))
(packages->manifestlist 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