A Minimal Debian Trixie VPS with libvirt + LXC for Linux containers

A Minimal Debian Trixie VPS with libvirt + LXC for Linux containers

I recently rebuilt my personal VPS and wanted something small, simple, and easy to reason about. No Docker, no Kubernetes, no overlay networks — just a handful of isolated environments on a single host, each looking and feeling like its own little Debian box.

This post walks through the setup I ended up with: a Debian Trixie host running libvirt-lxc containers that share the host’s network namespace and bind directly to the host NIC over IPv6.

Goals

Before diving in, here is what I wanted out of the box:

  • Isolation between workloads. Mail, static web, and a few other services should each live in their own root filesystem and not step on each other.
  • Simple tooling. LXC for the container runtime, libvirt for lifecycle management. Both ship in Debian and virsh is a comfortable interface.
  • IPv6-only SSH access to the host and guests for administration.
  • Direct NIC access from containers. Each container should bind directly to the host’s network interface instead of going through a bridge or NAT.

The last point is the unusual one, and it is what makes the rest of the setup delightfully boring. More on that below.

Step 1 — Install the required packages

On a fresh Debian Trixie host, install libvirt’s LXC driver along with debootstrap (for building container rootfs trees) and AppArmor (which libvirt uses to confine the containers it launches):

apt-get install \
    libvirt-daemon-driver-lxc \
    libvirt-clients \
    debootstrap \
    apparmor \
    apparmor-profiles \
    apparmor-utils

No daemon configuration is required at this point — the LXC driver is loaded on demand by libvirtd the first time you connect to lxc:///.

Step 2 — Bootstrap a container root filesystem

Pick a location under /var/lib/libvirt/lxc/ for the container and let debootstrap build a minimal Debian userland into it. I am using Bookworm inside the containers here, but any recent Debian suite will work:

mkdir -p /var/lib/libvirt/lxc/test
debootstrap bookworm /var/lib/libvirt/lxc/test http://deb.debian.org/debian

When this finishes you have a complete Debian root filesystem on the host, ready to be booted by libvirt.

Step 3 — Set a root password in the new rootfs

Before starting the container we need a way to log in once it boots. The simplest path is to chroot into the freshly debootstrapped tree and set a root password directly:

chroot /var/lib/libvirt/lxc/test /bin/bash
passwd
exit

If you skip this step, virsh console will drop you at a login prompt that you have no credentials for, and you will end up redoing the chroot dance anyway.

Step 4 — Write the domain XML

libvirt describes each container as a “domain”, just like a regular VM. Save the following as test.xml:

<domain type='lxc' xmlns:lxc='http://libvirt.org/schemas/domain/lxc/1.0'>
  <name>test</name>
  <memory unit='MiB'>1024</memory>
  <vcpu placement='static'>1</vcpu>
  <os>
    <type arch='x86_64'>exe</type>
    <init>/sbin/init</init>
  </os>
  <devices>
    <filesystem type='mount' accessmode='passthrough'>
      <source dir='/var/lib/libvirt/lxc/test'/>
      <target dir='/'/>
    </filesystem>
    <console type='pty' />
  </devices>
  <lxc:namespace>
    <lxc:sharenet type='pid' value='1'/>
  </lxc:namespace>
</domain>

A few things worth pointing out:

  • /sbin/init runs the full systemd stack inside the container, so systemctl, journald, and timers all work as you would expect.
  • The filesystem entry passes the debootstrapped directory through as the container’s root.
  • The interesting line is <lxc:sharenet type='pid' value='1'/>. We will come back to it in a moment.

Then register the domain with libvirt:

virsh -c lxc:/// define test.xml
virsh -c lxc:/// start test
virsh -c lxc:/// console test

The console subcommand attaches to the container’s pty. Log in as root with the password you set earlier, and you are inside a freshly booted Debian system. To detach from the console without stopping the container, press Ctrl-].

Step 5 — The lxc:sharenet trick

This is the part that makes the whole setup work without any bridge, iptables rules, or virtual networking.

By default, libvirt-lxc creates a fresh network namespace for each container. You then have to wire that namespace up to the outside world somehow — usually a virbr0 bridge with NAT, or a macvlan device, or a pair of veths.

<lxc:sharenet type='pid' value='1'/> tells libvirt instead: do not create a new network namespace for this container. Join the namespace of PID 1 — which is the host’s init process. In other words, the container sees the host’s network interfaces as its own. eth0 inside the container is literally the same eth0 as on the host, with all the same addresses and routes.

The practical consequences:

  • Containers can bind directly to any host IP address without forwarding or NAT. A mail server in one container can listen on one IPv6 address while a web server in another container listens on a different one.
  • There is no per-container firewall — all nftables rules are evaluated in the host’s network namespace, which is the single source of truth.
  • localhost inside the container is the host’s localhost, so containers can talk to each other over loopback.
  • Everything else (PIDs, mounts, UTS, IPC) is still isolated, so processes in different containers cannot see each other and each has its own hostname and filesystem view.

This is of course a deliberate trade-off. See the security note at the end.

Step 6 — Assign multiple IPv6 addresses to the host NIC

Since every container shares the host’s network stack, handing a container “its own IP” just means giving the host another address and having the container’s service bind to it.

Drop a snippet into /etc/network/interfaces.d/60-extra-ipv6:

iface eth0 inet6 static
    address 2a01:23f:ac09:da45::300:a0/64
iface eth0 inet6 static
    address 2a01:23f:ac09:da45::300:a1/64
iface eth0 inet6 static
    address 2a01:23f:ac09:da45::300:a2/64
iface eth0 inet6 static
    address 2a01:23f:ac09:da45::300:a3/64

Bring the new addresses up with ifup eth0 (or reboot, if you prefer peace of mind). Each of those addresses is now a “slot” you can dedicate to a particular container’s service: bind postfix to ::300:a0, nginx to ::300:a1, and so on.

Because there is only one network namespace in play, you do not need to teach the containers anything about the addresses. The host owns them; containers just bind to them.

Step 7 — Pin the host’s sshd to a single address

Because the container will share the host’s network namespace, the host’s own sshd has to stop listening on the wildcard address :: — otherwise it will grab connections for every IPv6 address on the box and there will be nothing left for the containers to bind to.

First, look up the addresses currently configured on the host:

ip -6 a s dev eth0
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    altname enp1s0
    altname enx92000787bd63
    inet6 2a01:23f:ac09:da45::1/64 scope global 
       valid_lft forever preferred_lft forever
    inet6 fe80::3b78:6122:191d:fd94/64 scope link 
       valid_lft forever preferred_lft forever

Pick the one you want to keep as the host’s administrative entry point (the address you SSH to when you want to reach the host itself, as opposed to a container). Then create a drop-in at /etc/ssh/sshd_config.d/10-listen-ipv6.conf on the host with a matching ListenAddress:

ListenAddress 2a01:23f:ac09:da45::1
ListenAddress 0.0.0.0

To make sshd listen on the single IPv4 address, just add a listener on 0.0.0.0, too.

Reload the service and confirm it is now bound only to that single address:

systemctl reload ssh
ss -tlnp | grep sshd

LISTEN 0      128                         0.0.0.0:22        0.0.0.0:*    users:(("sshd",pid=1224,fd=7))
LISTEN 0      128         [2a01:23f:ac09:da45::1]:22           [::]:*    users:(("sshd",pid=1224,fd=6))

With the host now pinned to ::1, addresses ::300:a0 through ::300:a3 are free for containers to claim.

Step 8 — Install and pin SSH inside the container

With the host’s extra addresses in place, attach to the container and install openssh-server:

virsh -c lxc:/// console test
# log in as root, then:
apt-get update
apt-get install openssh-server

On install, sshd will try to start with its default configuration, which means ListenAddress :: on port 22 — that is, listen on every IPv6 address on the box. Even though the host’s own sshd is now pinned to a single address, :: on the container would still collide with it, and the container’s sshd will fail to start with an “address already in use” error.

The fix is the same trick we just applied on the host: pin the container’s sshd to exactly one of the extra IPv6 addresses. Drop a snippet into /etc/ssh/sshd_config.d/10-listen-ipv6.conf inside the container:

ListenAddress 2a01:23f:ac09:da45::300:a0

Then restart the service:

systemctl restart ssh

Once each sshd is pinned to its own address, they coexist happily on the same port 22 and you can SSH into each container as if it were a standalone machine.

Repeat this step for every container, using a different address from the pool each time.

A word on security

This setup deliberately shares the network namespace between the host and every container. For a single-admin personal VPS that is exactly what I want — the simplicity is the whole point — but it does mean:

  • A process inside any container can sniff traffic on the host NIC.
  • A process inside any container can bind to any host IP and port that is not already taken.
  • Host-level firewall rules are the only network boundary.

If you are running workloads from multiple untrusted users, or containers that process attacker-controlled input in risky ways, you almost certainly want a traditional bridged or macvlan setup with separate network namespaces instead. For my use case — a handful of services I wrote or chose myself — the tradeoff is worth it, and the resulting system is a joy to operate.

Daniel Vogelbacher's Picture

About Daniel Vogelbacher

Hi, I'm Daniel, a software developer, Linux administrator and landscape photographer.

Germany https://chaospixel.com