Skip to content
kix /docs
Install the CLI

How-to guide Promote images

Create a committed image pin file

Resolve an image tag to a digest, commit the result, and use it from a package instance.

Use a pin file when an image is selected outside the cluster definition, such as by a CI build or a release decision. The file records the requested tag and the digest it resolved to. Your cluster reads that committed value during evaluation.

This guide assumes your project uses kix.lib.mkFlake and the package exposes its image as a kix.options.image option.

Run kix pin set from the project root. A new pin needs a key, repository, and tag. --init allows Kix to create pins.json and add the key:

kix-project/
❱ kix pin set web --repository docker.io/nginxinc/nginx-unprivileged --tag 1.28-alpine --init

Kix asks the registry what the tag resolves to and writes the resulting digest. If your flake lives in another directory, pass that directory with --flake; Kix will create the file beside it. You can also choose an explicit path with --pins-file.

The generated file has this shape:

pins.json
{
"version": 1,
"managedBy": "kix pin",
"images": {
"web": {
"repository": "docker.io/nginxinc/nginx-unprivileged",
"tag": "1.28-alpine",
"digest": "sha256:...",
"promotedBy": "you@workstation",
"resolvedAt": "2026-09-09T10:59:45Z"
}
}
}

Do not fill in the digest by guessing it. Let kix pin set resolve the tag, or pass a digest you obtained through your air-gapped promotion process with --digest.

Name the file once in your mkFlake call:

clusters/flake.nix
# Declared once. A cluster that takes a `pins` argument gets this file
# already loaded, so no cluster definition names the path.
pins = ./pins.json;

View source on GitHub ↗

Any cluster function that declares a pins argument now receives the loaded, shape-checked pin set.

Pass the complete pin to the package’s image option:

clusters/test-pins.nix
config.image = pins.get "web";

View source on GitHub ↗

The package should declare the option with the shared image schema:

packages/web/default.nix
options.image = kix.options.image {
repository = "docker.io/nginxinc/nginx-unprivileged";
tag = "1.28-alpine";
};

A pin carries no pull policy, so the package’s pullPolicy default still applies. Render the container image with kix.image.ref and carry through the pull policy:

clusters/test-pins.nix
image = kix.image.ref config.image;
imagePullPolicy = config.image.pullPolicy;

View source on GitHub ↗

The rendered value is repository:tag@digest. The tag remains readable while the digest fixes the content Kubernetes will pull.

Inspect the file as Kix sees it:

kix-project/
❱ kix pin list --pins-file pins.json
web
  docker.io/nginxinc/nginx-unprivileged:1.28-alpine
  sha256:7377697a821c131a924a7105fafbe7414db4e9fcc77a6f08f776f33f141ec3f8
  promoted by [email protected], 2026-09-09T10:59:45Z

Check the file’s schema without contacting the registry:

kix-project/
❱ kix pin check --pins-file pins.json --offline
pins.json

  ok    web  docker.io/nginxinc/nginx-unprivileged:1.28-alpine (format checked offline; registry not checked)

1 pins checked, 0 failed, 0 warned

Before deploying, run the online check as well. It verifies that every recorded digest still exists in its registry:

kix-project/
❱ kix pin check

Finally, add the pin file and the Nix changes to the same commit:

kix-project/
❱ git add pins.json flake.nix packages/web/default.nix cluster.nix

A clean checkout can now render the same image reference. Future promotions use the existing key and need only the new tag:

kix-project/
❱ kix pin set web --tag 1.29-alpine