General

Hosting a Tor Hidden Service with LXD Virtual Machines

Started by Xmrdotfail · Jul 10, 2026

#688
Hosting a Tor Hidden Service with LXD Virtual Machines

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
    tor-vm
    receives the request and forwards virtual port 80 to
    127.0.0.1:80
    (Nginx, loopback only).
  • Nginx on
    tor-vm
    reverse-proxies to
    http://10.10.20.10:8080
    (the source app on the internal bridge).
  • The source app queries MariaDB at
    10.10.20.20:3306
    (also on the internal bridge).
  • Neither
    source-vm
    nor
    db-vm
    can initiate outbound connections to the internet —
    lxdbr-int
    has
    ipv4.nat=false
    and no external uplink, so return traffic never arrives.


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 root

tor-vm: Tor daemon

debian-tor (package default)

Runs
tor
; owns
/var/lib/tor/*
keys

tor-vm: Nginx

www-data (package default)

Reverse proxy on
127.0.0.1:80


source-vm: web app

appuser (created)

Runs the app on :8080; no root, no sudo

db-vm: MariaDB daemon

mysql (package default)

Runs
mariadbd


db-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
    /dev/kvm
    exists, you're good. If not, enable VT-x/AMD-V in the server's BIOS.
  • 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 lxd


Verify:

lxd --version


2.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
EOF


Note: Creating
lxdbr0
with an IPv4 subnet enables
net.ipv4.ip_forward=1
host-wide. This is expected and required so
tor-vm
can reach the internet. The internal bridge (
lxdbr-int
) is created separately below with NAT disabled, which keeps its VMs offline.


Verify the default bridge:

lxc network show lxdbr0


2.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 lxdadmin


Switch to the new user and confirm LXD works (group membership requires a fresh login shell):

sudo -u lxdadmin -i
lxc list


From this point on, run all
lxc
commands as
lxdadmin
.
lxc
commands work without
sudo
(via the
lxd
group); host-level commands like
apt install
still need
sudo
.


2.4 Create the isolated internal bridge

lxdbr-int
is the offline bridge. Key properties:

  • ipv4.nat=false
    — no SNAT, so VMs on this bridge cannot reach the internet even though the host forwards packets.
  • ipv6.address=none
    — disables IPv6 on the bridge entirely.
  • ipv4.firewall=true
    — LXD generates iptables/nftables rules to restrict the bridge.


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=none


Verify:

lxc network show lxdbr-int
lxc network ls


You 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 --vm


Wait 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 list


Example 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 on
lxdbr0
don't matter during build — we'll assign static IPs on
lxdbr-int
later.


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
done


4. 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-server


The MariaDB daemon runs as the
mysql
system user (created automatically by the package). Verify:

lxc exec db-vm -- ps -o user,pid,cmd -C mariadbd


4.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.cnf


Expected:

bind-address = 10.10.20.20


MariaDB 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
#689
↳ Replying to @Xmrdotfail
4.3 Create the limited-privilege application database user

Run the
mariadb
client as root inside the VM. Replace
CHANGE_ME_DB_PASSWORD
with a strong password.

lxc exec db-vm -- mariadb <<'SQL'
CREATE DATABASE IF NOT EXISTS appdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- Application user: can only touch appdb.*, from the source-vm IP only.
CREATE USER 'appdb'@'10.10.20.10' IDENTIFIED BY 'CHANGE_ME_DB_PASSWORD';

-- Minimal privileges: no FILE, no SUPER, no GRANT OPTION, no DDL.
GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'appdb'@'10.10.20.10';

FLUSH PRIVILEGES;
SQL


Verify the grants:

lxc exec db-vm -- mariadb -e "SHOW GRANTS FOR 'appdb'@'10.10.20.10';"


Expected:

+--------------------------------------------------------------------------------------------------------------------+
| Grants for appdb@10.10.20.10 |
+--------------------------------------------------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO `appdb`@`10.10.20.10` IDENTIFIED BY PASSWORD '*' |
| GRANT SELECT, INSERT, UPDATE, DELETE ON `appdb`.* TO `appdb`@`10.10.20.10` |
+--------------------------------------------------------------------------------------------------------------------+


Create a sample table so the app has something to query:

lxc exec db-vm -- mariadb appdb <<'SQL'
CREATE TABLE IF NOT EXISTS visits (
id INT AUTO_INCREMENT PRIMARY KEY,
visited_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
remote_host VARCHAR(64) NOT NULL
);
INSERT INTO visits (remote_host) VALUES ('bootstrap');
SQL


4.4 Switch bind-address back to the internal IP

Now restore the production bind address (it will take effect after the VM moves to
lxdbr-int
in Section 7):

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'
lxc exec db-vm -- systemctl stop mariadb


5. Source VM setup (Python web app)

All commands in this section run inside
source-vm
via
lxc exec
.

5.1 Create the appuser system user

lxc exec source-vm -- useradd --system --create-home --shell /usr/sbin/nologin appuser


--system
assigns a low UID (no login shell).
--shell /usr/sbin/nologin
prevents interactive logins as
appuser
. The app will be launched by systemd, not by a user shell.


5.2 Install Python and the MySQL connector

lxc exec source-vm -- apt install -y python3 python3-pip
lxc exec source-vm -- pip3 install --break-system-packages mysql-connector-python


The
--break-system-packages
flag is required on Debian 12+ where PEP 668 protects the system Python environment. If you prefer isolation, use a
venv
instead (see troubleshooting, Section 10).


5.3 Create the application script

Write the app to
/opt/app/app.py
. It serves a simple HTTP page on port
8080
and records each visit in MariaDB.

lxc exec source-vm -- bash -c 'mkdir -p /opt/app'
lxc exec source-vm -- tee /opt/app/app.py >/dev/null <<'PY'
#!/usr/bin/env python3
"""Minimal web app for the Tor hidden-service guide.

Listens on 0.0.0.0:8080, connects to MariaDB at 10.10.20.20:3306,
inserts a row per request, and returns the visit count as HTML.
"""
from http.server import BaseHTTPRequestHandler, HTTPServer
import mysql.connector

DB_HOST = "10.10.20.20"
DB_PORT = 3306
DB_USER = "appdb"
DB_PASS = "CHANGE_ME_DB_PASSWORD"
DB_NAME = "appdb"

class Handler(BaseHTTPRequestHandler):
def do_GET(self):
try:
conn = mysql.connector.connect(
host=DB_HOST, port=DB_PORT,
user=DB_USER, password=DB_PASS, database=DB_NAME,
)
cur = conn.cursor()
cur.execute("INSERT INTO visits (remote_host) VALUES (%s)", ("tor-vm",))
conn.commit()
cur.execute("SELECT COUNT(*) FROM visits")
(count,) = cur.fetchone()
cur.close()
conn.close()
body = f"<html><body><h1>Hello from the onion!</h1><p>Visits: {count}</p></body></html>\n".encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
except Exception as e:
body = f"<html><body><h1>DB error</h1><pre>{e}</pre></body></html>\n".encode()
self.send_response(500)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

if __name__ == "__main__":
HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
PY
lxc exec source-vm -- chmod 755 /opt/app/app.py
lxc exec source-vm -- chown -R appuser:appuser /opt/app


Important: Replace
CHANGE_ME_DB_PASSWORD
with the same password you set in Section 4.3. Since
source-vm
will be offline after lockdown, this file is only reachable from the host via
lxc exec
or
lxc file
.


5.4 Create a systemd unit to run the app as appuser

lxc exec source-vm -- tee /etc/systemd/system/app.service >/dev/null <<'UNIT'
[Unit]
Description=Hidden-service demo web app
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=appuser
Group=appuser
ExecStart=/usr/bin/python3 /opt/app/app.py
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/opt/app

[Install]
WantedBy=multi-user.target
UNIT


The
NoNewPrivileges
,
ProtectSystem
,
ProtectHome
,
PrivateTmp
, and
ReadWritePaths
directives harden the service: even if the app is compromised,
appuser
cannot gain root or write outside
/opt/app
.

Enable and start it (it will fail to connect to the DB until Section 7 moves
db-vm
to
10.10.20.20
, but the service itself should start):

lxc exec source-vm -- systemctl daemon-reload
lxc exec source-vm -- systemctl enable --now app.service
lxc exec source-vm -- systemctl status app.service


5.5 Verify the app listens on 8080

lxc exec source-vm -- ss -tlnp | grep 8080


Expected (the PID will differ):

LISTEN 0  5  0.0.0.0:8080  0.0.0.0:*  users:(("python3",pid=1234,fd=3))
#690
↳ Replying to @Xmrdotfail
6. Tor VM setup (Nginx reverse proxy + Tor hidden service)

All commands in this section run inside
tor-vm
via
lxc exec
.

6.1 Install Nginx

lxc exec tor-vm -- apt install -y nginx


Nginx runs as
www-data
(the Debian package default). Verify:

lxc exec tor-vm -- ps -o user,pid,cmd -C nginx


6.2 Configure Nginx as a reverse proxy

Nginx will listen on
127.0.0.1:80
only (loopback — never on the bridge interfaces) and proxy to the source app at
10.10.20.10:8080
.

Remove the default site and create the hidden-service site:

lxc exec tor-vm -- rm -f /etc/nginx/sites-enabled/default
lxc exec tor-vm -- tee /etc/nginx/sites-available/hidden-service >/dev/null <<'NGINX'
server {
listen 127.0.0.1:80;
server_name _;

access_log /var/log/nginx/hidden-service-access.log;
error_log /var/log/nginx/hidden-service-error.log;

location / {
proxy_pass http://10.10.20.10:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
}
}
NGINX
lxc exec tor-vm -- ln -s /etc/nginx/sites-available/hidden-service /etc/nginx/sites-enabled/hidden-service
lxc exec tor-vm -- nginx -t
lxc exec tor-vm -- systemctl reload nginx


Verify Nginx is listening on loopback only:

lxc exec tor-vm -- ss -tlnp | grep ':80 '


Expected:

LISTEN 0  511  127.0.0.1:80  0.0.0.0:*  users:(("nginx",...))


The proxy will return a 502 until
source-vm
is moved to
10.10.20.10
in Section 7. That's expected.


6.3 Install Tor from the Tor Project APT repository

The Debian/Ubuntu
tor
package is often stale. Use the Tor Project's official repository for timely security updates.

Prerequisite:
tor-vm
must still be on
lxdbr0
(internet) for this step.

# 1. Install prerequisites for the HTTPS APT repo.
lxc exec tor-vm -- apt install -y apt-transport-https gnupg wget

# 2. Import the Tor Project signing key into the keyring.
lxc exec tor-vm -- bash -c 'wget -qO- https://deb.torproject.org/torproject.org/A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89.asc | gpg --dearmor | tee /usr/share/keyrings/deb.torproject.org-keyring.gpg >/dev/null'

# 3. Add the deb822-format sources entry for Debian trixie.
lxc exec tor-vm -- tee /etc/apt/sources.list.d/tor.sources >/dev/null <<'SOURCES'
Types: deb
URIs: https://deb.torproject.org/torproject.org/
Suites: trixie
Components: main
Signed-By: /usr/share/keyrings/deb.torproject.org-keyring.gpg
SOURCES

# 4. Install tor and the keyring package (keeps the signing key current).
lxc exec tor-vm -- apt update
lxc exec tor-vm -- apt install -y tor deb.torproject.org-keyring


Verify the Tor version and that it's running:

lxc exec tor-vm -- tor --version
lxc exec tor-vm -- systemctl status tor


The Tor package on Debian runs the daemon as the
debian-tor
system user. Confirm:

lxc exec tor-vm -- ps -o user,pid,cmd -C tor


6.4 Configure the hidden service

Edit
/etc/tor/torrc
. The
HiddenServiceDir
must be owned by
debian-tor
and must not be world-readable (it holds the private key).

lxc exec tor-vm -- tee /etc/tor/torrc >/dev/null <<'TORRC'
## Tor hidden service configuration
##
## The HiddenServiceDir holds the private key and hostname.
## It is created automatically by Tor on first start and must be
## owned by the debian-tor user.

HiddenServiceDir /var/lib/tor/hidden_service/
HiddenServicePort 80 127.0.0.1:80

## Only run as a client/relay for this hidden service (not a relay)
SocksPort 0
ORPort 0
ExitPolicy reject *:*

## Log to syslog (view with: journalctl -u tor)
Log notice syslog
TORRC


HiddenServicePort 80 127.0.0.1:80
maps the onion's virtual port 80 to Nginx on loopback.

SocksPort 0
disables the SOCKS proxy (we only need the hidden service).

ExitPolicy reject *:*
ensures this Tor instance never acts as an exit node.


Set correct ownership and permissions on the hidden-service directory:

lxc exec tor-vm -- install -d -o debian-tor -g debian-tor -m 700 /var/lib/tor/hidden_service


Restart Tor and check it started cleanly:

lxc exec tor-vm -- systemctl restart tor
lxc exec tor-vm -- systemctl status tor
lxc exec tor-vm -- journalctl -u tor --no-pager -n 20


Look for a line like:

Bootstrapped 100% (done): Done


6.5 Retrieve the onion address

lxc exec tor-vm -- cat /var/lib/tor/hidden_service/hostname


Example output (yours will differ):

abcdefghijklmnopqrstuvwxyz1234567890abcdefghijklmnop.onion


Save this address. Also back up the entire
/var/lib/tor/hidden_service/
directory — the
private_key
file is your onion identity. If you lose it, the address changes. See Section 9.


You can also list the keys (run as root; only
debian-tor
and root can read them):

lxc exec tor-vm -- ls -la /var/lib/tor/hidden_service/


Expected:

drwx------ 2 debian-tor debian-tor 4096 ...  .
drwx------ 3 debian-tor debian-tor 4096 ... ..
-rw------- 1 debian-tor debian-tor 63 ... hostname
-rw------- 1 debian-tor debian-tor 88 ... hs_ed25519_secret_key
-rw------- 1 debian-tor debian-tor 32 ... hs_ed25519_public_key


7. Lockdown — move source and db offline

This is the critical security step. We detach
source-vm
and
db-vm
from the internet-facing
lxdbr0
and attach them to the offline
lxdbr-int
with static IPs. We also add a second NIC (
eth1
) to
tor-vm
so it can reach the internal bridge.

Run these as
lxdadmin
on the host.

7.1 Stop the VMs

lxc stop source-vm
lxc stop db-vm
lxc stop tor-vm


7.2 Reconfigure source-vm networking

The default
eth0
(on
lxdbr0
) comes from the
default
profile. We override it with a local device on
lxdbr-int
with a static DHCP reservation. The
remove
is a safety net in case a local override already exists from a previous run; if
eth0
is only in the profile, the
remove
fails harmlessly and
add
creates the local override:

lxc config device remove source-vm eth0 2>/dev/null || true
lxc config device add source-vm eth0 nic \
network=lxdbr-int \
name=eth0 \
ipv4.address=10.10.20.10


7.3 Reconfigure db-vm networking

lxc config device remove db-vm eth0 2>/dev/null || true
lxc config device add db-vm eth0 nic \
network=lxdbr-int \
name=eth0 \
ipv4.address=10.10.20.20


7.4 Add an internal NIC to tor-vm

tor-vm
keeps its
eth0
on
lxdbr0
(for Tor/internet) and gains
eth1
on
lxdbr-int
(to reach the source app):

lxc config device add tor-vm eth1 nic \
network=lxdbr-int \
name=eth1 \
ipv4.address=10.10.20.30


7.5 (Optional) Set a static IP on tor-vm's external interface

This makes the
tor-vm
external IP predictable, which helps with firewall rules later. Since
eth0
is inherited from the profile, we use the same remove-or-true + add pattern to create a local override:

lxc config device remove tor-vm eth0 2>/dev/null || true
lxc config device add tor-vm eth0 nic \
network=lxdbr0 \
name=eth0 \
ipv4.address=10.10.10.100


7.6 Start the VMs

lxc start db-vm
lxc start source-vm
lxc start tor-vm


7.7 Verify the network topology

lxc list


Expected (IPs on the internal bridge):

+-----------+---------+--------------------------------------------+-----------------+-----------+
| NAME | STATE | IPV4 | TYPE | LOCATION |
+-----------+---------+--------------------------------------------+-----------------+-----------+
| db-vm | RUNNING | 10.10.20.20 (eth0) | VIRTUAL-MACHINE | none |
+-----------+---------+--------------------------------------------+-----------------+-----------+
| source-vm | RUNNING | 10.10.20.10 (eth0) | VIRTUAL-MACHINE | none |
+-----------+---------+--------------------------------------------+-----------------+-----------+
| tor-vm | RUNNING | 10.10.10.100 (eth0), 10.10.20.30 (eth1) | VIRTUAL-MACHINE | none |
+-----------+---------+--------------------------------------------+-----------------+-----------+
#691
↳ Replying to @Xmrdotfail
10.2 VM has no IP on the internal bridge

Check that the static reservation is set and the VM got the IP:

lxc list
lxc network list-leases lxdbr-int


If the IP is missing, restart the VM's network inside:

lxc exec source-vm -- systemctl restart systemd-networkd
# or, for ifupdown / netplan:
lxc exec source-vm -- dhclient eth0


10.3 Tor not bootstrapping

Check the Tor log:

lxc exec tor-vm -- journalctl -u tor --no-pager -n 50


Common causes:

  • tor-vm
    has no internet (check
    eth0
    is on
    lxdbr0
    and has a
    10.10.10.x
    IP).
  • The
    torrc
    has a typo — verify with
    lxc exec tor-vm -- tor --verify-config
    .
  • The
    HiddenServiceDir
    is not owned by
    debian-tor
    — fix with:
  • lxc exec tor-vm -- chown -R debian-tor:debian-tor /var/lib/tor/hidden_service
  • lxc exec tor-vm -- chmod 700 /var/lib/tor/hidden_service


10.4 Nginx returns 502 Bad Gateway

Nginx on
tor-vm
can't reach the source app. Check:

# Is the app running on source-vm?
lxc exec source-vm -- systemctl status app.service
lxc exec source-vm -- ss -tlnp | grep 8080

# Can tor-vm reach source-vm?
lxc exec tor-vm -- curl -sS http://10.10.20.10:8080

# Can source-vm reach db-vm? (app returns 500 if DB is down)
lxc exec source-vm -- bash -c 'nc -zv 10.10.20.20 3306'
lxc exec source-vm -- journalctl -u app.service --no-pager -n 20


10.5 MariaDB won't start after lockdown

The
bind-address
in
/etc/mysql/mariadb.conf.d/50-server.cnf
must match the IP actually assigned to
db-vm
. After Section 7,
db-vm
should be
10.10.20.20
:

lxc exec db-vm -- ip -4 addr show eth0
lxc exec db-vm -- grep bind-address /etc/mysql/mariadb.conf.d/50-server.cnf
lxc exec db-vm -- systemctl restart mariadb
lxc exec db-vm -- systemctl status mariadb


10.6 Python mysql-connector import fails

On Debian 13 (trixie), PEP 668 prevents
pip3 install
into the system environment. If you see
error: externally-managed-environment
, either use
--break-system-packages
(as in the guide) or, preferably, use a venv:

lxc exec source-vm -- apt install -y python3-venv
lxc exec source-vm -- sudo -u appuser python3 -m venv /opt/app/venv
lxc exec source-vm -- sudo -u appuser /opt/app/venv/bin/pip install mysql-connector-python


Then update the systemd unit:

ExecStart=/opt/app/venv/bin/python3 /opt/app/app.py


10.7 lxc file pull permission denied on hidden-service keys

The keys are owned by
debian-tor
with mode
600
.
lxc file pull
runs as root inside the VM namespace, so it should work. If it doesn't, copy via
cat
:

lxc exec tor-vm -- cat /var/lib/tor/hidden_service/hostname
lxc exec tor-vm -- cat /var/lib/tor/hidden_service/hs_ed25519_secret_key > ~/onion-backup/hs_ed25519_secret_key
chmod 600 ~/onion-backup/hs_ed25519_secret_key


10.8 Verifying the offline guarantee

To be certain
source-vm
and
db-vm
cannot reach the internet, check from inside each. The
curl
should time out (returning
OFFLINE
); a default route will still appear via
10.10.20.1
, but traffic has no return path because
lxdbr-int
has no NAT:

lxc exec source-vm -- ip route show default
lxc exec source-vm -- bash -c 'timeout 5 curl -s https://1.1.1.1 >/dev/null 2>&1 && echo LEAK || echo OFFLINE'
lxc exec db-vm -- ip route show default
lxc exec db-vm -- bash -c 'timeout 5 curl -s https://1.1.1.1 >/dev/null 2>&1 && echo LEAK || echo OFFLINE'


Both should print
OFFLINE
. The
ip route show default
will show
default via 10.10.20.1
, but the
curl
times out because there is no SNAT on
lxdbr-int
. If
curl
succeeds, verify
lxc network show lxdbr-int
has
ipv4.nat: "false"
.

Quick-reference command card

# --- Host (as lxdadmin) ---
lxc list
lxc network show lxdbr0
lxc network show lxdbr-int

# --- tor-vm ---
lxc exec tor-vm -- systemctl status tor
lxc exec tor-vm -- systemctl status nginx
lxc exec tor-vm -- cat /var/lib/tor/hidden_service/hostname
lxc exec tor-vm -- journalctl -u tor --no-pager -n 20

# --- source-vm ---
lxc exec source-vm -- systemctl status app.service
lxc exec source-vm -- ss -tlnp | grep 8080
lxc exec source-vm -- curl -s http://localhost:8080

# --- db-vm ---
lxc exec db-vm -- systemctl status mariadb
lxc exec db-vm -- mariadb -e "SHOW DATABASES;"
lxc exec db-vm -- mariadb -e "SHOW GRANTS FOR 'appdb'@'10.10.20.10';"
lxc exec db-vm -- mariadb -e "SELECT * FROM appdb.visits ORDER BY id DESC LIMIT 5;"

# --- End-to-end test ---
ONION=$(lxc exec tor-vm -- cat /var/lib/tor/hidden_service/hostname)
torsocks curl -sS "http://$ONION"


Summary of files modified

VM

File

Purpose

host

(preseed via lxd init)

ZFS pool, lxdbr0, default profile

host

(LXD network)

lxdbr-int offline bridge

tor-vm

/etc/nginx/sites-available/hidden-service

Reverse proxy to 10.10.20.10:8080

tor-vm

/etc/nginx/sites-enabled/hidden-service

Symlink to enable the site

tor-vm

/etc/apt/sources.list.d/tor.sources

Tor Project APT repo (deb822)

tor-vm

/usr/share/keyrings/deb.torproject.org-keyring.gpg

Tor repo signing key

tor-vm

/etc/tor/torrc

Hidden-service config

tor-vm

/var/lib/tor/hidden_service/

Onion keys + hostname (owned by debian-tor)

source-vm

/opt/app/app.py

Python web app

source-vm

/etc/systemd/system/app.service

Systemd unit (runs as appuser, hardened)

db-vm

/etc/mysql/mariadb.conf.d/50-server.cnf

bind-address = 10.10.20.20

db-vm

MariaDB appdb database + appdb user

Limited-privilege SQL user

Disclaimer

This guide provides operational security through network isolation, user separation, and least-privilege configuration. However, true anonymity depends on many factors beyond this setup, including but not limited to: the application code (no IP logging, no external resources), the host server's own network fingerprint, timing correlation attacks, and operational discipline. Review the Tor Project's ⚠️Operational Security⚠️ and ⚠️OnionScan⚠️ documentation, and regularly audit your setup.
#692
↳ Replying to @Xmrdotfail
7.8 Verify source-vm and db-vm are offline

The
lxdbr-int
bridge has
ipv4.nat=false
, which means LXD does not set up SNAT (masquerade) for this bridge. Even though the VMs receive a default route via DHCP (gateway
10.10.20.1
) and the host kernel has
ip_forward=1
, outbound internet traffic from
10.10.20.x
has no return path — replies to a private, non-NATed source address never arrive. The VMs are effectively offline.

From
source-vm
, confirm internet connectivity fails:

lxc exec source-vm -- ip route
lxc exec source-vm -- bash -c 'curl --connect-timeout 5 -s https://example.com >/dev/null 2>&1 && echo "LEAK: internet reachable" || echo "OK: no internet"'


Expected:

OK: no internet


ip route
will show a default route via
10.10.20.1
(pushed by LXD's dnsmasq), but the
curl
should time out after 5 seconds because return traffic cannot reach the VM. If the
curl
succeeds, check that
lxdbr-int
has
ipv4.nat=false
:

lxc network show lxdbr-int | grep ipv4.nat


Repeat for
db-vm
:

lxc exec db-vm -- ip route
lxc exec db-vm -- bash -c 'curl --connect-timeout 5 -s https://example.com >/dev/null 2>&1 && echo "LEAK: internet reachable" || echo "OK: no internet"'


7.9 Verify internal connectivity

From
tor-vm
, confirm it can reach the source app and the database on the internal bridge:

lxc exec tor-vm -- curl -sS --connect-timeout 5 http://10.10.20.10:8080
lxc exec tor-vm -- bash -c 'nc -zv 10.10.20.20 3306 2>&1'


The first command should return the HTML page (
Hello from the onion! ...
). The second should report
succeeded
.

From
source-vm
, confirm it can reach the database:

lxc exec source-vm -- bash -c 'nc -zv 10.10.20.20 3306 2>&1'


7.10 Confirm the app + DB chain works

lxc exec tor-vm -- curl -sS http://10.10.20.10:8080


Expected:

<html><body><h1>Hello from the onion!</h1><p>Visits: 2</p></body></html>


The visit count increments on each request, proving the
source-vm → db-vm
link works.

8. End-to-end test via Tor

8.1 Confirm Tor is publishing the hidden service

lxc exec tor-vm -- systemctl status tor
lxc exec tor-vm -- journalctl -u tor --no-pager -n 20 | grep -i 'bootstrap\|hidden'


8.2 Get the onion address

ONION=$(lxc exec tor-vm -- cat /var/lib/tor/hidden_service/hostname)
echo "Your onion address: http://$ONION"


8.3 Test from the host using torsocks (optional, quick smoke test)

If you want to verify from the host without a Tor Browser:

sudo apt install -y torsocks
torsocks curl -sS "http://$ONION"


Expected:

<html><body><h1>Hello from the onion!</h1><p>Visits: 3</p></body></html>


8.4 Test from Tor Browser

  • Open Tor Browser.
  • Navigate to
    http://<your-onion-address>.onion
    .
  • You should see the
    Hello from the onion!
    page with an incrementing visit counter.


If the page loads, the full chain works:

Tor client → tor-vm:80 (onion) → Nginx 127.0.0.1:80 → source-vm:8080 → db-vm:3306


9. Hardening checklist

9.1 Host firewall (optional but recommended)

Restrict inbound on the host to SSH only (or your management port). LXD's bridge firewall rules already isolate
lxdbr-int
; this hardens the host itself:

sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow routed (forwarded) traffic so LXD bridges work (tor-vm needs internet via lxdbr0)
sudo ufw default allow routed
sudo ufw allow ssh
sudo ufw --force enable


Why
default allow routed
?
UFW's default forward policy is
DROP
, which would block LXD's bridge forwarding (tor-vm → internet). Setting it to
allow
lets LXD's nftables rules handle bridge traffic. LXD's own
ipv4.firewall=true
on
lxdbr-int
still prevents the offline VMs from reaching the internet (no NAT = no return path).


9.2 Firewall on tor-vm

Lock
tor-vm
down so only Tor's outbound connections and the loopback Nginx listener are allowed:

lxc exec tor-vm -- apt install -y ufw
# Default policies
lxc exec tor-vm -- ufw default deny incoming
lxc exec tor-vm -- ufw default allow outgoing
lxc exec tor-vm -- ufw default allow routed
# Allow loopback (Nginx + Tor hidden-service port)
lxc exec tor-vm -- ufw allow in on lo
# Allow internal-bridge traffic from source/db to tor-vm (for diagnostics)
lxc exec tor-vm -- ufw allow in on eth1 from 10.10.20.0/24
# Enable (skip the confirmation prompt)
lxc exec tor-vm -- ufw --force enable


Nginx listens on
127.0.0.1:80
only, so even without a firewall it is not reachable from the bridge. The UFW rules add defense-in-depth.


9.3 Verify the app runs with reduced privileges

lxc exec source-vm -- ps -o user,pid,cmd -C python3


Expected: the process runs as
appuser
, not root. The systemd unit's
NoNewPrivileges=true
,
ProtectSystem=strict
,
ProtectHome=true
, and
PrivateTmp=true
further restrict it.

9.4 Verify MariaDB grants are minimal

lxc exec db-vm -- mariadb -e "SHOW GRANTS FOR 'appdb'@'10.10.20.10';"


The
appdb
user should have only
SELECT, INSERT, UPDATE, DELETE
on
appdb.*
— no
FILE
, no
SUPER
, no
GRANT OPTION
, no access to other databases or from other hosts.

9.5 Back up the Tor hidden-service keys

The files in
/var/lib/tor/hidden_service/
are the onion identity. If
hs_ed25519_secret_key
is lost, the onion address changes permanently.

# On the host, pull the keys to a safe location (e.g. an encrypted USB or password store).
mkdir -p ~/onion-backup
lxc file pull tor-vm/var/lib/tor/hidden_service/hostname ~/onion-backup/
lxc file pull tor-vm/var/lib/tor/hidden_service/hs_ed25519_secret_key ~/onion-backup/
lxc file pull tor-vm/var/lib/tor/hidden_service/hs_ed25519_public_key ~/onion-backup/
chmod 600 ~/onion-backup/hs_ed25519_secret_key


Store the backup offline (encrypted USB, password manager, etc.). Never commit it to git.

9.6 Keep packages updated (build-phase only)

After lockdown,
source-vm
and
db-vm
cannot reach the internet to install security updates. Two options:

  • Temporary re-attach: move the VM back to
    lxdbr0
    , run
    apt update && apt upgrade
    , move it back to
    lxdbr-int
    . (Use the Section 7 procedure in reverse.)
  • Proxy updates through
    tor-vm
    :
    set up an apt HTTP proxy on
    tor-vm
    (e.g.
    apt-cacher-ng
    ) listening on
    10.10.20.30:3142
    , and configure
    source-vm
    /
    db-vm
    to use it. This keeps them offline while still receiving updates.


10. Troubleshooting

10.1 lxc console access

If a VM's network is misconfigured and SSH/
lxc exec
don't work, use the console as
lxdadmin
:

lxc console tor-vm
lxc console source-vm
lxc console db-vm


Detach with
Ctrl+a
then
q
.
#693
↳ Replying to @Xmrdotfail
This looks legit. When I have more time, I will do a better read through, but thanks for the very thurough looking guide!
#694
↳ Replying to @Xmrdotfail
Thanks for sharing.