A production guide to hosting a Tor onion service on a single dedicated server using three LXD virtual machines. The source application and database VMs are kept offline behind an internal bridge with no NAT, so they cannot reach (or be reached by) the internet. Only the Tor-facing VM has internet access.
Architecture
┌──────────────────── DEDICATED SERVER (HOST) ────────────────────┐
│ LXD (snap); managed by 'lxdadmin' user (member of 'lxd' group) │
│ │
INTERNET ◄───────────┤ lxdbr0 (10.10.10.1/24, NAT=true) ◄──────────┐ │
(Tor network) │ ▲ │ │
│ ┌──────────┴──────────┐ │ │
│ │ tor-vm │ eth0 → lxdbr0 (internet)│ │
│ │ - Tor (debian-tor) │ │ │
│ │ - Nginx (www-data) │ eth1 → lxdbr-int │ │
│ └──────────┬───────────┘ │ │
│ │ eth1 │ │
│ lxdbr-int (10.10.20.1/24, NAT=false, no uplink) ◄───┘ │
│ ▲ ▲ │
│ ┌──────────┴──────────┐ ┌──────────┴──────────┐ │
│ │ source-vm │ │ db-vm │ │
│ │ - app :8080 │◄───►│ - MariaDB :3306 │ │
│ │ (appuser) │ SQL │ (mysql sys user; │ │
│ │ eth0 → lxdbr-int │ │ appdb SQL user) │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ source-vm & db-vm have NO route to the internet (offline) │
└─────────────────────────────────────────────────────────────────┘
Traffic: Tor client → tor-vm onion:80 → Nginx 127.0.0.1:80 → proxy_pass → source-vm:8080 → db-vm:3306
Data flow
- A Tor Browser client connects to the onion address.
- Tor on
receives the request and forwards virtual port 80 totor-vm
(Nginx, loopback only).127.0.0.1:80 - Nginx on
reverse-proxies totor-vm
(the source app on the internal bridge).http://10.10.20.10:8080 - The source app queries MariaDB at
(also on the internal bridge).10.10.20.20:3306 - Neither
norsource-vm
can initiate outbound connections to the internet —db-vm
haslxdbr-int
and no external uplink, so return traffic never arrives.ipv4.nat=false
VM inventory
VM
Image
Bridge(s)
Static IP(s)
Runs
tor-vm
images:debian/trixie
lxdbr0 + lxdbr-int
10.10.10.100 (eth0), 10.10.20.30 (eth1)
Tor daemon, Nginx reverse proxy
source-vm
images:debian/trixie
lxdbr-int only
10.10.20.10 (eth0)
Python web app on :8080
db-vm
images:debian/trixie
lxdbr-int only
10.10.20.20 (eth0)
MariaDB on :3306
User separation
Component
OS user
Purpose
Host LXD management
lxdadmin (added to lxd group)
Run
lxc / lxc console without roottor-vm: Tor daemon
debian-tor (package default)
Runs
tor; owns /var/lib/tor/* keystor-vm: Nginx
www-data (package default)
Reverse proxy on
127.0.0.1:80source-vm: web app
appuser (created)
Runs the app on :8080; no root, no sudo
db-vm: MariaDB daemon
mysql (package default)
Runs
mariadbddb-vm: app SQL user
appdb (created)
Limited to
SELECT,INSERT,UPDATE,DELETE on appdb.*1. Prerequisites
- One dedicated server running Debian 12+ or Ubuntu 22.04+ on the host.
- Root or sudo access on the host.
- KVM support (hardware virtualization) — LXD VMs require it. Verify:
ls -la /dev/kvm- If
exists, you're good. If not, enable VT-x/AMD-V in the server's BIOS./dev/kvm - At least 20 GB free disk (ZFS loopback + 3 VMs) and 2 GB RAM minimum.
- The host must have a working internet connection for the build phase.
2. Host setup
All commands in this section run on the host (the dedicated server).
2.1 Install LXD
sudo apt update
sudo apt install -y snapd
sudo snap install lxdVerify:
lxd --version2.2 Initialize LXD with a preseed file
This creates a ZFS loopback storage pool and the
lxdbr0 bridge (NAT, DHCP, internet-facing) in one non-interactive step.cat <<'EOF' | sudo lxd init --preseed
config: {}
networks:
- name: lxdbr0
type: bridge
config:
ipv4.address: 10.10.10.1/24
ipv4.nat: true
ipv4.dhcp: true
ipv6.address: none
storage_pools:
- name: default
driver: zfs
profiles:
- name: default
config: {}
devices:
root:
path: /
pool: default
type: disk
eth0:
name: eth0
network: lxdbr0
type: nic
projects: []
cluster: null
EOFNote: Creatingwith an IPv4 subnet enableslxdbr0host-wide. This is expected and required sonet.ipv4.ip_forward=1can reach the internet. The internal bridge (tor-vm) is created separately below with NAT disabled, which keeps its VMs offline.lxdbr-int
Verify the default bridge:
lxc network show lxdbr02.3 Create the lxdadmin user
Create a non-root user for day-to-day LXD management. Membership in the
lxd group lets this user run lxc commands (including lxc console) without sudo. We also add the user to the sudo group so it can install host-level packages (e.g., torsocks, ufw) later.sudo adduser lxdadmin
sudo usermod -aG lxd lxdadmin
sudo usermod -aG sudo lxdadminSwitch to the new user and confirm LXD works (group membership requires a fresh login shell):
sudo -u lxdadmin -i
lxc listFrom this point on, run allcommands aslxc.lxdadmincommands work withoutlxc(via thesudogroup); host-level commands likelxdstill needapt install.sudo
2.4 Create the isolated internal bridge
lxdbr-int is the offline bridge. Key properties:
— no SNAT, so VMs on this bridge cannot reach the internet even though the host forwards packets.ipv4.nat=false
— disables IPv6 on the bridge entirely.ipv6.address=none
— LXD generates iptables/nftables rules to restrict the bridge.ipv4.firewall=true
lxc network create lxdbr-int --type=bridge \
ipv4.address=10.10.20.1/24 \
ipv4.nat=false \
ipv4.dhcp=true \
ipv4.firewall=true \
ipv6.address=noneVerify:
lxc network show lxdbr-int
lxc network lsYou should see both
lxdbr0 and lxdbr-int.3. Launch the three VMs (build phase)
During the build phase, all three VMs are attached to
lxdbr0 (the internet-facing bridge) so they can run apt update / apt install. After software is installed, source-vm and db-vm will be moved to the offline bridge in Section 7.Run these as
lxdadmin:lxc launch images:debian/trixie tor-vm --vm
lxc launch images:debian/trixie source-vm --vm
lxc launch images:debian/trixie db-vm --vmWait for cloud-init to finish on each VM (the LXD agent and network come up during this time). Poll until each returns a state of
RUNNING and an IPv4 on lxdbr0:lxc listExample output:
+-----------+---------+---------------------+--------------------------------------------+-----------------+-----------+
| NAME | STATE | IPV4 | TYPE | SNAPSHOTS | LOCATION |
+-----------+---------+---------------------+--------------------------------------------+-----------------+-----------+
| db-vm | RUNNING | 10.10.10.32 (eth0) | VIRTUAL-MACHINE | 0 | none |
+-----------+---------+---------------------+--------------------------------------------+-----------------+-----------+
| source-vm | RUNNING | 10.10.10.51 (eth0) | VIRTUAL-MACHINE | 0 | none |
+-----------+---------+---------------------+--------------------------------------------+-----------------+-----------+
| tor-vm | RUNNING | 10.10.10.74 (eth0) | VIRTUAL-MACHINE | 0 | none |
+-----------+---------+---------------------+--------------------------------------------+-----------------+-----------+The exact DHCP IPs ondon't matter during build — we'll assign static IPs onlxdbr0later.lxdbr-int
Update packages inside each VM (still on the internet-facing bridge) and install basic utilities used later in the guide:
for vm in tor-vm source-vm db-vm; do
lxc exec "$vm" -- apt update
lxc exec "$vm" -- apt -y upgrade
lxc exec "$vm" -- apt install -y curl netcat-openbsd
done4. DB VM setup (MariaDB)
All commands in this section run inside
db-vm via lxc exec.4.1 Install MariaDB
lxc exec db-vm -- apt install -y mariadb-serverThe MariaDB daemon runs as the
mysql system user (created automatically by the package). Verify:lxc exec db-vm -- ps -o user,pid,cmd -C mariadbd4.2 Bind MariaDB to the internal bridge IP
Later (Section 7)
db-vm will have the static IP 10.10.20.20 on lxdbr-int. Configure MariaDB to listen only on that address now, so it never binds to a public interface.Edit
/etc/mysql/mariadb.conf.d/50-server.cnf inside the VM:lxc exec db-vm -- bash -c 'sed -i "s/^bind-address.*/bind-address = 10.10.20.20/" /etc/mysql/mariadb.conf.d/50-server.cnf'If the
bind-address line is commented out (#bind-address = 127.0.0.1) or missing, uncomment/add it. Verify the result:lxc exec db-vm -- grep -E '^bind-address' /etc/mysql/mariadb.conf.d/50-server.cnfExpected:
bind-address = 10.10.20.20MariaDB will fail to start right now because
10.10.20.20 isn't assigned yet — that's fine, it'll start after Section 7 assigns the IP. For the build phase, start it on the loopback temporarily so we can create the SQL user:lxc exec db-vm -- bash -c 'sed -i "s/^bind-address.*/bind-address = 127.0.0.1/" /etc/mysql/mariadb.conf.d/50-server.cnf'
lxc exec db-vm -- systemctl restart mariadb