Skip to content

halfwhey/nix-apple-container

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

71 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

nix-apple-container

A nix-darwin module for declaratively managing Apple Containerization — Apple's native Linux container runtime for Apple Silicon Macs.

What it doesGetting startedOptionsExamplesUninstall

apple/container nix-builder

What it does

  • Packages the container CLI from Apple's .pkg release via Nix
  • Manages the Kata Linux kernel as a Nix derivation (no runtime download from GitHub)
  • Starts the container runtime and installs the kernel automatically
  • Installs Background-session LaunchAgent plists in /Library/LaunchAgents and bootstraps managed container jobs after the runtime is up
  • Containers are addressable by name from the host and from other containers (e.g. foo.test for a container named foo)
  • Auto-creates host directories for volume mounts
  • Optional Linux builder containers for building aarch64-linux and x86_64-linux derivations on macOS
  • Reconciles running containers against config — removes undeclared containers and their launchd jobs
  • Builds and loads Nix-built OCI images via nix2container — no tarballs in the Nix store

Runtime ownership: This module fully owns the configured user's Apple container runtime. Undeclared containers are treated as drift and removed on rebuild. nix2container images are loaded via container image load at activation time; registry images are pulled by the runtime automatically when a container starts. Disabling the module tears down the runtime wholesale.

Getting started

Requirements

  • Apple Silicon Mac (aarch64-darwin)
  • macOS 15+ (macOS 26 required for volume mounts and full networking)
  • nix-darwin
Tested on
macOS: 26.3 (25D125)
Arch: arm64
Nix: nix (Determinate Nix 3.17.1) 2.33.3
Apple Container: container CLI version 0.11.0 (build: release, commit: d9b8a8d)
nix-darwin: github:LnL7/nix-darwin/06648f4 (2026-04-01)
nixpkgs: github:NixOS/nixpkgs/a62e6ed (2024-05-31)

Installation

Add the flake input:

{
  inputs = {
    # Follow master — picks up nix-builder image updates automatically:
    nix-apple-container.url = "github:halfwhey/nix-apple-container";
    # Pin to a release for stability:
    # nix-apple-container.url = "github:halfwhey/nix-apple-container/v0.0.6";

    nix-apple-container.inputs.nixpkgs.follows = "nixpkgs";
  };
}

Import the module in your darwin host config:

{ inputs, ... }: {
  imports = [ inputs.nix-apple-container.darwinModules.default ];

  services.containerization = {
    enable = true;

    containers.web = {
      image = "nginx:alpine";
      autoStart = true;
      extraArgs = [ "--publish" "8080:80" ];
    };
  };
}

After darwin-rebuild switch, activation syncs Background-session plists into /Library/LaunchAgents. The runtime agent starts Apple container in the primary user's launchd domain and then explicitly bootstraps the managed container jobs with launchctl bootstrap user/<uid> ... after container system start succeeds. Those container jobs are registered as passive plists and only start once com.apple.container.apiserver is enabled, which avoids launch-time races during activation. Changing any container option (image, env, volumes, ports) and rebuilding will automatically stop the old container and start a fresh one with the new config.

This follows upstream Apple container semantics: the runtime is available after the first session for system.primaryUser exists. A GUI login works, and an SSH login as that user works too. services.containerization.user must match system.primaryUser.

Options

See docs/options.md for the full option reference — services.containerization, containers, kernel, images, and linux-builder.

Examples

Minimal

services.containerization.enable = true;

Web server with port forwarding

services.containerization = {
  enable = true;
  containers.nginx = {
    image = "nginx:alpine";
    autoStart = true;
    extraArgs = [ "--publish" "8080:80" ];
  };
};

Gitea with persistent storage

services.containerization = {
  enable = true;
  containers.gitea = {
    image = "gitea/gitea:latest";
    autoStart = true;
    volumes = [
      "/Users/me/.gitea/data:/data"
    ];
    extraArgs = [
      "--publish" "3000:3000"
      "--publish" "2222:22"
    ];
  };
};

Custom versions

Override package (apple/container CLI) or kernel (kata-containers) with a different version:

services.containerization = {
  enable = true;

  package = pkgs.callPackage "${inputs.nix-apple-container}/pkgs/package.nix" {
    version = "0.11.0";
    hash = "sha256-...";
  };

  kernel = pkgs.callPackage "${inputs.nix-apple-container}/pkgs/kernel.nix" {
    version = "3.27.0";
    hash = "sha256-...";
  };
};

The runtime default kernel (services.containerization.kernel) is separate from the per-builder kernel used by linux-builder.<arch>.kernel. By default, linux-builder.x86_64.kernel is pinned to Kata 3.24.0 and passed via container run --kernel to avoid Rosetta regressions with newer kernels.

The builder image tag is versioned independently from the base nixos/nix version. Tags use the form <builder-version>-nix<nix-version>, for example v2-nix2.34.6.

Formatting is managed with treefmt-nix. Run nix fmt (or make fmt) to format the repo, and nix flake check to verify formatting in CI. The current config enables nixfmt for .nix files.

For a fully custom kernel (different source or extraction logic), pass any flat-file derivation to kernel:

services.containerization.kernel = pkgs.stdenv.mkDerivation {
  pname = "my-kernel";
  version = "3.26.0";
  outputHash = "sha256-...";
  outputHashMode = "flat";
  nativeBuildInputs = with pkgs; [ cacert curl zstd gnutar ];
  buildCommand = ''
    curl -L -o kata.tar.zst "https://github.com/kata-containers/kata-containers/releases/download/3.26.0/kata-static-3.26.0-arm64.tar.zst"
    tar --zstd -xf kata.tar.zst ./opt/kata/share/kata-containers/
    cp -L ./opt/kata/share/kata-containers/vmlinux.container $out
  '';
};

Nix-built images with nix2container

Build OCI images with nix2container and load them into the container runtime without storing full tarballs in the Nix store. Only a tiny JSON metadata file lives in the store — layers are streamed on-the-fly from existing Nix store paths at activation time.

Add the nix2container flake input:

{
  inputs = {
    nix2container.url = "github:nlewo/nix2container";
    nix2container.inputs.nixpkgs.follows = "nixpkgs";
  };
}

Then declare images and containers. Images must contain aarch64-linux packages since Apple containers run Linux VMs:

{ inputs, pkgs, ... }:
let
  nix2container = inputs.nix2container.packages.${pkgs.system}.nix2container;
  pkgsLinux = import pkgs.path { system = "aarch64-linux"; };
in {
  services.containerization = {
    enable = true;
    linux-builder.aarch64.enable = true;  # needed to build aarch64-linux derivations

    images.greeter = nix2container.buildImage {
      name = "greeter";
      tag = "latest";
      config.Cmd = [
        "${pkgsLinux.busybox}/bin/sh" "-c"
        "echo 'Listening on :8080...' && while true; do echo -e 'HTTP/1.1 200 OK\r\n\r\nHello from a Nix-built container!' | ${pkgsLinux.busybox}/bin/nc -l -p 8080; done"
      ];
    };

    containers.greeter = {
      image = "greeter:latest";
      autoStart = true;
      extraArgs = [ "--publish" "8080:8080" ];
    };
  };
}

Test it with curl http://localhost:8080 after rebuild.

Images are loaded into the runtime via container image load at activation time. The load is idempotent — images already present are skipped.

Note: Building nix2container images requires aarch64-linux packages. Enable linux-builder.aarch64 and rebuild twice: the first starts the builder, the second builds and loads the image.

Cross-architecture Linux builds

Build x86_64-linux and aarch64-linux derivations on Apple Silicon:

services.containerization = {
  enable = true;
  linux-builder = {
    aarch64.enable = true;  # aarch64-linux builder
    x86_64.enable = true;   # x86_64-linux builder 
  };
};

Each architecture runs its own builder container. Both share the same multi-arch image and SSH key. The x86_64 builder uses the runtime name nix-builder-amd64 and listens on port 31023 by default (configurable via linux-builder.x86_64.sshPort).

The x86_64 builder also has its own per-container kernel option:

services.containerization.linux-builder.x86_64.kernel =
  pkgs.callPackage "${inputs.nix-apple-container}/pkgs/kernel.nix" {
    version = "3.24.0";
    hash = "sha256-2pNP+CBvV4DBAeGiwIe8MpdasjcDhc9L2tcRArP7ANw=";
  };

Set it to null to use the runtime default kernel instead.

Registry images

Containers referencing images not in images.* are pulled automatically by the container runtime when container run is invoked. No Nix-side configuration is needed — just declare the container:

services.containerization = {
  enable = true;

  containers.alpine = {
    image = "alpine:latest";
    autoStart = true;
  };
};

Uninstall

Set enable = false and rebuild. The module will:

  1. Unload all container launchd user agents
  2. Stop the container runtime
  3. Remove runtime state (~/Library/Application Support/com.apple.container/) — respects preserveImagesOnDisable and preserveVolumesOnDisable
  4. Remove builder SSH key (~/.ssh/nix-builder_ed25519*) and legacy /etc/nix/builder_ed25519* if present
  5. Clear user preference defaults and .pkg install receipts

If you remove the module import entirely (instead of enable = false), no cleanup runs. Keep the import with enable = false first, rebuild, then remove the import. If you find any lingering artifacts please open an issue.

Standalone uninstall

If you've already removed the module import, or prefer a one-command cleanup:

nix run github:halfwhey/nix-apple-container#uninstall

This performs the same teardown as enable = false — stops the runtime, removes agents, cleans up state. Accepts --preserve-images, --preserve-volumes, and --yes (skip confirmation).

Troubleshooting

VPN breaks container networking

Active VPN or tunnel interfaces (utun*) break the vmnet port forwarding used by --publish. Symptoms include curl: (56) Connection reset by peer on published ports and failure to resolve .test DNS names — even though the container itself is running normally.

This is a known upstream issue. The only current workaround is to disconnect the VPN before starting containers. macOS 26 is expected to overhaul container networking.

To check for active tunnel interfaces:

ifconfig | grep utun

Headless Mac: first user session still required

The module keeps the runtime in the user's launchd domain because Apple container expects its apiserver there, not in system. After a cold boot, containers will not start until system.primaryUser has an active session. A GUI login works, and an SSH login as that user also works; the Background-session runtime agent then bootstraps the managed container jobs into that session's user launchd domain.

Headless Mac: permission popups block container startup

On headless Mac minis (or any Mac without a display), macOS may present GUI permission dialogs for network or volume access the first time a container starts. These popups are invisible over SSH and will silently block the container from launching.

Connect a display (or use screen sharing) and approve the permission prompts. Once granted, the permissions persist across reboots.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages