I use Proxmox for hosting most of my personal services, and since I've been writing a lot of Guile Scheme lately, I wanted a lightweight Guix System container for hosting scrap.
Specifically, I want a fully working Guix System inside an unprivileged LXC
container that behaves as close to a bare-metal install as possible. It's not
enough to just have a static image, guix system reconfigure and everything
else should work from inside the container.
There's not a lot of good information on how to do this, and what does exist seems to be a bit stale.
It turned out to be much more work than I had anticipated.
Posting this all here for my future self, or in the off chance that someone else stumbles upon this while trying to solve the same problem.
Problem 1: Guix doesn't do FHS
This isn't a surprise, and isn't really a problem, but it does mean that the default Proxmox LXC configuration doesn't know how to boot a standard guix image.
Most guides for running Guix in LXC (including this otherwise excellent
one) hardcode
specific /gnu/store/... paths in the container config. They set
lxc.init.cmd to something like:
/gnu/store/vbcdwklck7cd28j4jv9wchfw60abivfd-guile-3.0.9/bin/guile \
/gnu/store/hgi3i9ydjhi5bxnpz7zmzaqn60iw3ak1-bootThis works fine, particularly if you are just spinning up a one-off container.
But it is something you need to remember to edit in /etc/pve/lxc/<CTID>.conf
every time you spin up a new container.
And as soon as you run guix system reconfigure, or update the
daemon and the store gets garbage collected -- the container refuses to start.
Fix: use program-file to build a small Guile script that resolves
/var/guix/profiles/system at boot time, sets GUIX_NEW_SYSTEM, and execls
the correct Shepherd boot script. Then use special-files-service-type to
symlink it to /sbin/init:
(define %lxc-special-files
`(("/sbin/init"
,(program-file "lxc-init"
#~(begin
(setenv "HOME" "/root")
(let* ((system (canonicalize-path
"/var/guix/profiles/system"))
(guile (string-append system
"/profile/bin/guile"))
(boot (string-append system "/boot")))
(setenv "GUIX_NEW_SYSTEM" system)
(execl guile "guile" boot)))))
("/bin/sh" ,(file-append bash "/bin/sh"))
("/usr/bin/env" ,(file-append coreutils "/bin/env"))))From the outside the container now looks like any FHS-compatible distro:
Proxmox execs /sbin/init, which always resolves the current generation.
Reconfigure changes the profile symlink, and the next boot picks it up
automatically.
Catch: special-files-service-type creates these symlinks during
activation. Activation runs after PID 1 starts. PID 1 is /sbin/init. So
on first boot, /sbin/init needs to exist before the thing that creates it can
run, which is a bit of chicken-and-egg situation.
We work around that by manually injecting a stub that gets replaced on first boot.
Problem 2: guix system init doesn't work in 2026 (for container images)
Most instructions on the web suggest using guix system init. But
unfortunately as of 2026, it can no longer produce a usable LXC rootfs due to
the addition of some extra checks that require a valid filesystem, so filling
it in with a "dummy" filesystem fails.
The problem:
store-file-system(ingnu/system.scm) filtersfile-systemsfor(mount? #t)and crashes with a match error if the result is empty. Any(mount? #f)dummy root crashes before it does anything useful.With
(mount? #t),check-file-system-availabilitytries tostat()the device or look up the partition label against the host's block devices at build-time. Nothing works:/dev/nullerrors, made-up paths error, labels don't exist on the host.Even past those checks, shepherd unconditionally calls
mount(2)for every(mount? #t)file-system. The container's/is already mounted by LXC -- it would either be clobbered (tmpfs) or the syscall would fail.
There doesn't seem to be any way to provide a dummy/stub filesystem that will
satisfy guix system init.
Fix: use guix system image -t tarball instead. It builds in a sandbox
where the root filesystem is synthesized (image->root-file-system in
gnu/system/image.scm rewrites the root type to "dummy"), so the host's
block devices never get involved.
We do still declare a root filesystem with (mount-may-fail? #t) in the config
because at boot shepherd will attempt the mount, fail (the host filesystem
isn't labelled Guix), and we don't want that to abort boot.
Why tarball and not docker? image -t docker applies
containerized-operating-system automatically -- it swaps the kernel, drops
console services, replaces networking with dummy-networking-service-type, and
wraps the output in a docker manifest.
That's too docker-specific though (as you would expect from the name). It
strips things we might actually want (like /dev/shm and /gnu/store mounts
that work fine in unprivileged LXC) and adds a manifest wrapper we'd have to
discard. tarball emits a flat rootfs directly usable by pct create.
So we have to replicate the relevant subset of containerized-operating-system by
hand.
Problem 3: Hardware-related services need to be removed
There are a bunch of things that aren't relevant to a container (firmware, kernel, hardware-related services, etc...) that we have to remove:
firmware-service-type-- activation fails because/procand/sysare partially masked%linux-bare-metal-service-- tries to runmodprobeetc.host-name-service-type-- would clobber the hostname Proxmox injects vialxc.uts.namehosts-service-type-- creates a read-only/etc/hostswith the "wrong" hostname -- we want to pick up the hostname provided by Proxmox/LXC
Fix: delete them from essential-services using modify-services:
(essential-services
(modify-services
(operating-system-default-essential-services this-operating-system)
(delete firmware-service-type)
(delete (service-kind %linux-bare-metal-service))
(delete host-name-service-type)
(delete hosts-service-type)))We also stub out kernel, firmware, and initrd-modules.
(operating-system
(firmware '())
(initrd-modules '())
(kernel hello)
Deleting host-name-service-type causes more problems though: agetty's
shepherd service hardcodes host-name as a requirement. Without something
providing it, agetty won't start. So we have to replace it with a dummy service:
(define %dummy-host-name
(simple-service 'dummy-host-name shepherd-root-service-type
(list (shepherd-service
(provision '(host-name))
(one-shot? #t)
(start #~(const #t))))))We also need (chroot? #f) on guix-service-type if we want to use
unpriviledged containers, because unprivileged can't use chroot() and the
various mounts the daemon's build sandbox uses.
Problem 4: %base-file-systems
You can't use %base-file-systems wholesale.
%debug-file-system (debugfs at /sys/kernel/debug) -- mount gets blocked
by LXC and fails the shepherd service. That blocks the rest of the boot, which
blocks the rest of the boot process.
%pseudo-terminal-file-system (devpts at /dev/pts) -- PVE pre-mounts a
healthy devpts with ptmxmode=666 before PID 1 starts. Guix then mounts
another devpts on top with ptmxmode=000. Both succeed, but devpts has
per-instance slave-node namespaces. /dev/ptmx allocates PTYs on LXC's
instance; /dev/pts/ now shows Guix's instance where those slaves don't exist.
openpty(3) returns ENOENT and sshd reports PTY allocation request failed on channel 0 when you attempt to SSH into the container or use pct console
%efivars-file-system -- harmless (has mount-may-fail?), but pointless
in a container.
Fix: declare a minimal set of file systems explicitly. We only really need
%immutable-store and %shared-memory-file-system (and even the latter is
questionable, but it doesn't cause any problems):
(file-systems
(list (file-system
(device (file-system-label "Guix"))
(mount-point "/")
(type "ext4")
(mount-may-fail? #t)
(check? #f))
%shared-memory-file-system
%immutable-store))Problem 5: mingetty doesn't work on pct console
I initially tried to use mingetty-service-type for the console login. Two problems:
mingetty's
login-programdefaults to#f, which resolves to the compile-time default/bin/login. That obviously doesn't exist on Guix. (agetty defaults to(file-append shadow "/bin/login"), which works). This might be worth filing as a Guix bug...Even after fixing that, every login attempt produced
tty1: too long login name. At this point I was getting too exhausted to dig into exactly why, so it's still a mystery.
Fix: use agetty-service-type. It seems to "Just Work", is what
%base-services uses on bare metal anyway. I can't even remember why I
initially picked mingetty. I probably just copied it from another LXC example...
Problem 6: guix system reconfigure fails inside the container
Once I finally got a working container, I struggled a bit getting guix system reconfigure to work:
--skip-checksneeded:check-file-system-availabilitywalks/proc/partitionslooking for a block device with the fake/dummyGuixlabel. Inside the LXC container,/proc/partitionsshows the host's block devices but the container's/dev/is its own tmpfs, so every probe fails. The template build doesn't hit this becauseimage -t tarballrewrites the root type to"dummy"in its sandbox;reconfigurereads the config as-is.--no-bootloaderneeded: grub-install inspects the host's mount table, finds PVE's ZFS subvolume name, and can't canonicalize it from inside the container. The template build doesn't hit this becauseimage -t tarballskips the bootloader installer entirely.
Fix:
sudo -i guix system reconfigure --skip-checks --no-bootloader /etc/config.scmThe End Result
This is the config.scm I ended up with
(use-modules (gnu)
(gnu system locale)
(gnu system shadow)
(guix channels)
(guix gexp)
(guix packages)
(guix download))
(use-service-modules networking ssh admin shepherd)
(use-package-modules ssh bash package-management admin)
(define %lxc-special-files
`(("/sbin/init"
,(program-file "lxc-init"
#~(begin
(setenv "HOME" "/root")
(let* ((system (canonicalize-path
"/var/guix/profiles/system"))
(guile (string-append system
"/profile/bin/guile"))
(boot (string-append system "/boot")))
(setenv "GUIX_NEW_SYSTEM" system)
(execl guile "guile" boot)))))
("/bin/sh" ,(file-append bash "/bin/sh"))
("/usr/bin/env" ,(file-append coreutils "/bin/env"))))
(define %dynamic-hosts
(simple-service 'dynamic-hosts-file activation-service-type
#~(let ((hostname (gethostname)))
(call-with-output-file "/etc/hosts"
(lambda (port)
(display "127.0.0.1 localhost\n" port)
(display "::1 localhost\n" port)
(display (string-append
"127.0.1.1 " hostname "\n") port))))))
(define %dummy-host-name
(simple-service 'dummy-host-name shepherd-root-service-type
(list (shepherd-service
(documentation "No-op host-name service for LXC.")
(provision '(host-name))
(one-shot? #t)
(start #~(const #t))))))
(operating-system
(host-name "localhost")
(timezone "America/Edmonton")
(locale "en_US.utf8")
(firmware '())
(initrd-modules '())
(kernel hello)
(packages (cons* guix sudo %base-packages))
(users (cons* (user-account
(name "ryan")
(comment "Ryan")
(group "users")
(supplementary-groups '("wheel"))
(home-directory "/home/ryan")
(password "$6$...")) ;; mkpasswd -m sha-512
%base-user-accounts))
(essential-services
(modify-services
(operating-system-default-essential-services this-operating-system)
(delete firmware-service-type)
(delete (service-kind %linux-bare-metal-service))
(delete host-name-service-type)
(delete hosts-service-type)))
(locale-definitions
(list (locale-definition
(name "en_US.utf8")
(source "en_US")
(charset "UTF-8"))))
(bootloader
(bootloader-configuration
(bootloader grub-bootloader)
(targets '("/dev/null"))))
(file-systems
(list (file-system
(device (file-system-label "Guix"))
(mount-point "/")
(type "ext4")
(mount-may-fail? #t)
(check? #f))
%shared-memory-file-system
%immutable-store))
(services
(list
(service dhcpcd-service-type)
(service syslog-service-type)
(service agetty-service-type
(agetty-configuration
(tty "tty1")
(term "linux")
(extra-options '("-L"))
(shepherd-requirement '(user-processes udev syslogd))))
(service login-service-type)
(service guix-service-type
(guix-configuration (chroot? #f)))
(service static-networking-service-type
(list %loopback-static-networking))
(service special-files-service-type %lxc-special-files)
(service udev-service-type
(udev-configuration (rules '())))
%dynamic-hosts
%dummy-host-name
(service openssh-service-type
(openssh-configuration
(password-authentication? #f)
(authorized-keys
`(("ryan" ,(local-file "keys/ryan.pub")))))))))We can build it with:
guix system image -t tarball config.scmUnfortunately, as mentioned above, we can't use the image as-is.
The tarball from guix system image doesn't have /sbin/init, /bin/sh, or
/usr/bin/env because activation hasn't run yet (see Problem 1). Since we're
already messing with the image, we also want to inject the source config.scm
so reconfigure works out of the box without having to manually scp it over
later.
Here's the shell script that handles it:
#!/usr/bin/env bash
set -euo pipefail
CONFIG=config.scm
OUTPUT=guix-lxc-template.tar.zst
BUILDDIR=build
# 1. Build the rootfs tarball.
image_path=$(guix system image -t tarball "$CONFIG")
# 2. Decompress and extract.
rm -rf "$BUILDDIR/rootfs"
mkdir -p "$BUILDDIR/rootfs"
case "$image_path" in
*.tar.gz) gzip -dc "$image_path" | tar -xC "$BUILDDIR/rootfs" ;;
*.tar.xz) xz -dc "$image_path" | tar -xC "$BUILDDIR/rootfs" ;;
*.tar.zst) zstd -dc "$image_path" | tar -xC "$BUILDDIR/rootfs" ;;
*.tar) tar -xf "$image_path" -C "$BUILDDIR/rootfs" ;;
*) echo "unexpected extension: $image_path"; exit 1 ;;
esac
# 3. Seed /sbin/init, /bin/sh, /usr/bin/env.
# The activation script in the store contains the link targets.
# Parse them out and create the symlinks directly in the rootfs.
activate=$(find "$BUILDDIR/rootfs/gnu/store" -name '*-activate-service.scm' \
-exec grep -l activate-special-files {} + | head -1)
if [ -z "$activate" ]; then
echo "no activate-special-files script found"; exit 1
fi
# Extract pairs: "/sbin/init" "/gnu/store/...-lxc-init" etc.
grep -oP '"/([^"]+)"\s+"/gnu/store/[^"]+"' "$activate" |
while read -r line; do
link=$(echo "$line" | grep -oP '^"/[^"]+"' | tr -d '"')
target=$(echo "$line" | grep -oP '"/gnu/store/[^"]+"' | tr -d '"')
dest="$BUILDDIR/rootfs${link}"
mkdir -p "$(dirname "$dest")"
ln -sf "$target" "$dest"
echo " $link -> $target"
done
# 4. Inject config.scm for in-container reconfigure.
cp "$CONFIG" "$BUILDDIR/rootfs/etc/config.scm"
chmod 600 "$BUILDDIR/rootfs/etc/config.scm"
# 5. Repack as zstd with root ownership, because zstd is better than gzip.
ZSTD_CLEVEL=19 tar -C "$BUILDDIR/rootfs" --zstd \
--numeric-owner --owner=0 --group=0 -cf "$OUTPUT" .
echo "Built $OUTPUT ($(du -h "$OUTPUT" | cut -f1))"Deploy to Proxmox:
scp guix-lxc-template.tar.zst <proxmox-host>:/var/lib/vz/template/cache/
pct create <vmid> local:vztmpl/guix-lxc-template.tar.zst \
--hostname guix \
--rootfs local-zfs:20 \
--memory 2048 \
--net0 name=eth0,bridge=vmbr0,ip=dhcp,ip6=auto \
--features nesting=1,keyctl=1 \
--ostype unmanaged \
--unprivileged 1ostype unmanaged because Proxmox doesn't know about Guix.
nesting=1 relaxes user-namespace restrictions enough for shepherd,
guix-daemon, and activation.
As I'm writing this I'm realizing that I didn't even confirm whether or not
either nesting=1 or keyctl=1 is required after setting (chroot #f). I'll
have to revisit that.
Niceties
A few optional but handy things in the config:
DHCP inside the container via
dhcpcd-service-type.Dynamic
/etc/hostsgenerated from the Proxmox-assigned hostname at boot, rather than hardcoding a hostname inconfig.scm:(define %dynamic-hosts (simple-service 'dynamic-hosts-file activation-service-type #~(let ((hostname (gethostname))) (call-with-output-file "/etc/hosts" (lambda (port) (display "127.0.0.1 localhost\n" port) (display "::1 localhost\n" port) (display (string-append "127.0.1.1 " hostname "\n") port))))))SSH public keys from SourceHut at build time so that we don't have to manage key files manually.
References
- Guix in a Linux Container -- Thedro Neely -- the article that got me started
- GuixSD in LXD -- Eddy Pronk -- 2017 thread exploring /sbin/init shims
- Init command setup for Incus -- Sept 2025, still manually updating store hashes
- System containers workflow -- Nov 2025, the immutable rebuild-and-replace default
- AppArmor and guix-daemon
- Proxmox LXC documentation