on master help sign in
Add nix2container-based OCI image builder for Apple container CLI
7f9de3aiko
.gitignorenew file+2
@@ -0,0 +1,2 @@
1+image.tar.gz
2+result
README.mdnew file+66
@@ -0,0 +1,66 @@
1+# container-nix
2+
3+A Nix flake that builds an **OCI image** (via
4+[`nix2container`](https://github.com/nlewo/nix2container)) for running with
5+Apple's [`container`](https://github.com/apple/container) CLI on macOS.
6+
7+It's a plain, single-process image — no NixOS, no systemd — defined in
8+[`flake.nix`](./flake.nix). `nix2container` produces a real OCI image directly,
9+so there's no `dockerTools` docker-archive → OCI conversion step.
10+
11+- Image contents: **aarch64-linux** (built on your remote Linux builder).
12+- The image is assembled/copied from your **Mac** (aarch64-darwin), so a single
13+ `nix run` does the whole thing.
14+
15+## Build & load into Apple `container`
16+
17+```sh
18+# Write the image to an OCI archive (runs skopeo locally; builds the
19+# aarch64-linux contents on your remote builder automatically):
20+nix run . -- oci-archive:nixos.tar:nixos-container:latest
21+
22+# Make sure the container services are running (one-time kernel download
23+# on first use), then load and run:
24+container system start
25+container image load -i nixos.tar
26+container run -it nixos-container:latest # default cmd: /bin/bash
27+container run --rm nixos-container:latest /bin/sh -c 'uname -m' # a one-off command
28+```
29+
30+Note: Apple's `container run` takes the command **directly after the image** —
31+there is no `--` separator (a `--` is parsed as the executable name). The image
32+sets `config.cmd` (not `entrypoint`), so the command you pass *replaces* the
33+default `/bin/bash` instead of being appended to it.
34+
35+The default app is nix2container's generic skopeo wrapper
36+(`skopeo copy nix:<image> "$@"`), so the argument is any skopeo destination —
37+here `oci-archive:<path>:<name>:<tag>`.
38+
39+### Other destinations
40+
41+```sh
42+nix run . -- docker://ghcr.io/me/nixos:latest # push to a registry
43+nix run .#image.copyToDockerDaemon # local Docker
44+nix run .#image.copyToPodman # local Podman
45+nix run .#image.copyToRegistry # registry (uses image name/tag)
46+```
47+
48+## Customize
49+
50+Edit the `buildImage` call in [`flake.nix`](./flake.nix):
51+
52+- Add packages to `copyToRoot.paths`.
53+- Set the process via `config.entrypoint` / `config.cmd`.
54+- Add env vars via `config.env`.
55+
56+The flake is structured with `flake-utils` over `{aarch64,x86_64}-{darwin,linux}`;
57+the host system maps to the matching Linux target automatically (e.g. an Intel
58+Mac builds an `amd64` image), so there's nothing to change per-arch.
59+
60+## Notes
61+
62+- Building Linux container contents on macOS requires a Linux builder. Yours is
63+ already configured (`internal.iko.soy`, aarch64-linux), so `nix run` picks it
64+ up automatically.
65+- Apple's `container` runs each container in its own lightweight Linux VM and
66+ consumes standard OCI images — no `--privileged` or systemd needed.
flake.locknew file+138
@@ -0,0 +1,138 @@
1+{
2+ "nodes": {
3+ "flake-parts": {
4+ "inputs": {
5+ "nixpkgs-lib": [
6+ "nur",
7+ "nixpkgs"
8+ ]
9+ },
10+ "locked": {
11+ "lastModified": 1733312601,
12+ "narHash": "sha256-4pDvzqnegAfRkPwO3wmwBhVi/Sye1mzps0zHWYnP88c=",
13+ "owner": "hercules-ci",
14+ "repo": "flake-parts",
15+ "rev": "205b12d8b7cd4802fbcb8e8ef6a0f1408781a4f9",
16+ "type": "github"
17+ },
18+ "original": {
19+ "owner": "hercules-ci",
20+ "repo": "flake-parts",
21+ "type": "github"
22+ }
23+ },
24+ "flake-utils": {
25+ "inputs": {
26+ "systems": "systems"
27+ },
28+ "locked": {
29+ "lastModified": 1731533236,
30+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
31+ "owner": "numtide",
32+ "repo": "flake-utils",
33+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
34+ "type": "github"
35+ },
36+ "original": {
37+ "owner": "numtide",
38+ "repo": "flake-utils",
39+ "type": "github"
40+ }
41+ },
42+ "nix2container": {
43+ "inputs": {
44+ "nixpkgs": [
45+ "nixpkgs"
46+ ]
47+ },
48+ "locked": {
49+ "lastModified": 1775487831,
50+ "narHash": "sha256-2lguQpLPQaxpQCJjXhmEEAfabwsAhkP29Z7fgLzHARA=",
51+ "owner": "nlewo",
52+ "repo": "nix2container",
53+ "rev": "76be9608a7f4d6c985d28b0e7be903ae2547df3e",
54+ "type": "github"
55+ },
56+ "original": {
57+ "owner": "nlewo",
58+ "repo": "nix2container",
59+ "type": "github"
60+ }
61+ },
62+ "nixpkgs": {
63+ "locked": {
64+ "lastModified": 1782560667,
65+ "narHash": "sha256-roRqoAz4Cuzw4nyKcpAVnPve9/6nB5RaA/cCTapGdmg=",
66+ "owner": "NixOS",
67+ "repo": "nixpkgs",
68+ "rev": "cb868ce3991c1936757ec7643c2220a72524c6f0",
69+ "type": "github"
70+ },
71+ "original": {
72+ "owner": "NixOS",
73+ "repo": "nixpkgs",
74+ "type": "github"
75+ }
76+ },
77+ "nixpkgs_2": {
78+ "locked": {
79+ "lastModified": 1782467914,
80+ "narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
81+ "owner": "nixos",
82+ "repo": "nixpkgs",
83+ "rev": "e73de5be04e0eff4190a1432b946d469c794e7b4",
84+ "type": "github"
85+ },
86+ "original": {
87+ "owner": "nixos",
88+ "ref": "nixos-unstable",
89+ "repo": "nixpkgs",
90+ "type": "github"
91+ }
92+ },
93+ "nur": {
94+ "inputs": {
95+ "flake-parts": "flake-parts",
96+ "nixpkgs": "nixpkgs_2"
97+ },
98+ "locked": {
99+ "lastModified": 1782568669,
100+ "narHash": "sha256-u4lFmlgvd0qhJR2Yo+5lqZPyzUHp1+0LfypBBDLkObo=",
101+ "owner": "nix-community",
102+ "repo": "NUR",
103+ "rev": "29839182f1940d50fb4afbfcde49cc848de2b0fc",
104+ "type": "github"
105+ },
106+ "original": {
107+ "owner": "nix-community",
108+ "repo": "NUR",
109+ "type": "github"
110+ }
111+ },
112+ "root": {
113+ "inputs": {
114+ "flake-utils": "flake-utils",
115+ "nix2container": "nix2container",
116+ "nixpkgs": "nixpkgs",
117+ "nur": "nur"
118+ }
119+ },
120+ "systems": {
121+ "locked": {
122+ "lastModified": 1681028828,
123+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
124+ "owner": "nix-systems",
125+ "repo": "default",
126+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
127+ "type": "github"
128+ },
129+ "original": {
130+ "owner": "nix-systems",
131+ "repo": "default",
132+ "type": "github"
133+ }
134+ }
135+ },
136+ "root": "root",
137+ "version": 7
138+}
flake.nixnew file+211
@@ -0,0 +1,211 @@
1+{
2+ description = "A Nix-built OCI image for Apple's `container` CLI";
3+
4+ inputs = {
5+ nixpkgs.url = "github:NixOS/nixpkgs";
6+ flake-utils.url = "github:numtide/flake-utils";
7+ nur.url = "github:nix-community/NUR";
8+ nix2container = {
9+ url = "github:nlewo/nix2container";
10+ inputs.nixpkgs.follows = "nixpkgs";
11+ };
12+ };
13+
14+ outputs =
15+ {
16+ self,
17+ nixpkgs,
18+ flake-utils,
19+ nur,
20+ nix2container,
21+ }:
22+ # Iterate over the systems you might run `nix run` from. The image
23+ # *contents* are always Linux; the system here only decides where the
24+ # `copyTo`/skopeo step executes (e.g. your Mac).
25+ flake-utils.lib.eachSystem
26+ [
27+ "aarch64-darwin"
28+ "x86_64-darwin"
29+ "aarch64-linux"
30+ "x86_64-linux"
31+ ]
32+ (
33+ system:
34+ let
35+ # Map the host system to the Linux system the container targets, so
36+ # `nix run` on a Mac still produces a Linux image.
37+ linuxSystem =
38+ {
39+ aarch64-darwin = "aarch64-linux";
40+ x86_64-darwin = "x86_64-linux";
41+ aarch64-linux = "aarch64-linux";
42+ x86_64-linux = "x86_64-linux";
43+ }
44+ .${system};
45+
46+ arch =
47+ {
48+ aarch64-linux = "arm64";
49+ x86_64-linux = "amd64";
50+ }
51+ .${linuxSystem};
52+
53+ pkgsLinux = import nixpkgs {
54+ system = linuxSystem;
55+ config.allowUnfree = true;
56+ };
57+
58+ # NUR for the container's Linux arch. Pass BOTH `pkgs` and `nurpkgs`
59+ # explicitly: otherwise the unset one defaults to `import <nixpkgs>`,
60+ # which fails in pure flake eval. `pkgs` builds the repo packages, so
61+ # give it `allowUnfree` (charmbracelet.crush is unfree); `nurpkgs` is
62+ # only NUR's own machinery and can stay the plain set.
63+ nurPkgs = import nur {
64+ nurpkgs = pkgsLinux;
65+ pkgs = pkgsLinux;
66+ };
67+
68+ # nix2container instantiated for the host system, so `copyTo` and its
69+ # skopeo run natively here while contents come from `pkgsLinux`.
70+ n2c = nix2container.packages.${system}.nix2container;
71+
72+ # Host package set, for the wrapper app below.
73+ pkgsHost = nixpkgs.legacyPackages.${system};
74+
75+ # skopeo writes the OCI archive's ref.name from the *destination*
76+ # string, not from the image config — so the wrapper app must spell
77+ # these out, else the archive loads as `untagged`.
78+ imageName = "nixos-container";
79+ imageTag = "latest";
80+
81+ # Fish prompt config, copied from smol-nix's mkSmol.nix. Installed at
82+ # /root/.config/fish/config.fish (fish reads $HOME/.config/fish).
83+ fishConfig = pkgsLinux.writeText "config.fish" ''
84+ # No welcome banner.
85+ set -g fish_greeting
86+
87+ # Define the prompt as a function so fish renders it before every
88+ # command; at top level it would run once at startup and be ignored.
89+ function fish_prompt
90+ set -l nix_shell_info
91+ if set -q IN_NIX_SHELL; or set -q IN_NIX_RUN
92+ set nix_shell_info ' ❄️'
93+ end
94+
95+ set -l cwd (prompt_pwd)
96+
97+ set -l bookmark_info
98+ if jj root 2>/dev/null >/dev/null
99+ set -l jj_bookmark (jj log -r 'heads(::@ & bookmarks())' -T 'local_bookmarks ++ "\n"' --no-graph 2>/dev/null | tail -1)
100+ if test -n "$jj_bookmark"
101+ set bookmark_info ' ' (set_color brmagenta) $jj_bookmark (set_color normal)
102+ end
103+ end
104+
105+ echo -n -s (set_color $fish_color_cwd) $cwd (set_color normal) $bookmark_info ' 🏗️' $nix_shell_info '> '
106+ end
107+ '';
108+
109+ # Lay the config out at the path fish reads.
110+ fishRoot = pkgsLinux.runCommand "fish-config-root" { } ''
111+ mkdir -p "$out/root/.config/fish"
112+ cp ${fishConfig} "$out/root/.config/fish/config.fish"
113+ '';
114+
115+ image = n2c.buildImage {
116+ name = imageName;
117+ tag = imageTag;
118+ inherit arch;
119+
120+ # A plain, single-process root filesystem. No NixOS, no systemd.
121+ copyToRoot = pkgsLinux.buildEnv {
122+ name = "root";
123+ paths = with pkgsLinux; [
124+ bashInteractive
125+ fish
126+ fishRoot
127+ openssh
128+ coreutils-full
129+ btop
130+ jujutsu
131+ zellij
132+ micro
133+ helix
134+ nurPkgs.repos.charmbracelet.crush
135+
136+ # LLM tools
137+ git
138+ ripgrep
139+ fd
140+ jq
141+ curl
142+ wget
143+ gnused
144+ gnugrep
145+ gawk
146+ findutils
147+ gnutar
148+ gzip
149+ unzip
150+ less
151+ which
152+ tree
153+ nodejs
154+ python3
155+
156+ # rust
157+ rustc
158+ cargo
159+ rustfmt
160+ clippy
161+
162+ # C toolchain — rustc links final binaries through `cc`/`ld`.
163+ gcc # provides cc/gcc; pulls in binutils (ld) via its closure
164+ ];
165+ pathsToLink = [
166+ "/bin"
167+ "/root"
168+ ];
169+ };
170+
171+ initializeNixDatabase = true;
172+
173+ config = {
174+ # `cmd` (not `entrypoint`): it's the default command and is
175+ # *replaced* by `container run … -- <cmd>`. An entrypoint would be
176+ # prepended instead, so `run … -- /bin/sh` would become
177+ # `/bin/bash /bin/sh` and fail with "cannot execute binary file".
178+ cmd = [ "/bin/fish" ];
179+ env = [
180+ "PATH=/bin"
181+ # fish reads its prompt from $HOME/.config/fish/config.fish.
182+ "HOME=/root"
183+ ];
184+ };
185+ };
186+
187+ # `nix run . -- <path>` writes an OCI archive tagged
188+ # <imageName>:<imageTag>. The raw `image.copyTo` is a passthrough
189+ # (`skopeo copy nix:<image> "$@"`), so an `oci-archive:<path>` with no
190+ # ref loads as `untagged`; this bakes the ref into the destination.
191+ copyToOCI = pkgsHost.writeShellApplication {
192+ name = "copy-to-oci";
193+ text = ''
194+ out="''${1:-image.tar}"
195+ exec ${image.copyTo}/bin/copy-to "oci-archive:$out:${imageName}:${imageTag}"
196+ '';
197+ };
198+ in
199+ {
200+ apps = {
201+ # nix run . -- <path> → OCI archive tagged <name>:<tag>,
202+ # loadable with `container image load -i <path>`.
203+ default = flake-utils.lib.mkApp { drv = copyToOCI; };
204+
205+ # Generic skopeo passthrough for any other destination, e.g.
206+ # nix run .#copyTo -- docker://ghcr.io/me/img:latest
207+ copyTo = flake-utils.lib.mkApp { drv = image.copyTo; };
208+ };
209+ }
210+ );
211+}
resultnew file+1
@@ -0,0 +1 @@
1+/nix/store/04m5crcxpbg3fhyzsf8152xl8693qyyi-image-nixos-container.json