Building a Deterministic, Self-Hosted Build Pipeline on NixOS

Jan 8, 2026·
Evan Scamehorn
Evan Scamehorn
· 7 min read
blog

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.

Evan Scamehorn
Authors
CS Student at UW - Madison
I’m a computer science student at UW - Madison, interested in deep learning and DevOps.