There is a kind of technical contribution almost nobody sees, yet it sustains free internet access for thousands of people every day: donating a slice of a server to run a Tor bridge. Unlike an ordinary relay, a bridge is an entry point that is not publicly listed, existing specifically to let users under censorship reach the Tor network without their local ISP noticing.

This article documents the full process: the technical grounding of what a bridge is and why it matters, the criteria for choosing a VPS, deploying the stack on Docker (obfs4 bridge, monitoring and alerting) and hardening the host. It closes with the main failure point found in practice, the interaction between Docker and UFW, which silently invalidates much of a VPS firewall configuration.

Concepts

Node types in the Tor network

The Tor network is sustained by volunteers operating three types of node, with distinct purposes. The middle relay forwards already-encrypted traffic between other nodes, being public and openly listed in the consensus. The exit relay is the last hop, where traffic leaves the network toward the open internet using the operator's IP, the most needed type, but also the one most exposed to abuse complaints and legal implications. The bridge, in turn, is an entry point deliberately absent from the public consensus.

The distinction is crucial: ordinary relays appear on a public list that any government can download and block wholesale in a single operation. A bridge is not on that list. A censor cannot block what it cannot enumerate, and that is precisely where its function lies.

obfs4 and Pluggable Transports

Keeping the bridge address secret is not enough: Tor traffic has a recognizable signature in its TLS handshake, which lets Deep Packet Inspection (DPI) systems identify and block the connection even without knowing the destination IP. Pluggable Transports solve this by wrapping the traffic in an obfuscation layer.

obfs4 is the standard transport for bridges. It performs a handshake authenticated by a key that only a client holding the bridge line knows, turning the entire flow into seemingly random noise - no fixed headers, no recognizable packet size pattern, and no response to probes from anyone without the key. That last property is what provides resistance to active probing: a censor connecting to the obfs4 port without the correct key receives nothing that confirms a Tor bridge is there.

Distribution and bridge life cycle

A newly created bridge is not handed directly to users. It is registered with the Bridge Authority and distributed by Rdsys, the Tor Project's distribution system, which releases it through controlled channels, BridgeDB over HTTPS, e-mail and Telegram, or bundled with Tor Browser. Distribution is fragmented on purpose: no single channel hands out the complete set of bridges, precisely to prevent a censor from enumerating them all at once.

Bridges are not permanent. Countries with large-scale active probing capability manage to identify and block bridges over time, even with obfuscation. This creates a continuous replacement dynamic: the network depends on a steady flow of new bridges, still absent from block lists, replacing those being burned. The value of a new bridge lies exactly at that point.

Operational relevance

The numbers support the argument. Russia and Iran are consistently the countries with the highest volume of bridge connections, tens of thousands of daily users combined, with spikes multiplying by dozens of times during periods of heavier repression. In Iran, direct connections to Tor have been blocked for years; for virtually every Iranian user, a bridge is not an alternative, it is the only entry path that exists. The network operates with roughly 1,800 to 2,200 active bridges at any given moment, a modest figure against the demand.


Why not run it on a home network

The temptation to save money and run the bridge on a home computer exists, but it runs into technical limitations that a VPS eliminates immediately.

CGNAT

A large share of residential connections, especially mobile and part of cable connections, operates behind Carrier-Grade NAT, meaning the subscriber does not even hold a public IP of their own. Without a public IP, there is no way for the Bridge Authority to reach the ORPort to validate the bridge's reachability. This is an architectural blocker, not a configuration issue.

Dynamic IP

Even without CGNAT, residential ISPs rotate the subscriber's IP periodically. Each change breaks the bridge's continuity, forcing it to rebuild reputation from scratch, and Rdsys distribution mechanisms favor bridges with stable uptime and a consistent address.

Shared bandwidth

The bridge competes for upload with all household usage: streaming, video calls, games. On a VPS, bandwidth is dedicated and predictable.

Terms of service

Many residential ISPs contractually prohibit running servers, even when they do not actively enforce it.

Residential IP exposure

Although the bridge does not expose the operator to end users, that is precisely the point of the design - it remains exposed to any internet scanning system, including those of countries performing active probing. It is preferable that this address belongs to a disposable VPS rather than to the operator's home.


Choosing the VPS

The Tor Project's official requirements for a bridge are modest: a minimum of 1 Mbit/s of bandwidth (with an ideal range between 10 and 16 Mbit/s), a monthly traffic minimum of 100 GB, 2 TB or more recommended - and no relevant CPU or RAM demands. That makes low-cost entry plans viable.

Hetzner (CX22)

2 vCPU, 4 GB RAM, 40 GB NVMe and 20 TB of monthly traffic, in the USD 4.50 to 5 range per month. It offers the best ratio of included bandwidth to price among traditional providers.

OVHcloud (entry line)

2 GB RAM with unmetered bandwidth, around USD 4 to 4.50 per month. The absence of a traffic cap removes any concern about usage growth.

Contabo (Cloud VPS)

4 vCPU, 8 GB RAM and 100 GB SSD for roughly USD 5 per month on long-term promotional contracts. Generous resources, with historically more irregular network performance.

BuyVM

Unmetered bandwidth at 1 Gbps for around USD 3.50 per month. A smaller provider, but traditionally tolerant of privacy infrastructure.

The deciding factor is neither CPU nor RAM, but high or unmetered bandwidth. A healthy, well distributed bridge tends to receive growing traffic as it gains reputation on the network, so a capped plan tends to become a bottleneck sooner than expected. It is also prudent to check the provider's policy regarding Tor nodes: bridges and middle relays are widely accepted, while exit relays are usually prohibited.


Actual resource usage

After months of operation, the measured consumption of the full stack, obfs4 bridge, Uptime Kuma monitoring and the daily summary script, stayed considerably below what any entry plan offers:

RAM     150-400 MB total, against 4-8 GB available
Disk    1-2 GB for the whole stack, growing slowly through logs
CPU     average well below one core, with rare spikes under heavy traffic

In practice, even the cheapest plan on the list above keeps a wide resource margin, which allows the same VPS to take on other roles, bug bounty and continuous reconnaissance tooling, for instance, with no noticeable conflict with the bridge.


Stack architecture

The stack runs entirely on Docker and consists of three services, on top of a previously hardened host:

Service layer: obfs4-bridge, built from the official Tor Project image
Observability layer: uptime-kuma, monitoring the port and alerting via Telegram
Reporting layer: tor-bridge-report.sh, querying the Tor Metrics Onionoo API
Host layer: UFW, ufw-docker, Fail2Ban, unattended-upgrades and kernel hardening via sysctl


Hands-on

Host preparation

With the instance provisioned, update the system and install the base dependencies.

apt update && apt upgrade -y
apt install -y ufw fail2ban unattended-upgrades curl jq

Next, install Docker Engine from the official script and enable the service.

curl -fsSL https://get.docker.com | sh
systemctl enable --now docker

Kernel hardening

Before exposing any service, apply network protections at the kernel level. Create the file /etc/sysctl.d/99-hardening.conf with the content below.

# Network protections
net.ipv4.conf.all.accept_redirects = 0   # Blocks ICMP redirects
net.ipv4.conf.all.send_redirects = 0     # Does not send redirects
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.all.rp_filter = 1          # Prevents IP spoofing
net.ipv4.tcp_syncookies = 1              # Mitigates SYN flood
net.ipv4.conf.all.log_martians = 1

# Kernel protections
kernel.kptr_restrict = 2                 # Hides kernel pointers
kernel.dmesg_restrict = 1
sysctl --system

Firewall and the Docker problem

Configure UFW with a restrictive policy, allowing only SSH. The bridge ports must not be opened here, the reason follows immediately.

ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw --force enable

The critical point: Docker bypasses UFW by default. The reason lies in the iptables evaluation order: traffic to a published port is DNATed in the PREROUTING chain (nat table) and delivered to the container by traversing the FORWARD chain, never the INPUT chain, which is exactly where UFW applies its rules. When publishing a port in a docker-compose.yml, Docker also inserts its own rules into the DOCKER chain, evaluated within FORWARD before any UFW policy. The result is that a container with a published port stays externally reachable even with UFW set to deny everything - and, conversely, attempts to open the port through UFW alone do not have the expected effect. It is a silent divergence between what ufw status reports and what the kernel actually enforces.

The fix is the community project ufw-docker, which reorganizes the chains so that UFW governs container-bound traffic again.

wget -O /usr/local/bin/ufw-docker \
  https://github.com/chaifeng/ufw-docker/raw/master/ufw-docker
chmod +x /usr/local/bin/ufw-docker
ufw-docker install && systemctl restart ufw

From this point on, rules are applied per container, not per generic port. The bridge-specific rules are added after the services are up.

* Validation must be performed from outside the host, with independent tools (nmap or online port checkers). Trusting only what runs locally is exactly what masks this class of failure.

obfs4 bridge configuration

The official Tor Project image bundles tor and obfs4proxy already configured as a bridge. Create the project directory and the environment file.

mkdir -p /opt/tor-bridge && cd /opt/tor-bridge
# .env
OR_PORT=443
PT_PORT=8443
EMAIL=your-email@example.com
NICKNAME=YourBridge

The port choice is not arbitrary. The ORPort is used for communication with the Tor network and for the reachability check performed by the Bridge Authority; PT_PORT is the obfs4 port censored clients connect to. Common ports such as 443 tend to traverse restrictive firewalls more easily than arbitrary high ports. EMAIL is used by the Tor Project to notify the operator about problems with the bridge, and NICKNAME is the node's public identifier.

Next, define the docker-compose.yml with the stack's services.

services:
  obfs4-bridge:
    image: thetorproject/obfs4-bridge:latest
    container_name: obfs4-bridge
    restart: unless-stopped
    env_file: .env
    ports:
      - "443:443"
      - "8443:8443"
    volumes:
      - tor-data:/var/lib/tor

  uptime-kuma:
    image: louislam/uptime-kuma:1
    container_name: uptime-kuma
    restart: unless-stopped
    ports:
      - "127.0.0.1:3001:3001"
    volumes:
      - kuma-data:/app/data

volumes:
  tor-data:
  kuma-data:

The tor-data volume is the most important element of the configuration: it holds the bridge's identity keys. Losing that volume means losing the fingerprint, all reputation accumulated on the network and the continuity of Rdsys distribution, the bridge is treated as a brand new node again. Uptime Kuma, in turn, is published only on 127.0.0.1, deliberately unreachable from outside and accessible only through an SSH tunnel.

Bring the stack up and follow the initialization.

docker compose up -d
docker logs -f obfs4-bridge

Now apply the per-container firewall rules. Without this step, the ORPort is not reachable from outside and the bridge never completes validation.

ufw-docker allow obfs4-bridge 443/tcp
ufw-docker allow obfs4-bridge 8443/tcp

Bridge validation

Definitive confirmation comes from tor's own logs. The message below indicates that the Bridge Authority managed to reach the ORPort from the internet.

docker logs obfs4-bridge | grep -i "self-testing"
[notice] Self-testing indicates your ORPort 203.0.113.10:443 is reachable
         from the outside. Excellent. Publishing server descriptor.

With the bridge published, retrieve the bridge line, the string a censored client uses to connect manually to the node.

docker exec obfs4-bridge get-bridge-line
obfs4 203.0.113.10:8443 A1B2C3D4E5F60718293A4B5C6D7E8F9012345678 \
  cert=Bvg/itxeL4TWKLP6N1MaQzSOC6tBjZmJ3EXAMPLEcert iat-mode=0

* The bridge line must not be published publicly. Distribution is Rdsys's responsibility, which hands it out in fragmented fashion to real users. Posting it in a repository, forum or social network is the fastest path to having it collected and added to block lists.

Querying Onionoo

The public Onionoo API exposes node metadata maintained by Tor Metrics. For bridges, the query is not made with the plain fingerprint but with its SHA-1 hash, one more layer of protection against enumeration. Extract the fingerprint from the volume and derive the hash.

docker exec obfs4-bridge cat /var/lib/tor/fingerprint
echo -n "<FINGERPRINT>" | xxd -r -p | sha1sum | cut -d' ' -f1 | tr 'a-z' 'A-Z'

With the hash in hand, query the bridge's state.

curl -s "https://onionoo.torproject.org/details?lookup=<HASHED_FINGERPRINT>" | jq

* Newly created bridges take a few hours to appear on Onionoo and a few days to be effectively distributed by Rdsys. No results in the first hours is expected behavior, not a symptom of error.

Monitoring and alerting

Since Uptime Kuma is published only on loopback, access to the interface goes through an SSH tunnel, avoiding exposing the dashboard to the internet.

ssh -N -L 3001:localhost:3001 root@<PUBLIC_IP>
# Then, locally:
# http://localhost:3001

In the dashboard, configure a TCP Port monitor pointing at PT_PORT (8443) and a second one at the ORPort (443), with a 60-second interval. Then add a Telegram notification with the bot token and chat ID. Any port going down now triggers an immediate alert, and, for a bridge, prolonged downtime means losing position in Rdsys distribution.

Automated daily summary

Complementing Uptime Kuma's reactive alerting, a daily report provides the bridge's state from the network's own perspective: assigned flags, observed bandwidth and the date of the last published descriptor. Create the script /opt/tor-bridge/tor-bridge-report.sh.

#!/usr/bin/env bash
set -euo pipefail

HASHED_FP="<HASHED_FINGERPRINT>"
BOT_TOKEN="<TELEGRAM_BOT_TOKEN>"
CHAT_ID="<TELEGRAM_CHAT_ID>"

DATA=$(curl -s "https://onionoo.torproject.org/details?lookup=${HASHED_FP}")

RUNNING=$(echo "$DATA" | jq -r '.bridges[0].running')
NICKNAME=$(echo "$DATA" | jq -r '.bridges[0].nickname')
FLAGS=$(echo "$DATA" | jq -r '.bridges[0].flags | join(", ")')
SEEN=$(echo "$DATA" | jq -r '.bridges[0].last_seen')

[ "$RUNNING" = "true" ] && STATUS="online" || STATUS="OFFLINE"

MSG="Bridge ${NICKNAME}
Status: ${STATUS}
Flags: ${FLAGS}
Last descriptor: ${SEEN}"

curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
  -d "chat_id=${CHAT_ID}" \
  --data-urlencode "text=${MSG}" > /dev/null

Make the script executable and schedule daily execution via cron.

chmod +x /opt/tor-bridge/tor-bridge-report.sh
# crontab -e
0 9 * * * /opt/tor-bridge/tor-bridge-report.sh

* The script holds Telegram bot credentials. Restrict permissions with chmod 700 and keep it out of any versioned repository.

Fail2Ban and automatic updates

Finally, complete host hardening by enabling Fail2Ban for SSH and automatic security updates. A bridge is a long-running, low-maintenance service, exactly the machine profile that tends to accumulate outdated packages.

systemctl enable --now fail2ban
dpkg-reconfigure --priority=low unattended-upgrades


Final considerations

The total cost of maintaining a bridge lands in the USD 4 to 6 per month range, and the technical knowledge required does not go beyond that of anyone already comfortable with Linux and Docker. Operationally, the only discipline genuinely required is preserving the container's data volume and keeping uptime stable.

The most transferable lesson from the process, however, goes beyond the Tor context: the divergence between what ufw status reports and what the kernel actually enforces under Docker affects any containerized service published on a VPS. Validating the exposure surface from outside the host, with independent tools, should be a standard step in any deployment, not merely the outcome of a debugging session.