Installing Guix System on Proxmox LXC

April 26, 2026

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-boot

This 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:

  1. store-file-system (in gnu/system.scm) filters file-systems for (mount? #t) and crashes with a match error if the result is empty. Any (mount? #f) dummy root crashes before it does anything useful.

  2. With (mount? #t), check-file-system-availability tries to stat() the device or look up the partition label against the host's block devices at build-time. Nothing works: /dev/null errors, made-up paths error, labels don't exist on the host.

  3. 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 /proc and /sys are partially masked
  • %linux-bare-metal-service -- tries to run modprobe etc.
  • host-name-service-type -- would clobber the hostname Proxmox injects via lxc.uts.name
  • hosts-service-type -- creates a read-only /etc/hosts with 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:

  1. mingetty's login-program defaults 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...

  2. 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-checks needed: check-file-system-availability walks /proc/partitions looking for a block device with the fake/dummy Guix label. Inside the LXC container, /proc/partitions shows 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 because image -t tarball rewrites the root type to "dummy" in its sandbox; reconfigure reads the config as-is.

  • --no-bootloader needed: 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 because image -t tarball skips the bootloader installer entirely.

Fix:

sudo -i guix system reconfigure --skip-checks --no-bootloader /etc/config.scm

The 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.scm

Unfortunately, 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 1

ostype 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/hosts generated from the Proxmox-assigned hostname at boot, rather than hardcoding a hostname in config.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