Debian/systemd base image for Apple container machines on Apple Silicon, plus tool overlays (kind+podman, jdk25+docker).
  • Dockerfile 100%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Mikhail Lukianchenko a5fe7531c3
Release from CHANGELOG.md as 13.YYYYMMDD.PATCH
MAJOR is the Debian major, MINOR the snapshot date, PATCH the release
serial on one snapshot; release.yaml refuses a version whose first two
parts disagree with Dockerfile's FROM. The pin moves to pin.yaml and only
commits the Dockerfile: CHANGELOG.md is not written by a workflow, so a
new snapshot ships with the next entry. Overlays build FROM the base
version being released rather than latest.
2026-10-30 23:30:24 +01:00
.claude-plugin Move to sarak.sh 2026-10-30 17:25:35 +01:00
.forgejo/workflows Release from CHANGELOG.md as 13.YYYYMMDD.PATCH 2026-10-30 23:30:24 +01:00
skills/apple-container apple-container skill: Podman inside a machine replaces Docker 2026-10-30 19:02:44 +01:00
CHANGELOG.md Release from CHANGELOG.md as 13.YYYYMMDD.PATCH 2026-10-30 23:30:24 +01:00
Dockerfile Pin base to debian:trixie-20261027 2026-10-28 05:02:56 +00:00
jdk25.Dockerfile Release from CHANGELOG.md as 13.YYYYMMDD.PATCH 2026-10-30 23:30:24 +01:00
kind.Dockerfile Release from CHANGELOG.md as 13.YYYYMMDD.PATCH 2026-10-30 23:30:24 +01:00
podman.Dockerfile Release from CHANGELOG.md as 13.YYYYMMDD.PATCH 2026-10-30 23:30:24 +01:00
README.md Release from CHANGELOG.md as 13.YYYYMMDD.PATCH 2026-10-30 23:30:24 +01:00

machine-debian

A base image for Apple container machines — long-lived Debian/systemd Linux VMs on macOS (Apple Silicon). It ships a general development baseline; each project overlays only its own non-standard tooling and boots a project-specific machine from the result.

Published as sarak.sh/mikluko/machine-debian.

This repo is also a Claude Code plugin shipping the apple-container skill — the working knowledge behind this project (see Claude plugin).

Requirements

  • macOS on Apple Silicon.
  • Apple's container CLI, v1.0+ (container --version).
  • arm64 only. The image is built and published for arm64.

Tags

The base and each overlay are separate packages, released together under one version from CHANGELOG.md:

  • MAJOR is the Debian major version of the base: 13 for trixie.
  • MINOR is the date of the Debian snapshot, YYYYMMDD.
  • PATCH counts releases on one snapshot.

13.20261027.1 is the second release on debian:trixie-20261027. Each release publishes MAJOR.MINOR.PATCH, MAJOR.MINOR, MAJOR and latest per package. Pin the full version for reproducibility.

  • base: sarak.sh/mikluko/machine-debian
  • kind: sarak.sh/mikluko/machine-debian/kind
  • jdk25: sarak.sh/mikluko/machine-debian/jdk25
  • podman: sarak.sh/mikluko/machine-debian/podman

Releasing

release.yaml runs on a push to main that touches CHANGELOG.md. When the newest entry names a version no tag names yet, it builds and pushes every image, then creates the vMAJOR.MINOR.PATCH tag and release. It refuses a version whose MAJOR and MINOR disagree with the snapshot Dockerfile pins.

Base pinning

Dockerfile's FROM tracks a dated Debian snapshot (debian:trixie-YYYYMMDD), not the floating trixie. Daily, pin.yaml resolves Debian's newest trixie-YYYYMMDD tag and commits the new pin when it changes. A pin publishes nothing by itself; it ships with the next release, MAJOR.YYYYMMDD.0 of the new snapshot.

Setup: the registry login exchanges the workflow's OIDC token through an Authorized Integration of the owner's with scope write:package. The repository variable REGISTRY_AUDIENCE holds that integration's audience.

What's in the base

  • Init: systemd as PID 1 (ENTRYPOINT /sbin/init), so services run normally inside the machine.
  • Languages: Go, Node.js + npm.
  • Toolchain: build-essential, Git, OpenSSH client, sudo, ca-certificates.
  • DNS: a local unbound on 127.0.0.1, forwarding over DNS-over-TLS to 1.1.1.1 and 9.9.9.10, is the machine's only resolver; the vmnet gateway the machine is handed at boot is not used.

No container runtime is baked into the base. Everything beyond this baseline, including Docker or Podman, is a per-project or shared overlay (see Overlays).

How it works

  1. This repo builds and publishes the base image to sarak.sh/mikluko/machine-debian.
  2. A project defines its own Dockerfile with FROM sarak.sh/mikluko/machine-debian and adds project-specific packages/tools (see Overlays).
  3. The project builds that Dockerfile into a local image and creates a container machine from it. The machine is a persistent VM you run commands in, stop, and delete.

A container machine boots from an image snapshot. Rebuilding the image does not update a running machine — to pick up changes you rebuild and re-create the machine (see Update).

Playbooks

Sizing used below: --cpus 6 --memory 16G. Adjust per project.

Publish / update the base image

Run from this repo. Builds arm64 and pushes to sarak.sh.

container registry login sarak.sh           # once
container build -t sarak.sh/mikluko/machine-debian .
container image push sarak.sh/mikluko/machine-debian

Create a project machine

Run from the project directory (the one with the FROM sarak.sh/mikluko/machine-debian Dockerfile). Replace myproject.

container build -t machine-myproject .
container machine create machine-myproject --name myproject --cpus 6 --memory 16G

create boots the machine. Confirm:

container machine ls

Enter / run in a machine

The host-matched default user can't reach the root-owned Docker socket, so docker fails. Fix it by joining the docker group with --gid (least privilege; avoids the root home-mount hazard). Detect the gid inside the machine — getent group docker needs no root:

gid=$(container machine run -n myproject -- getent group docker | cut -d: -f3)
container machine run -n myproject --gid "$gid" -t -i            # interactive shell
container machine run -n myproject --gid "$gid" -- <cmd> [args]  # one-off command

There is no machine-level default-user setting, so pass --gid on every run (see Gotchas). run boots the machine if it is stopped.

Update a project machine

Machines cannot hot-swap their image, so re-create:

container build -t machine-myproject .          # rebuild (pull fresh base first if needed)
container machine stop myproject
container machine delete myproject
container machine create machine-myproject --name myproject --cpus 6 --memory 16G

To also pick up a newer base image, pull it before rebuilding:

container image pull sarak.sh/mikluko/machine-debian

Delete a project machine

container machine stop myproject
container machine delete myproject
container image delete machine-myproject    # optional: drop the local image too

Gotchas

  • Use the docker group, not --root. run defaults to the host-matched user, who can't reach the root-owned Docker socket. Join the group with --gid (detect it via getent group docker, no root needed). Docker's daemon runs as root and stores under machine-local /var/lib/docker, so the group is enough — no rootless/home-mount storage problem. There is no machine-level default-user setting, so pass --gid on every run. (Rootless Podman is daemonless and does hit the home-mount storage issue below — run it as root or point its storage at a machine-local path.)
  • Your host $HOME is mounted read-write into the machine at the same path, and run defaults its working directory into it. A relative write inside the machine can hit your real host files. Build and scratch in machine-local paths (/root, /var/tmp), not under the mounted home.
  • SSH agent forwarding is transparent for 1Password — its agent socket lives under ~/Library/... (inside the mounted home), so ssh-add -l works in the machine with no setup. A stock macOS ssh-agent socket lives outside $HOME and would not forward this way.
  • container machine run often prints a spurious first line Error: The operation couldn't be completed. Operation not supported by device. It is harmless; the command still runs.
  • container machine create may print an XPC boot timeout yet boot fine — check container machine ls before retrying.

Overlays

Common tool layers ship as named overlay Dockerfiles here. Each is FROM sarak.sh/mikluko/machine-debian, inherits the base init/entrypoint, and CI publishes it as its own sub-package (see Tags).

  • kind.Dockerfile → sarak.sh/mikluko/machine-debian/kind — kind + Podman as its container provider (KIND_EXPERIMENTAL_PROVIDER=podman).
  • jdk25.Dockerfile → sarak.sh/mikluko/machine-debian/jdk25 — OpenJDK 25 (headless) + Maven (from Debian trixie-security) + Docker Engine.
  • podman.Dockerfile → sarak.sh/mikluko/machine-debian/podman — rootful Podman serving the Docker-compatible API on port 2375, for a host client that speaks to Docker, such as an IDE's Docker interpreter, at tcp://<name>.machine:2375. A machine publishes no ports and changes address on every boot, so the client goes by name, which resolves once sudo container system dns create machine has run. Unauthenticated, as root.

Create a machine straight from a published overlay, no local build:

container machine create sarak.sh/mikluko/machine-debian/kind \
  --name myproject --cpus 6 --memory 16G

Or build an overlay Dockerfile locally (e.g. to pin a version or tweak it):

container build -t machine-myproject -f kind.Dockerfile .
container machine create machine-myproject --name myproject --cpus 6 --memory 16G

A project needing several overlays composes them in its own Dockerfile: start FROM sarak.sh/mikluko/machine-debian and copy in the RUN blocks it wants (a single base per image, so overlays are combined by hand, not chained).

Claude plugin

This repo is a Claude Code plugin shipping the apple-container skill: the accumulated working knowledge of container machine (the gotchas around home mounts, rootless storage, SSH-agent forwarding, and systemd/Docker inside the VM). It is published through the mikluko/marketplace-claude marketplace:

/plugin marketplace add mikluko/marketplace-claude
/plugin install machine-debian@mikluko

Plugin layout: .claude-plugin/plugin.json (manifest), skills/apple-container/.