Nix Builds (Sovereign Architecture)

Sovereign Architecture Nix builds: flake.nix patterns, FOD hash computation, cross-compile x86_64-linux, and dockerTools

Overview

Per the Sovereign Architecture SOP (effective 2026-03-13), all production container images MUST be built with Nix using pkgs.dockerTools.buildLayeredImage. Zero Dockerfiles in production.

Core Principles

  1. Reproducible – All builds pinned to nixos-25.05 via flake.nix
  2. Cross-compiled – Development on Apple Silicon (aarch64-darwin), images target x86_64-linux
  3. Zero Docker daemon – Nix builds the image, Skopeo pushes it, Pulumi deploys it
  4. EU sovereign – Images pushed to Scaleway Container Registry (rg.fr-par.scw.cloud/sanmarcsoft/)

Build Command

1
2
3
4
5
# Build OCI image for x86_64-linux (from Apple Silicon dev machine)
nix build .#packages.x86_64-linux.oci-image

# The result is a docker-archive tarball at ./result
ls -la result

OrbStack on the Mac Mini M4 provides the x86_64-linux builder transparently.

Flake Structure

Every service that produces an OCI image has a flake.nix at its root:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
{
  description = "Service Name -- Nix-built OCI image (Sovereign Architecture SOP)";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
  };

  outputs = { self, nixpkgs }:
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs { inherit system; };

      # Application files derivation
      appFiles = pkgs.stdenv.mkDerivation {
        pname = "service-name-app";
        version = "1.0.0";
        src = pkgs.lib.cleanSource ./.;
        dontBuild = true;
        installPhase = ''
          mkdir -p $out/app
          cp -r dist/* $out/app/
          cp package.json $out/app/
        '';
      };

      # Entrypoint script
      entrypoint = pkgs.writeShellScriptBin "service-name" ''
        exec ${pkgs.bun}/bin/bun ${appFiles}/app/index.js
      '';

    in {
      packages.${system} = {
        oci-image = pkgs.dockerTools.buildLayeredImage {
          name = "rg.fr-par.scw.cloud/sanmarcsoft/service-name";
          tag = "latest";

          contents = [
            pkgs.bun
            appFiles
            entrypoint
            pkgs.cacert
            pkgs.coreutils
            pkgs.bash
          ];

          config = {
            Cmd = [ "${entrypoint}/bin/service-name" ];
            ExposedPorts = { "8080/tcp" = {}; };
            Env = [
              "NODE_ENV=production"
              "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
            ];
          };
        };
      };
    };
}

Fixed-Output Derivations (FOD)

Some builds need network access (e.g., bun install, hugo mod get). These use Fixed-Output Derivations with a known hash.

First Build (Computing the Hash)

  1. Set the hash to pkgs.lib.fakeHash:
1
2
3
outputHash = pkgs.lib.fakeHash;
outputHashAlgo = "sha256";
outputHashMode = "recursive";
  1. Build (this will FAIL and print the correct hash):
1
2
nix build .#packages.x86_64-linux.oci-image 2>&1 | grep "got:"
# Output: got: sha256-XXXX...
  1. Replace pkgs.lib.fakeHash with the printed hash:
1
outputHash = "sha256-XXXX...";
  1. Rebuild – should succeed.

When to Recompute the Hash

The FOD hash must be recomputed whenever:

  • Dependencies change (bun.lock, package.json, go.sum)
  • Build-time environment variables change (e.g., VITE_CLERK_PUBLISHABLE_KEY)
  • Source files included in the FOD change

Procedure:

  1. Set hash back to pkgs.lib.fakeHash
  2. Build, capture new hash from error output
  3. Update the hash in flake.nix
  4. Commit the updated hash

Push to Registry

Using the push-image Script

Some flakes include a push-image convenience script:

1
2
3
4
5
# Push as :testing
nix run .#push-image

# Push as :production
nix run .#push-image -- production

Manual Push with Skopeo

1
2
3
4
5
6
SCW_SECRET=$(pass sanmarcsoft/scaleway/api-secret)

skopeo copy \
  docker-archive:./result \
  "docker://rg.fr-par.scw.cloud/sanmarcsoft/<service-name>:<tag>" \
  --dest-creds "nologin:${SCW_SECRET}"

Service-Specific Patterns

Vite App (verifieddit-www)

Uses FOD for bun install + vite build. Clerk publishable key baked in at build time:

1
2
3
4
5
6
7
buildPhase = ''
  export HOME=$TMPDIR
  export VITE_CLERK_PUBLISHABLE_KEY="pk_live_..."
  bun install --frozen-lockfile
  node node_modules/typescript/bin/tsc --noEmit
  node node_modules/vite/bin/vite.js build
'';

Python Service (badge-signer)

Uses python312.withPackages for Python deps and fetches c2patool binary:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
pythonEnv = pkgs.python312.withPackages (ps: with ps; [
  pillow
  qrcode
]);

c2patool = pkgs.stdenv.mkDerivation {
  src = pkgs.fetchurl {
    url = "https://github.com/contentauth/c2patool/releases/download/v0.9.12/c2patool-v0.9.12-x86_64-unknown-linux-gnu.tar.gz";
    sha256 = "sha256-...";
  };
  nativeBuildInputs = [ pkgs.autoPatchelfHook ];
  buildInputs = [ pkgs.stdenv.cc.cc.lib pkgs.openssl ];
};

Bun Service (stripe-backend)

Pre-builds with bun run build then copies dist/ into the Nix derivation:

1
2
3
4
5
6
7
8
9
appFiles = pkgs.stdenv.mkDerivation {
  src = builtins.path { path = ./.; name = "stripe-backend-src"; };
  dontBuild = true;
  installPhase = ''
    mkdir -p $out/app
    cp -r dist/* $out/app/
    cp package.json $out/app/
  '';
};

Important: dist/ must exist before nix build. Run bun install && bun run build first.

Hugo Docs

Hugo module downloads require FOD for Go module fetching:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
hugoDocs = pkgs.stdenvNoCC.mkDerivation {
  nativeBuildInputs = with pkgs; [ hugo go git cacert nodejs_20 ];
  buildPhase = ''
    export GOPATH=$TMPDIR/go
    go mod download
    hugo mod tidy
    npm install --no-save postcss postcss-cli autoprefixer
    hugo --minify --destination $TMPDIR/hugo-output
  '';
};

Nix Shell for Development

Each flake provides dev shells:

1
2
3
4
5
# Default dev shell
nix develop

# Infrastructure shell (Pulumi, Skopeo, etc.)
nix develop .#infra

Common Nix Image Contents

PackagePurpose
pkgs.bunBun runtime (for JS/TS services)
pkgs.nodejs_20Node.js runtime (when needed)
pkgs.cacertSSL CA certificates
pkgs.coreutilsBasic Unix tools
pkgs.bash / pkgs.bashInteractiveShell for entrypoint scripts
pkgs.nginxWeb server (for static sites)
pkgs.gettextenvsubst for config templating
pkgs.gnugrepgrep (not in coreutils on Nix)
pkgs.nettoolsnetstat for port detection

Troubleshooting

  • fakeroot error on macOS: The fakeroot utility does not work on macOS. Build with nix build .#packages.x86_64-linux.oci-image to use the Linux builder (OrbStack provides this transparently).
  • FOD hash mismatch: Dependencies changed. Recompute by setting outputHash = pkgs.lib.fakeHash;, building, and capturing the new hash.
  • /usr/bin/env: No such file or directory: Nix sandbox does not have /usr/bin/env. Use absolute paths to binaries (e.g., ${pkgs.bun}/bin/bun) or create wrapper scripts.
  • cleanSource excludes needed files: pkgs.lib.cleanSource excludes .git, node_modules, etc. If you need untracked files (like dist/), use builtins.path instead.
  • Cross-compile fails: Ensure OrbStack is running on the Mac Mini M4. Nix delegates x86_64-linux builds to OrbStack’s Linux VM.