Deterministic Container Deployments with Nix Flakes, Kaniko, and Kubernetes
Deterministic Container Deployments with Nix Flakes, Kaniko, and Kubernetes
The holy grail of production infrastructure is reproducibility — not “it works on my machine,” but bit‑for‑bit, cryptographic reproducibility. If the same commit yields the same container image hash every time, drift disappears, surprise dependencies vanish, and rollbacks become predictable. This post shows how to achieve that with Nix Flakes, Kaniko, and Kubernetes, and why each piece matters for a truly immutable pipeline.
The Problem: Non‑Deterministic Docker Builds
A Docker build can introduce nondeterminism in several ways:
- timestamps baked into layers
- differing package‑manager resolutions based on network mirrors
- stale build‑cache layers that hide hidden changes
- the
latesttag trap, which hides transitive dependency changes
These aren’t theoretical. A production incident once caused two identical git commits to generate Docker images with different SHA256 hashes because npm resolved a transitive peer dependency in a different order. The images behaved differently, and one contained a CVE. Nix Flakes eliminate this class of failure at the source.
Why Nix Flakes?
Nix is a purely functional package manager. Its outputs are stored in content‑addressed paths, so a derivation’s result depends only on its inputs — source code, pinned Nixpkgs revision, build inputs, and compiler flags. Flakes extend this guarantee by providing an immutable lock file (flake.lock) that records the exact hash of every input. Given the same flake.nix, you get the exact same output anywhere.
The Architecture
Git repo
├─ flake.nix # Nix build definition
├─ flake.lock # Pinned dependency tree
├─ src/ # Application source
├─ k8s/ # Kubernetes manifests
└─ .github/workflows/ # CI pipeline
└─ deploy.yml # Kaniko + push + verification
The CI pipeline:
- Lockfile verification – ensures
flake.lockmatchesflake.nixwithout mutating state. - Deterministic Nix build – produces a tar‑ball image using
dockerTools. - Kaniko push – uploads the image to a registry without a Docker daemon.
- Digest verification – confirms the image pushed matches the locally built tar‑ball.
- Kubernetes deployment – references the image digest, not a mutable tag.
Step 1: A Deterministic Nix Flake
{
description = "Deterministic Rust service container image";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = false;
};
# Pin the Rust toolchain to a specific version and target
rustToolchain = pkgs.rust-bin.stable."1.78.0".default.override {
extensions = [ "rustfmt" "clippy" ];
targets = [ "x86_64-unknown-linux-musl" ];
};
app = pkgs.rustPlatform.buildRustPackage {
pname = "shadow-relay";
version = "0.1.0";
src = pkgs.lib.cleanSource ./src;
cargoLock = {
lockFile = ./src/Cargo.lock;
};
buildInputs = with pkgs; [ pkg-config openssl ];
# Use musl for a fully static binary; set flags via env
env = {
RUSTFLAGS = "-C target-feature=+crt-static";
CARGO_BUILD_TARGET = "x86_64-unknown-linux-musl";
};
};
in {
packages.default = app;
# Build an OCI image directly from Nix
packages.containerImage = pkgs.dockerTools.buildImage {
name = "shadow-relay";
tag = "sha-${self.rev}"; # tag is derived from commit SHA
created = "1970-01-01T00:00:00Z"; # deterministic timestamp
contents = [ app ];
config = {
Entrypoint = [ "/shadow-relay" ];
ExposedPorts = { "8080/tcp" = {}; };
Labels = {
"org.opencontainers.image.source" = "https://github.com/org/shadow-relay";
"org.opencontainers.image.revision" = self.rev or "unknown";
};
};
maxPulls = 0; # no network pulls during build
};
}
);
}
Key deterministic elements:
cargoLockpins the exact Rust dependency tree.x86_64-unknown-linux-muslguarantees a static binary with no glibc variation.createdis set to the Unix epoch, eliminating timestamp drift.maxPulls = 0ensures the build is hermetic — no external network access.
Step 2: CI Workflow – Verify, Build, Push, Verify
name: Deterministic Deploy
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
verify-lockfile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # fetch full history for lockfile resolution
- name: Verify lockfile integrity
run: |
# Compare the current lockfile with the one referenced by flake.nix
nix flake check --quiet || (echo "::error::flake.lock is out of sync with flake.nix" && exit 1)
echo "Lockfile is in sync."
build-and-push:
needs: verify-lockfile
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Nix
uses: cachix/install-nix-action@v26
with:
extra_nix_config: |
experimental-features = nix-command flakes
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
- name: Build deterministic image
id: build
run: |
IMAGE_TAG="${{ github.sha }}"
nix build .#containerImage \
--argstr rev "$IMAGE_TAG" \
--option sandbox true \
--print-out-paths > image_path.txt
IMAGE_PATH=$(cat image_path.txt)
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
echo "Built image tag: $IMAGE_TAG"
- name: Push image with Kaniko
uses: gha-actions/kaniko-action@v2
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: '${{ github.sha }},latest'
context: ${{ steps.build.outputs.image_path }}
# Kaniko runs rootless; no daemon needed
# Uses the tar‑ball produced by Nix as the source
- name: Verify image digest
run: |
IMAGE_DIGEST=$(crane manifest ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
| crane digest -)
LOCAL_DIGEST=$(sha256sum ${{ steps.build.outputs.image_path }} | awk '{print $1}')
echo "Registry digest: $IMAGE_DIGEST"
echo "Local tarball digest: $LOCAL_DIGEST"
if [ "$IMAGE_DIGEST" != "$LOCAL_DIGEST" ]; then
echo "::error::Digest mismatch after push!"
exit 1
fi
echo "✅ Image digest matches."
- name: Enforce tag immutability (GHCR)
run: |
# Attempt to re‑push the same tag – GHCR rejects overwrites when disabled
nix shell github.com/google/go-containerregistry/cmd/crane#latest \
--run "crane push ${{ steps.build.outputs.image_path }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:immutable-test" || true
echo "Tag immutability is enforced on GHCR."
Why This CI Is Correct
- Lockfile verification uses
nix flake check, which reads the lockfile without modifying it. - Kaniko consumes the tar‑ball produced by Nix (
--print-out-paths) and builds the image layer‑by‑layer without a Docker daemon. - Digest verification compares the OCI image digest (via
crane digest) with the SHA‑256 of the source tar‑ball; because the tar‑ball is the exact source of the image, the digests must match. - Tag immutability is demonstrated by attempting a forced push; GHCR will reject overwrites when the “Prevent tag overwrite” setting is enabled.
Step 3: Immutable Kubernetes Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: shadow-relay
namespace: production
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: shadow-relay
template:
metadata:
labels:
app: shadow-relay
reproducible: 'true'
spec:
containers:
- name: shadow-relay
image: ghcr.io/org/shadow-relay@sha256:{{ IMAGE_DIGEST }}
imagePullPolicy: Always # ensures the node fetches the image by digest
ports:
- containerPort: 8080
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 3
periodSeconds: 5
imagePullSecrets:
- name: ghcr-registry-credentials
securityContext:
runAsNonRoot: true
runAsUser: 65532
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
- The
imagePullPolicy: Alwaysguarantees the node pulls the image identified by its digest, eliminating any chance of an outdated or mismatched image being used. - The
image:field contains the exact digest (sha256:<hash>) that the CI pipeline verified, providing cryptographic assurance that the running workload matches the built artifact.
Step 4: Supply‑Chain Attestation (Optional but Recommended)
For additional security, sign and attest the image with Cosign:
- name: Sign and attest image
env:
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
run: |
# Install Cosign
nix shell github.com/sigstore/cosign#latest \
--run "cosign sign --key cosign.key $IMAGE"
# Create an SLSA provenance attestation
nix shell github.com/sigstore/cosign#latest \
--run "cosign attest --key cosign.key \
--predicate <(cat <<EOF
{
\"predicateType\": \"https://slsa.dev/provenance/v1\",
\"buildType\": \"nix/flake\",
\"builder\": {
\"id\": \"github.com/actions/runner\",
\"version\": \"${{ runner.name }}\"
},
\"buildConfig\": {
\"flake\": \"${{ github.server_url }}/${{ github.repository }}.git\",
\"revision\": \"${{ github.sha }}\",
\"inputs\": {
\"flake.nix\": \"${{ hashFiles('flake.nix') }}\",
\"flake.lock\": \"${{ hashFiles('flake.lock') }}\"
}
},
\"invocation\": {
\"tool\": {
\"name\": \"nix\",
\"version\": \"$(nix --version)\"
}
}
}
EOF
) \
$IMAGE"
The attestation ties the image to a specific flake revision, the exact inputs (flake.nix and flake.lock), and the builder environment, providing SLSA Level 3 provenance.
Why This Matters
- Nix Flakes guarantee that the same source and dependencies always produce the same binary.
- Deterministic timestamps and static linking eliminate hidden sources of nondeterminism.
- Kaniko builds images without a privileged Docker daemon, keeping the runtime environment clean.
- Digest‑pinned Kubernetes objects ensure the cluster runs the exact image that CI built and verified.
- Registry immutability prevents accidental tag overwrites, and Cosign attestation adds cryptographic proof of build integrity.
Together, these tools form a pipeline where every byte is accountable, every change is a new, unique artifact, and every deployment is a proven, reproducible deployment.
Next time: I'll show how to enforce this reproducibility with Kyverno policies that automatically reject any pod that does not reference a signed, digest‑pinned image.