DA CentOS 7 → Debian 12 on Proxmox Part 1 — NAT pre-stage
My customer's main server had been running CentOS 7 for almost ten years. It reached EOL in mid-2024, and I put off the migration for quite a while before getting to it. The migration is now done, and the server has been stable in production for almost a month, so I'm writing up the approach I used.
My customer's main server had been running CentOS 7 for almost ten years. It reached EOL back in mid-2024, and I had put this off for quite a while. There are over 80 users on this machine, all waiting to move to a new OS. I finished the work late last month. The server is stable now, with almost a month in production and no problems so far.
In this series, I'll walk through the entire migration from start to finish. For this first part, I'll start with NAT pre-stage, which is central to reducing downtime.
The idea: prepare the new VM before shutting down the old one
The principle is simple. Instead of shutting down the old VM and then building a new one (which would leave customers waiting for hours), I chose to build the new VM on a separate internal bridge, in a private subnet that does not conflict with the old machine's public IP.
While I prepare the new VM, the old VM stays online and serves customers as usual. It has no idea another machine is being prepared alongside it. Once the system is ready and the process is nearly complete, I can swap the NICs in a short window.
I'll be upfront here — I initially expected this technique to bring customer downtime close to zero. But in practice, I hit two obstacles: the DA Legacy license and slow qcow2 reads. Actual downtime ended up being about 1 hour. I'll cover the details in full at the end; for now, I'll focus on the preparation.
The 2-bridge layout
My network layout uses two bridges on the Proxmox host: one public bridge and one internal bridge with outbound NAT.
public bridge
- PVE host + old VM connect here
- Uses public IPs 203.0.113.x
- Customers connect through here as usual
internal bridge
- private 192.168.10.0/24
- NAT egress through vmbr0
- The new VM stays here during preparation
The key is that the new VM connects to vmbr1, so it never conflicts with the old VM's public IP. This lets both machines run in parallel on the same host.
Configure networking on PVE — /etc/network/interfaces
I configure vmbr0 as the public bridge connected to the host's physical NIC. vmbr1 is a standalone bridge with no physical port, but it serves as the private subnet's gateway, with MASQUERADE through vmbr0.
auto vmbr0
iface vmbr0 inet static
address 203.0.113.41/29
gateway 203.0.113.1
bridge-ports eno1
bridge-stp off
bridge-fd 0
auto vmbr1
iface vmbr1 inet static
address 192.168.10.254/24
bridge-ports none
bridge-stp off
bridge-fd 0
post-up iptables -t nat -A POSTROUTING -s 192.168.10.0/24 -o vmbr0 -j MASQUERADE
post-up iptables -A FORWARD -s 192.168.10.0/24 -j ACCEPT
post-up iptables -A FORWARD -d 192.168.10.0/24 -j ACCEPT
post-down iptables -t nat -D POSTROUTING -s 192.168.10.0/24 -o vmbr0 -j MASQUERADE
post-down iptables -D FORWARD -s 192.168.10.0/24 -j ACCEPT
post-down iptables -D FORWARD -d 192.168.10.0/24 -j ACCEPT
bridge-ports none is the key here. It tells vmbr1 not to connect to any physical NIC; it is entirely internal to the host. The MASQUERADE rule rewrites the source address of every packet leaving 192.168.10.0/24 to the host's IP so it can go out through vmbr0 — the new VM can therefore access the internet normally and download packages to install, even without a public IP of its own.
Then I bring it up with ifup vmbr1 and check that the bridge is up and the NAT rule is in place.
ifup vmbr1
ip a show vmbr1
iptables -t nat -L POSTROUTING -n | grep MASQ
If address 192.168.10.254 appears on vmbr1 and there is a MASQUERADE line in the POSTROUTING chain, the network side is ready.
SSH access to the new VM through PVE
Now that the new VM is in a private subnet, I cannot ssh directly to it from outside. I need DNAT forwarding through the host. I choose a port on the host (let's call it <fwd-port>) and point it to port 22 on the new VM.
iptables -t nat -A PREROUTING -p tcp --dport <fwd-port> -j DNAT --to 192.168.10.20:22
iptables -A FORWARD -p tcp -d 192.168.10.20 --dport 22 -j ACCEPT
netfilter-persistent save
netfilter-persistent save matters here; otherwise, the rules disappear on reboot. Then I test from my workstation.
ssh -p <fwd-port> [email protected]
If everything is correct, I can connect through NAT forwarding straight to the new VM's shell, even though it still has no public IP of its own.
Create the new VM on vmbr1
The next step is to create the VM and connect it directly to vmbr1. I use qm create with the specifications the customer needs.
qm create 101 \
--name newserver \
--memory 60000 \
--cores 16 \
--machine q35 \
--bios seabios \
--scsihw virtio-scsi-single \
--net0 virtio,bridge=vmbr1 \
--scsi0 VMDATA:900,discard=on,ssd=1,iothread=1
The --net0 ...,bridge=vmbr1 part connects the VM to the internal bridge, placing it in the private subnet from the start.
There are 4 gotchas at this step that I've run into before, so here is what to watch for.
1) Do not add viommu=on — the installer will hit a kernel panic on Debian 12 + q35. It took me 2 hours to identify this as the cause. 2) Use q35, not i440fx — it provides modern PCIe and better ZFS performance. 3) The NIC name on Debian 12 + q35 + virtio is enp6s18, not ens18 as on Debian 13. Anyone used to ens18 will get confused. 4) Set virtio queues=8 later to use all vCPUs concurrently.
I configure virtio multiqueue later with this command.
qm set 101 --net0 virtio,bridge=vmbr1,queues=8
Setting queues equal to the vCPU count helps spread traffic processing across cores instead of piling it onto a single core at high throughput.
Install Debian 12 minimal
I boot from the ISO and choose a minimal installation. I select only SSH server and standard system utilities. There is no need to install a desktop or any unnecessary services; CustomBuild will install what is needed later.
For partitions, I use roughly this layout.
I make /home the largest because the data for all 80-plus users will live there.
Quota mount option — easy to miss
This is critical for DirectAdmin: the / and /home partitions need quota mount options.
usrjquota=aquota.user,grpjquota=aquota.group,jqfmt=vfsv0
I chose vfsv0 even though newer DA docs recommend vfsv1, because vfsv0 is compatible with the existing CentOS 7 quota file format. When restoring customer quotas, the restore fails with vfsv1. I spent time trying it once before switching back to vfsv0.
Install base packages
After installing the OS, I first install the base packages that DA and CustomBuild need.
apt update && apt install -y \
openssh-server curl wget quota iptables chrony \
perl python3 sudo rsync bzip2 \
netfilter-persistent
systemctl enable --now chrony
chronyc sources -v
The system clock must be accurate because DA license validation also checks the time. I once forgot to configure chrony, and license activation failed because the time skew was too large. I spent a long time troubleshooting before realizing it was the clock.
The benefit: all preparation happens without affecting customers
This is the real value of NAT pre-stage — I can perform every preparation step below on the new VM while customers continue using the old machine.
- Debian 12 install + base packages — around 30 minutes
- DA install + license activate — around 10 minutes (the old DA panel goes offline for just 1 minute, but customer services keep running normally)
- CustomBuild stack — around 2 to 2.5 hours
- custom data prep (DNSSEC keys, hooks, templates) — around 30 minutes
- firewall config staging — around 10 minutes
- pre-cutover smoke test — around 30 minutes
That adds up to around 3 to 4 hours of preparation — customers are completely unaffected during this time because everything happens on a parallel machine tucked away in a private subnet.
But the cutover still takes time, not zero
I initially hoped downtime would be close to zero, but that did not happen in practice because of two obstacles.
DA Legacy license
- Uses last DA wins behavior
- Cannot run two DA instances at once
- Must stop the old DA before activating the new one
Slow qcow2 reads
- DA backup on the old machine takes hours
- The main bottleneck during cutover
- This is why ZFS comes into play in the next part
Together, these two issues brought actual customer downtime to about 1 hour, instead of the zero I had initially aimed for. I'll cover the full cutover details at the end of the series.
Next part
In the next part, I'll cover storage — I'll remove the old mdadm setup and replace it with a ZFS mirror, all while the old VM remains online and serves customers.
Feel free to adjust the subnet, port, or specifications to suit your own machine. The principle is the same — get the new machine ready first, then swap over later to minimize the impact on customers. If I think of anything else or find something that needs correcting, I'll update this article.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first