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
virshis 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/initruns the full systemd stack inside the container, sosystemctl, journald, and timers all work as you would expect.- The
filesystementry 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
listenon one IPv6 address while a web server in another container listens on a different one. - There is no per-container firewall — all
nftablesrules are evaluated in the host’s network namespace, which is the single source of truth. localhostinside the container is the host’slocalhost, 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.