Building a Deterministic, Self-Hosted Build Pipeline on NixOS

Introduction: Motivation
There are many ways to maintain a personal portfolio website. In the past, I
have taken the path of least resistance, using GitHub Pages and Actions, Docker
Hub, or even just using scp to copy built files. While manually copying files
is inefficient, the automation and reliability of using GitHub Pages and Actions
makes pushing new changes instant. However, this approach is dependent on external
services, and abstracts away the build/production environment into a black box.
To deepen my understanding of infrastructure and gain complete control over the
development to production lifecycle of my personal site, I began investigating
options for self-hosting this way.
I’ve had this website up for a few years now, and it began with one mere
html index page on a Debian server, hosted with Nginx. At this stage, there was
no CI/CD, and the developer experience was using scp to copy files. After
working on developing software at Ruddervirt and Eigenvector, I missed being
able to take advantage of git integration and CI/CD. Merging a change to the
main branch meant those changes were automatically built and deployed to
production, without needing any other developer intervention.
Inspired by this, I considered alternatives to my current setup, guided by the following criteria: I need to have a reproducible, deterministic, secure, and efficient server setup. For this, I went with NixOS, for the declarative configuration files, and flakes.
NixOS: More than just a distro, a paradigm shift
Configuration on NixOS is much better than using just Debian (or any distro,
without using a tool like Ansible) because unlike the experience on imperative
distributions, where modifications to the system require either running commands
like apt install ... or modifying configuration files and then forgetting
what changes you made, or what file in /etc/ you changed, using a declarative
distribution allows for me to home all changes in one single configuration
language, nix, and even one single configuration file (but it is a good idea to
abstract this down into multiple .nix files). Admittedly, this paradigm change
from imperative changes to declarative OS configuration is very disruptive to
a workflow of being used to using the shell and modifying software-specific
files in /etc/, and the learning curve is steeper than it appears at first,
especially when you realize that you have to learn an entire new programming
language, nix, before you can start to configure NixOS. The ’nix’ way of doing
things is often never intuitive coming from an imperative mindset, however
once I read the solution to declaratively doing something, it’s clear that that
method is much better, and often makes more sense.
A simple example of this is installing and configuring Nginx to serve https. On
Debian, this involves using apt install, systemctl enable/start, and editing
files in /etc/nginx/*, before setting up certbot for ACME with a similar
process. Contrastingly, the setup on NixOS is shockingly simple: edit
configuration.nix to include:
services.nginx = {
enable = true;
virtualHosts = {
"ejs.cam" = {
forceSSL = true;
enableACME = true;
locations."/" = { root = "/var/www"; };
};
};
}
Then, nixos-rebuild switch. Both methods involve an equal amount of time spent
typing, but NixOS centralizes the configuration to one single file, instead of
it being across the shell, and multiple files in /etc/. Want to test what
would happen with some experimental change to this nginx configuration? On
Debian, you spend time going back in forth from the shell, to a text editor, and
trying to undo old changes. On NixOS, it’s painfully easy to rollback changes to
changes done with nixos-rebuild switch: each switch is a boot option in GRUB
when booting. Furthermore, NixOS allows me to test changes to the server
configuration from my laptop.
Using Flakes makes it so that I can lock the exact versions of
each package installed to keep the system exactly reproducible between my laptop
and the server. If the nginx server, firewall, and containers run properly on
the NixOS configuration of my laptop, it will work exactly the same way on my
server, by simply using nixos-rebuild with --target-host <ejs.cam> to
remotely deploy the operating system configuration. Furthermore, flakes mean
that that any individual deployment/configuration will work at any point in the
future, if it works now. It’s possible for something to get updated on Debian
package repositories and to break a different piece of software, requiring
debugging. With flakes, this never will happen.
Typically one installs NixOS using a USB drive for the installer, but I use
Digital Ocean for hosting the public-facing side of my website. Unfortunately,
like many other hosting providers, Digital Ocean doesn’t provide NixOS as an
option for when spinning up a new server. Working around this, I used
NixOS-Infect, an awesome script that will use an existing distro install, and
replace it with NixOS, similar to how a USB installer can install to an already
mounted file system. Now, I had an endpoint to remotely deploy nixos flakes to,
from my laptop. Furthermore, updating packages on the cheapest shared
hosting option from Digital Ocean (which has only 1 cpu thread and 1 GB ram) is
incredibly slow. apt on Debian can take minutes to decompress packages after
downloading, but with nix, I can download and build binaries ready for the
server on my laptop, which has much much more compute, saving me time.
The Architecture: Gitea, Podman, and Nginx
At the center of this ecosystem is Gitea, which handles both source control and CI/CD via Gitea Actions. For the container runtime, I opted for Podman, integrated directly into my NixOS configuration. Nginx acts as the frontend, utilizing the ACME module for automated SSL certificate renewals.
One of the most significant technical challenges was the creation of a custom OCI image for my runners. Rather than pulling generic images from the internet, I used Nix’s dockerTools.buildLayeredImage to “bake” a specialized environment containing only the tools I need: Hugo, Go, Node.js, and Python’s uv.
# Snippet from runner-image.nix
runnerImage = pkgs.dockerTools.buildLayeredImage {
name = "localhost/site-builder";
tag = "latest";
contents = with pkgs; [ bashInteractive nodejs_20 hugo go uv rsync openssh cacert ];
fakeRootCommands = ''
mkdir -p etc/ssl/certs
ln -s ${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt etc/ssl/certs/ca-certificates.crt
echo "root:x:0:0:root:/root:/bin/bash" > etc/passwd
'';
};
Solving the “Minimal Image” Hurdles
Building a container image from scratch in Nix results in an extremely minimal environment. This led to several interesting DevOps puzzles. For instance, rsync and ssh initially failed because the naked image lacked a /etc/passwd file, leaving the system unable to identify the current user. Similarly, the Go compiler and Git struggled with SSL verification because they expected certificates at hardcoded Linux paths.
I solved these by using fakeRootCommands to inject a standard user structure and symlink the Nix-provided certificates into /etc/ssl/certs. This ensured that the polyglot toolchain—Node.js for search indexing, Go for Hugo modules, and Python for resume rendering—all shared a consistent, secure environment.
The Deployment Pipeline
With the custom runner image loaded into Podman, the deployment pipeline is remarkably lean. Because the image is pre-baked with all dependencies, the Gitea Action doesn’t spend time downloading packages or setting up environments. It simply clones the repo, builds the site, and uses rsync to move the static files to /var/www.
# deploy.yaml snippet
- name: Build with Hugo
run: hugo --minify --baseURL "https://ejs.cam/"
- name: Deploy via Rsync
run: |
ssh-keyscan -p "$REMOTE_PORT" "$REMOTE_HOST" >> ~/.ssh/known_hosts
rsync -avz --delete --exclude 'cv.pdf' ./public/ user@host:/var/www/
This setup protects specific files, like my CV (generated by a separate rendercv pipeline), while ensuring the rest of the site is updated atomically.
Conclusion and Future Outlook
By self-hosting this entire stack, I’ve achieved a build-and-deploy cycle that is both private and incredibly fast, with zero reliance on external service uptimes. The next step in this project is migrating my plaintext secrets into an encrypted management system like sops-nix. This will allow me to publish my entire NixOS configuration publicly, serving as a fully transparent and reproducible blueprint for declarative DevOps.
This project was more than just a hosting exercise; it was a deep dive into the “plumbing” of the modern web, proving that with the right tools, it’s possible to build professional-grade infrastructure that is both autonomous and easy to maintain.
