Daniel Meier

Infrastructure, Linux, automation, and operational engineering

Running a Tor Exit Node on CentOS Stream 9 with Ansible

If you want to operate a Tor exit node properly, you should avoid doing it by hand.

You need a repeatable bootstrap, a hardened SSH configuration, a firewall that only exposes what you actually need, a local resolver, and a maintenance path that does not turn into improvisation after the first update cycle.

I still like CentOS Stream 9 for this kind of infrastructure work. I explained why in an earlier article here: Why I Continue To Advocate For CentOS Stream In Production Environments.

In this article, I will show a small Ansible setup that bootstraps a CentOS Stream 9 host, configures it as a Tor exit relay, adds a local Unbound resolver, hardens SSH, enables fail2ban, and gives you a minimal maintenance workflow.

All hostnames, IP addresses, nicknames, usernames and contact details below are sanitized examples, but the structure mirrors a real setup.

Generate an SSH Key First

Before you touch Ansible, create a dedicated SSH keypair for the host:

ssh-keygen -t ed25519 -f ~/.ssh/tor_exit_ed25519 -C "tor-exit"

This gives you:

  • ~/.ssh/tor_exit_ed25519
  • ~/.ssh/tor_exit_ed25519.pub

You can then use the private key to connect to the VPS and the public key as the administrative key installed by Ansible.

If the machine is fresh and still allows password login for the provider user, a simple first connection might look like this:

ssh -i ~/.ssh/tor_exit_ed25519 bootstrap@203.0.113.10

Repository Layout

A small structure like this is enough:

.
├── group_vars/
│   └── tor_exit.yml
├── inventory.yml
├── playbooks/
│   ├── check.yml
│   ├── fail2ban.yml
│   ├── hardening-services.yml
│   ├── init.yml
│   ├── maintenance.yml
│   ├── site.yml
│   ├── ssh-hardening.yml
│   └── unbound.yml
├── requirements.yml
└── templates/
    ├── torrc.j2
    └── unbound.conf.j2

Install the required Ansible collection:

ansible-galaxy collection install -r requirements.yml

Inventory and Variables

This inventory keeps the target definition very small:

# inventory.yml
all:
  children:
    tor_exit:
      hosts:
        tor-exit-01:
          ansible_host: 203.0.113.10
          ansible_user: bootstrap
          ansible_ssh_private_key_file: ~/.ssh/tor_exit_ed25519

And the group variables define the relay itself:

# group_vars/tor_exit.yml
tor_nickname: "ExampleExit01"
tor_contact_info: "abuse: tor-admin@example.org"
tor_exit_policy_mode: "web_only"
tor_exit_policy_custom:
  - "accept *:80"
  - "accept *:443"
  - "reject *:*"

tor_or_port: 9001

tor_relay_bandwidth_rate: "5 MB"
tor_relay_bandwidth_burst: "10 MB"

admin_user: "admin"
admin_ssh_public_key: "{{ lookup('file', '~/.ssh/tor_exit_ed25519.pub') }}"

The collection dependency is minimal:

# requirements.yml
collections:
  - name: ansible.posix

Bootstrap the Host

The first playbook prepares a CentOS Stream 9 system for normal Ansible use. It creates swap, installs Python, enables CRB and EPEL, brings up firewalld, and installs your administrative SSH key.

# playbooks/init.yml
---
- name: Initial bootstrap for CentOS Stream 9
  hosts: tor_exit
  become: true
  gather_facts: false

  tasks:

    - name: Check if swapfile exists
      ansible.builtin.stat:
        path: /swapfile
      register: swapfile_stat

    - name: Create swapfile
      ansible.builtin.command:
        cmd: fallocate -l 1G /swapfile
      when: not swapfile_stat.stat.exists

    - name: Set swapfile permissions
      ansible.builtin.file:
        path: /swapfile
        owner: root
        group: root
        mode: "0600"

    - name: Format swapfile
      ansible.builtin.command:
        cmd: mkswap /swapfile
      when: not swapfile_stat.stat.exists

    - name: Enable swapfile
      ansible.builtin.command:
        cmd: swapon /swapfile
      when: not swapfile_stat.stat.exists

    - name: Persist swapfile in fstab
      ansible.builtin.lineinfile:
        path: /etc/fstab
        line: "/swapfile none swap sw 0 0"
        state: present

    - name: Install Python and DNF helpers
      ansible.builtin.raw: |
        dnf -y install python3 python3-dnf dnf-plugins-core sudo
      changed_when: false

    - name: Gather facts after Python bootstrap
      ansible.builtin.setup:

    - name: Enable CRB repository
      ansible.builtin.command:
        cmd: dnf config-manager --set-enabled crb
      changed_when: false

    - name: Install EPEL repositories
      ansible.builtin.dnf:
        name:
          - epel-release
          - epel-next-release
        state: present
        update_cache: true

    - name: Install base packages
      ansible.builtin.dnf:
        name:
          - firewalld
          - python3-firewall
          - python3-dbus
          - curl
          - htop
          - vim
          - nyx
          - iftop
          - vnstat
          - git
        state: present

    - name: Enable and start vnStat daemon
      ansible.builtin.systemd:
        name: vnstat
        enabled: true
        state: started

    - name: Check vnStat database
      ansible.builtin.command:
        cmd: vnstat --dbiflist
      register: vnstat_dbiflist
      changed_when: false
      failed_when: false

    - name: Add eth0 to vnStat database if missing
      ansible.builtin.command:
        cmd: vnstat --add -i eth0
      when: "'eth0' not in vnstat_dbiflist.stdout"
      failed_when: false

    - name: Restart vnStat after adding interface
      ansible.builtin.systemd:
        name: vnstat
        state: restarted

    - name: Enable and start firewalld
      ansible.builtin.systemd:
        name: firewalld
        enabled: true
        state: started

    - name: Allow SSH through firewall
      ansible.posix.firewalld:
        service: ssh
        permanent: true
        immediate: true
        state: enabled

    - name: Create admin user
      ansible.builtin.user:
        name: "{{ admin_user }}"
        groups: wheel
        append: true
        shell: /bin/bash
        create_home: true

    - name: Install SSH key for admin user
      ansible.posix.authorized_key:
        user: "{{ admin_user }}"
        key: "{{ admin_ssh_public_key }}"
        state: present

    - name: Allow wheel group passwordless sudo
      ansible.builtin.copy:
        dest: /etc/sudoers.d/90-wheel-nopasswd
        content: "%wheel ALL=(ALL) NOPASSWD: ALL\n"
        owner: root
        group: root
        mode: "0440"
        validate: "visudo -cf %s"

Run it like this:

ansible-playbook -i inventory.yml playbooks/init.yml

After that, switch your inventory to the user you actually want to keep. For example:

all:
  children:
    tor_exit:
      hosts:
        tor-exit-01:
          ansible_host: 203.0.113.10
          ansible_user: admin
          ansible_ssh_private_key_file: ~/.ssh/tor_exit_ed25519

Configure Tor as an Exit Relay

The relay playbook is deliberately small. It installs Tor, cleans up unnecessary firewall exposure, opens the ORPort, and renders torrc from a template.

# playbooks/site.yml
---
- name: Configure Tor exit relay
  hosts: tor_exit
  become: true
  gather_facts: true

  tasks:
    - name: Install Tor
      ansible.builtin.dnf:
        name: tor
        state: present

    - name: Remove cockpit from firewall
      ansible.posix.firewalld:
        service: cockpit
        permanent: true
        immediate: true
        state: disabled

    - name: Remove dhcpv6-client from firewall
      ansible.posix.firewalld:
        service: dhcpv6-client
        permanent: true
        immediate: true
        state: disabled

    - name: Allow Tor ORPort through firewall
      ansible.posix.firewalld:
        port: "{{ tor_or_port }}/tcp"
        permanent: true
        immediate: true
        state: enabled

    - name: Deploy torrc
      ansible.builtin.template:
        src: ../templates/torrc.j2
        dest: /etc/tor/torrc
        owner: root
        group: root
        mode: "0644"
      notify: Restart tor

    - name: Enable and start Tor
      ansible.builtin.systemd:
        name: tor
        enabled: true
        state: started

  handlers:
    - name: Restart tor
      ansible.builtin.systemd:
        name: tor
        state: restarted

And the corresponding torrc template:

# templates/torrc.j2
## Managed by Ansible

Nickname {{ tor_nickname }}
ContactInfo {{ tor_contact_info }}

SocksPort 0
ORPort {{ tor_or_port }} IPv4Only
ServerDNSResolvConfFile /etc/tor/resolv.conf
HeartbeatPeriod 1 hour

ControlPort 127.0.0.1:9051
CookieAuthentication 1

ExitRelay 1

RelayBandwidthRate {{ tor_relay_bandwidth_rate }}
RelayBandwidthBurst {{ tor_relay_bandwidth_burst }}

{% if tor_exit_policy_mode == "reduced" %}
ReducedExitPolicy 1
{% elif tor_exit_policy_mode == "web_only" %}
ExitPolicy accept *:80
ExitPolicy accept *:443
ExitPolicy reject *:*
{% elif tor_exit_policy_mode == "custom" %}
{% for rule in tor_exit_policy_custom %}
ExitPolicy {{ rule }}
{% endfor %}
{% endif %}

I generally recommend starting with the web_only policy if you want a narrower operational footprint.

Add a Local Resolver with Unbound

For an exit node, I prefer a local resolver instead of pushing DNS resolution elsewhere. This playbook installs Unbound, fetches root hints, prepares the DNSSEC trust anchor, validates the configuration, and points Tor to its own resolver file.

# playbooks/unbound.yml
---
- name: Configure local Unbound resolver for Tor exit
  hosts: tor_exit
  become: true
  gather_facts: true

  tasks:
    - name: Install Unbound and DNS tools
      ansible.builtin.dnf:
        name:
          - unbound
          - bind-utils
        state: present

    - name: Ensure Unbound state directory exists
      ansible.builtin.file:
        path: /var/lib/unbound
        owner: unbound
        group: unbound
        mode: "0750"
        state: directory

    - name: Download root hints
      ansible.builtin.get_url:
        url: https://www.internic.net/domain/named.cache
        dest: /etc/unbound/root.hints
        owner: root
        group: unbound
        mode: "0644"

    - name: Remove old misplaced trust anchor
      ansible.builtin.file:
        path: /etc/unbound/root.key
        state: absent

    - name: Prepare DNSSEC trust anchor
      ansible.builtin.command:
        cmd: unbound-anchor -a /var/lib/unbound/root.key
      register: unbound_anchor
      changed_when: false
      failed_when: unbound_anchor.rc not in [0, 1]

    - name: Set trust anchor permissions
      ansible.builtin.file:
        path: /var/lib/unbound/root.key
        owner: unbound
        group: unbound
        mode: "0644"

    - name: Deploy Unbound config
      ansible.builtin.template:
        src: ../templates/unbound.conf.j2
        dest: /etc/unbound/unbound.conf
        owner: root
        group: root
        mode: "0644"
      notify: Restart unbound

    - name: Restore SELinux context for Unbound files
      ansible.builtin.command:
        cmd: restorecon -Rv /etc/unbound /var/lib/unbound
      changed_when: false
      failed_when: false

    - name: Validate Unbound config
      ansible.builtin.command:
        cmd: unbound-checkconf /etc/unbound/unbound.conf
      changed_when: false

    - name: Enable and start Unbound
      ansible.builtin.systemd:
        name: unbound
        enabled: true
        state: started

    - name: Create Tor resolver file using local Unbound
      ansible.builtin.copy:
        dest: /etc/tor/resolv.conf
        owner: root
        group: root
        mode: "0644"
        content: |
          nameserver 127.0.0.1
          options edns0 trust-ad

    - name: Ensure Tor uses dedicated resolver file
      ansible.builtin.lineinfile:
        path: /etc/tor/torrc
        regexp: "^ServerDNSResolvConfFile "
        line: "ServerDNSResolvConfFile /etc/tor/resolv.conf"
        state: present
      notify: Restart tor

  handlers:
    - name: Restart unbound
      ansible.builtin.systemd:
        name: unbound
        state: restarted

    - name: Restart tor
      ansible.builtin.systemd:
        name: tor
        state: restarted

The matching Unbound template:

# templates/unbound.conf.j2
server:
    chroot: ""

    interface: 127.0.0.1
    port: 53

    access-control: 127.0.0.0/8 allow
    access-control: 0.0.0.0/0 refuse

    do-ip4: yes
    do-ip6: no
    do-udp: yes
    do-tcp: yes

    root-hints: "/etc/unbound/root.hints"
    auto-trust-anchor-file: "/var/lib/unbound/root.key"

    username: "unbound"
    directory: "/var/lib/unbound"

    hide-identity: yes
    hide-version: yes
    harden-glue: yes
    harden-dnssec-stripped: yes
    harden-referral-path: yes
    qname-minimisation: yes
    aggressive-nsec: yes
    minimal-responses: yes

    prefetch: yes
    prefetch-key: yes

    cache-min-ttl: 60
    cache-max-ttl: 86400

    log-queries: no
    log-replies: no
    verbosity: 1

Harden SSH and Basic Services

I do not keep the default SSH posture on an internet-facing Tor host. This playbook disables root login and password authentication, limits forwarding features, and reduces brute-force tolerance.

# playbooks/ssh-hardening.yml
---
- name: Harden SSH access
  hosts: tor_exit
  become: true
  gather_facts: true

  tasks:
    - name: Disable root login and password auth
      ansible.builtin.copy:
        dest: /etc/ssh/sshd_config.d/99-tor-exit-hardening.conf
        owner: root
        group: root
        mode: "0644"
        content: |
          PermitRootLogin no
          PasswordAuthentication no
          KbdInteractiveAuthentication no
          PubkeyAuthentication yes

          AllowUsers admin
          MaxAuthTries 3
          MaxStartups 10:30:60
          LoginGraceTime 30
          X11Forwarding no
          AllowTcpForwarding no
          AllowAgentForwarding no
          PermitTunnel no

    - name: Validate sshd config
      ansible.builtin.command:
        cmd: sshd -t
      changed_when: false

    - name: Restart sshd
      ansible.builtin.systemd:
        name: sshd
        state: restarted

This service-hardening playbook removes one small but common bit of noise:

# playbooks/hardening-services.yml
---
- name: Disable unnecessary services
  hosts: tor_exit
  become: true
  gather_facts: true

  tasks:
    - name: Disable and mask rpcbind units
      ansible.builtin.systemd:
        name: "{{ item }}"
        enabled: false
        state: stopped
        masked: true
      loop:
        - rpcbind.service
        - rpcbind.socket
      failed_when: false

    - name: Verify rpcbind port is closed
      ansible.builtin.shell:
        cmd: "ss -tulpen | grep ':111' || true"
      register: rpcbind_check
      changed_when: false

    - name: Print rpcbind check
      ansible.builtin.debug:
        var: rpcbind_check.stdout_lines

Add fail2ban

On a public VPS, this is still worth having:

# playbooks/fail2ban.yml
---
- name: Configure fail2ban for SSH
  hosts: tor_exit
  become: true
  gather_facts: true

  tasks:
    - name: Install fail2ban
      ansible.builtin.dnf:
        name:
          - fail2ban
          - fail2ban-firewalld
        state: present

    - name: Configure sshd jail
      ansible.builtin.copy:
        dest: /etc/fail2ban/jail.d/sshd.local
        owner: root
        group: root
        mode: "0644"
        content: |
          [sshd]
          enabled = true
          backend = systemd
          banaction = firewallcmd-ipset
          port = ssh
          maxretry = 3
          findtime = 10m
          bantime = 1h

    - name: Enable and start fail2ban
      ansible.builtin.systemd:
        name: fail2ban
        enabled: true
        state: restarted

Maintenance and Verification

An exit node is not a “configure once and forget it” system. The maintenance playbook updates the host, reboots when required, and verifies that Tor and the firewall come back cleanly.

# playbooks/maintenance.yml
---
- name: Maintain Tor exit host
  hosts: tor_exit
  become: true
  gather_facts: true

  tasks:
    - name: Update all packages
      ansible.builtin.dnf:
        name: "*"
        state: latest
        update_cache: true

    - name: Check if reboot is required
      ansible.builtin.command:
        cmd: needs-restarting -r
      register: reboot_check
      failed_when: false
      changed_when: reboot_check.rc == 1

    - name: Print reboot status
      ansible.builtin.debug:
        var: reboot_check.stdout_lines

    - name: Reboot if required
      ansible.builtin.reboot:
        msg: "Reboot initiated by Ansible after package updates"
        reboot_timeout: 900
        connect_timeout: 20
        post_reboot_delay: 15
        test_command: "systemctl is-system-running || true"
      when: reboot_check.rc == 1

    - name: Wait for host connection after reboot
      ansible.builtin.wait_for_connection:
        timeout: 300
        delay: 5
      when: reboot_check.rc == 1

    - name: Ensure firewalld is enabled and running
      ansible.builtin.systemd:
        name: firewalld
        enabled: true
        state: started

    - name: Ensure Tor is enabled and running
      ansible.builtin.systemd:
        name: tor
        enabled: true
        state: started

    - name: Check Tor service
      ansible.builtin.command:
        cmd: systemctl is-active tor
      changed_when: false

    - name: Show Tor fingerprint
      ansible.builtin.command:
        cmd: cat /var/lib/tor/fingerprint
      changed_when: false

    - name: Show Tor ORPort listener
      ansible.builtin.shell:
        cmd: ss -tulpen | grep ':{{ tor_or_port }}'
      changed_when: false

And this tiny check playbook is useful when you just want a quick status read:

# playbooks/check.yml
---
- name: Check Tor relay status
  hosts: tor_exit
  become: true
  gather_facts: false

  tasks:
    - name: Check Tor service
      ansible.builtin.command:
        cmd: systemctl is-active tor
      changed_when: false

    - name: Show Tor fingerprint
      ansible.builtin.command:
        cmd: cat /var/lib/tor/fingerprint
      changed_when: false

    - name: Show firewall state
      ansible.builtin.command:
        cmd: firewall-cmd --list-all
      changed_when: false

    - name: Show Tor ORPort listener
      ansible.builtin.shell:
        cmd: ss -tulpen | grep ':{{ tor_or_port }}'
      changed_when: false

Typical Run Order

Once the machine exists, the practical order is:

ansible-galaxy collection install -r requirements.yml
ansible-playbook -i inventory.yml playbooks/init.yml
ansible-playbook -i inventory.yml playbooks/site.yml
ansible-playbook -i inventory.yml playbooks/unbound.yml
ansible-playbook -i inventory.yml playbooks/ssh-hardening.yml
ansible-playbook -i inventory.yml playbooks/fail2ban.yml
ansible-playbook -i inventory.yml playbooks/hardening-services.yml
ansible-playbook -i inventory.yml playbooks/check.yml

For ongoing operations:

ansible-playbook -i inventory.yml playbooks/maintenance.yml
ansible-playbook -i inventory.yml playbooks/check.yml

Final Notes

Running a Tor exit node is not just a packaging exercise.

You should expect abuse complaints, make sure your contact information is real, keep the host narrow in scope, and avoid mixing unrelated workloads onto it. But from a systems perspective, the basic shape is straightforward: bootstrap the OS, lock down SSH, expose only the ORPort, keep DNS local, and make updates boring.

That is exactly the kind of work Ansible is good at.