Skip to content
BurnerByte

Proxmox VE LXC

An LXC container is the cheapest sensible home for this: a couple of hundred megabytes of overhead instead of a kilobyte-for-kilobyte virtual machine, snapshots that take a second, and a backup story Proxmox already solved.

BurnerByte ships no Proxmox-specific tooling, and this is not a helper script. It is the sequence that works, with the two container features Docker needs and the reasons they are needed — because an LXC that silently lacks them produces failures that look like Docker bugs.

LXC or VM?

Docker inside an unprivileged LXC is a supported, widely-run configuration, and it is what this guide describes. It is not the only defensible choice.

ChooseWhen
LXC (this guide)You want low overhead, instant snapshots and shared page cache with the host. Fine for a homelab and for most small teams.
VM (qemu)You want a kernel boundary between this workload and the host — a mail server does accept unauthenticated connections from strangers. Also the right call if you plan to run Docker Swarm or Kubernetes inside.

If you pick the VM, stop reading here and follow the Docker Compose guide inside it — there is nothing Proxmox-specific left to do.

Create the container

From the Proxmox host shell. Download a Debian 13 template first if you have not already.

  1. Fetch a template

    on the Proxmox host
    $ pveam update$ pveam available --section system | grep debian-13$ pveam download local debian-13-standard_13.1-1_amd64.tar.zst
  2. Create the container

    on the Proxmox host
    $ pct create 120 local:vztmpl/debian-13-standard_13.1-1_amd64.tar.zst \    --hostname burnerbyte \    --cores 2 \    --memory 4096 \    --swap 2048 \    --rootfs local-lvm:24 \    --net0 name=eth0,bridge=vmbr0,ip=dhcp,firewall=1 \    --features nesting=1,keyctl=1 \    --unprivileged 1 \    --onboot 1 \    --start 1

    120 is the container ID — pick any free one. A static address is usually better than DHCP for something an MX record points at; substitute ip=192.168.1.20/24,gw=192.168.1.1 for ip=dhcp if you have one to hand.

  3. Get a shell inside

    bash
    $ pct enter 120 # Then, inside the container:$ apt update && apt upgrade -y$ apt install -y curl ca-certificates git gnupg

Nesting and keyctl, and why both

The --features nesting=1,keyctl=1 flag above is the part people skip, and the failures it causes do not point at it.

  • nesting=1 lets the container mount the cgroup and /proc views a container runtime needs. Without it, the Docker daemon fails to start or starts and cannot run anything.
  • keyctl=1 exposes the kernel keyring syscalls. On an unprivileged LXC these are blocked by default, and Postgres in particular will fail on startup in ways that read as data corruption rather than a missing capability.

Already created the container without them? Set them on the stopped container:

on the Proxmox host
$ pct stop 120$ pct set 120 --features nesting=1,keyctl=1$ pct start 120 # Confirm$ pct config 120 | grep features
Unprivileged is the right default

Docker inside the container

Install from Docker’s own repository rather than Debian’s, which lags well behind.

inside the container
$ install -m 0755 -d /etc/apt/keyrings$ curl -fsSL https://download.docker.com/linux/debian/gpg \    -o /etc/apt/keyrings/docker.asc$ chmod a+r /etc/apt/keyrings/docker.asc $ echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \  > /etc/apt/sources.list.d/docker.list $ apt update$ apt install -y docker-ce docker-ce-cli containerd.io \    docker-buildx-plugin docker-compose-plugin # Should print a version and an empty container list, not a daemon error.$ docker compose version$ docker ps
If the daemon will not start

Deploy BurnerByte

From here it is the ordinary Docker path. Everything in the Docker Compose guide applies unchanged — this is the condensed form.

inside the container
$ git clone https://github.com/AmJaradat01/burnerbyte.git /opt/burnerbyte$ cd /opt/burnerbyte$ cp .env.example .env $ echo "JWT_SECRET=$(openssl rand -hex 32)" >> .env$ echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env # Real values for the rest$ cat >> .env <<'ENV'SMTPD_PORT=25SMTP_HOSTNAME=mail.example.comFRONTEND_URL=https://mail.example.comAPI_BASE_URL=https://api.example.comWS_BASE_URL=wss://api.example.comPOSTGRES_PASSWORD=<generate one>REDIS_PASSWORD=<generate one>MINIO_ROOT_USER=<generate one>MINIO_ROOT_PASSWORD=<generate one>ENV $ docker compose up -d --build

Building the images inside a 2-core container takes a while on first run — the Go binaries and the Next.js bundle are both compiled from source. Give it ten to fifteen minutes and watch with docker compose logs -f.

Start it on host boot

--onboot 1 on the container plus restart: unless-stopped in the Compose file (already there) is the whole arrangement. Nothing else is needed — no systemd unit, no cron.

Networking and port 25

With a bridged interface the container has its own address on your LAN, so the ports it publishes are reachable directly. Three things still have to line up.

HopWhat to do
Router → containerForward TCP 25 from your public address to the container’s. Forward 80 and 443 too if the reverse proxy lives here rather than on a separate box.
Proxmox firewallThe container was created with firewall=1. Either add allow rules for 25, 80 and 443 under Datacenter → Firewall, or drop the flag. Rules are silently dropping traffic if the firewall is on with an empty ruleset.
ISPResidential ISPs block inbound 25 more often than not, and it is not negotiable from your side. Test from outside before building anything on the assumption that it works.
from outside your network
# Is 25 reachable at all?$ nc -vz your-public-ip 25 # What does your MX actually resolve to?$ dig +short MX your-domain.com$ dig +short A mail.example.com
Reverse DNS decides whether anyone trusts you

Storage layout

Everything stateful lives in Docker volumes inside the container’s rootfs by default, which means one Proxmox backup captures the whole thing. That is the simple arrangement and it is fine.

If attachment volume is going to be significant, give it its own mount point instead, so the rootfs stays small and snapshots stay fast:

on the Proxmox host
# A 100 GB volume mounted at /var/lib/docker/volumes inside the container$ pct set 120 --mp0 local-lvm:100,mp=/var/lib/docker/volumes,backup=1 # backup=0 instead if you would rather back attachments up separately —# a 100 GB mount point in every vzdump makes for slow, large backups.
Note

Snapshots and backups

The reason to run this in Proxmox rather than on bare metal. Snapshot before every upgrade; it costs a second and turns a failed migration into a rollback.

on the Proxmox host
# Before upgrading$ pct snapshot 120 pre-upgrade --description "before git pull" # It went wrong$ pct rollback 120 pre-upgrade # Scheduled backup — Datacenter → Backup in the UI, or:$ vzdump 120 --storage local --mode snapshot --compress zstd --mailnotification failure
A snapshot is not a database backup

Sizing and tuning

ResourceGuidance
Cores2 is enough to run it. 4 makes first-build and image rebuilds noticeably less painful.
Memory4 GB with the bundled Postgres, Redis and MinIO. Postgres is the consumer; against a managed database 2 GB is comfortable.
Swap2 GB. LXC swap comes from the host, so this is cheap insurance against an OOM kill during an image build rather than something you want used steadily.
Rootfs24 GB covers images, the database and a modest attachment history. Attachments are the variable — size for the retention you intend, or give them a mount point.

Resources on a container can be changed live, without a restart, which is the other reason LXC suits this workload:

on the Proxmox host
$ pct set 120 --memory 8192 --cores 4

Troubleshooting

SymptomCause
Docker daemon will not startnesting=1 missing. Set it on the stopped container and restart.
Postgres crashes on first startkeyctl=1 missing. The kernel keyring syscalls are blocked, and the resulting errors do not mention it.
Builds get OOM-killedThe Next.js build is the memory peak. Raise memory to 6–8 GB for the build and drop it back afterwards, or build the images elsewhere and pull them.
Port 25 refuses inside the containerDocker is publishing 2525 by default. Set SMTPD_PORT=25 in .env and docker compose up -d smtpd.
Reachable from the LAN, not the internetRouter port-forward or the Proxmox firewall, in that order of likelihood.
Container clock driftsAn LXC shares the host clock. Fix NTP on the Proxmox host, not inside the container — a skewed clock expires JWTs early and makes TTL countdowns lie.

Next: the DNS records that decide whether any of this ever receives a message.