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.
| Choose | When |
|---|---|
| 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.
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.zstCreate 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 1120is the container ID — pick any free one. A static address is usually better than DHCP for something an MX record points at; substituteip=192.168.1.20/24,gw=192.168.1.1forip=dhcpif you have one to hand.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
/procviews 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:
$ pct stop 120$ pct set 120 --features nesting=1,keyctl=1$ pct start 120 # Confirm$ pct config 120 | grep features--unprivileged 1. The container’s root maps to an unprivileged host UID, which is the boundary you actually want around a service that accepts connections from strangers. Nothing in this guide needs a privileged container.Docker inside the container
Install from Docker’s own repository rather than Debian’s, which lags well behind.
$ 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 psnesting. Check journalctl -u docker --no-pager -n 50 inside the container; a cgroup or mount error there means the feature flag did not take. Verify with pct config 120 | grep features on the host.Deploy BurnerByte
From here it is the ordinary Docker path. Everything in the Docker Compose guide applies unchanged — this is the condensed form.
$ 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 --buildBuilding 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.
| Hop | What to do |
|---|---|
| Router → container | Forward 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 firewall | The 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. |
| ISP | Residential 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. |
# 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.comStorage 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:
# 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.docker compose up, or stop Docker and move the existing contents across first. Mounting over a directory that already has volumes in it hides them.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.
# 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--mode snapshot captures the filesystem while Postgres is mid-write. It restores, and it is almost always fine, but “almost always” is not a backup policy for the store holding every account on the platform. Take a pg_dump on a schedule as well — the Docker guide has the command. Use --mode suspend if you want the filesystem copy to be genuinely consistent and can accept the pause.Sizing and tuning
| Resource | Guidance |
|---|---|
| Cores | 2 is enough to run it. 4 makes first-build and image rebuilds noticeably less painful. |
| Memory | 4 GB with the bundled Postgres, Redis and MinIO. Postgres is the consumer; against a managed database 2 GB is comfortable. |
| Swap | 2 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. |
| Rootfs | 24 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:
$ pct set 120 --memory 8192 --cores 4Troubleshooting
| Symptom | Cause |
|---|---|
| Docker daemon will not start | nesting=1 missing. Set it on the stopped container and restart. |
| Postgres crashes on first start | keyctl=1 missing. The kernel keyring syscalls are blocked, and the resulting errors do not mention it. |
| Builds get OOM-killed | The 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 container | Docker is publishing 2525 by default. Set SMTPD_PORT=25 in .env and docker compose up -d smtpd. |
| Reachable from the LAN, not the internet | Router port-forward or the Proxmox firewall, in that order of likelihood. |
| Container clock drifts | An 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.