Skip to content

Instantly share code, notes, and snippets.

@Zahorone
Forked from Lvdwardt/rustdesk.yml
Last active December 1, 2025 21:23
Show Gist options
  • Select an option

  • Save Zahorone/984e75c6a3e8c4a1706f81b6c5e55071 to your computer and use it in GitHub Desktop.

Select an option

Save Zahorone/984e75c6a3e8c4a1706f81b6c5e55071 to your computer and use it in GitHub Desktop.
Rustdesk + Nginx proxy manager + permanent setting of NFTables to suppress Docker bypass of UFW
version: '3'
networks:
rustdesk-net:
external: false
services:
nginx-proxy-manager:
image: 'jc21/nginx-proxy-manager:latest'
restart: unless-stopped
ports:
# These ports are in format <host-port>:<container-port>
- '80:80' # Public HTTP Port
- '443:443' # Public HTTPS Port
- '127.0.0.1:8081:81' # Admin Web Port. NPM bypass UFW. 127.0.0.1 is easy setting to fix it from localhost only.
# Add any other Stream port you want to expose
# - '21:21' # FTP
# Ports needed for Rustdesk:
- '21115:21115'
- '21116:21116'
- '21116:21116/udp'
- '21117:21117'
- '21118:21118'
- '21119:21119'
# Uncomment the next line if you uncomment anything in the section
# environment:
# Uncomment this if you want to change the location of
# the SQLite DB file within the container
# DB_SQLITE_FILE: "/data/database.sqlite"
# Uncomment this if IPv6 is not enabled on your host/
# DISABLE_IPV6: 'true'
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
networks:
- rustdesk-net
hbbs:
container_name: hbbs
image: rustdesk/rustdesk-server:latest
command: hbbs -r rustdesk.yourDomain.com:21117
volumes:
- ./data:/root
networks:
- rustdesk-net
depends_on:
- hbbr
restart: unless-stopped
hbbr:
container_name: hbbr
image: rustdesk/rustdesk-server:latest
command: hbbr
volumes:
- ./data:/root
networks:
- rustdesk-net
restart: unless-stopped
@Zahorone
Copy link
Author

Zahorone commented Oct 8, 2025

Best option is to have new installed server. In this example I will use Ubuntu 24.04.3 LTS.
There are 3 main steps.
a. Setting Docker
b. Setting Firewall
c. Setting Nginx Proxy Manager

A. .......... Setting Docker

  1. Install Docker Engine https://docs.docker.com/engine/install/ubuntu/

  2. Verify that Docker is running
    sudo systemctl status docker

Snímka obrazovky 2025-10-08 o 15 32 48
  1. Create directory
    sudo mkdir /opt/rustnpm

  2. Open Directory
    cd /opt/rustnpm

  3. Create file docker-compose.yml
    sudo nano docker-compose.yml

  4. Copy rustdesk.yml code to new file
    DO NOT FORGET to change line 44 command: hbbs -r rustdesk.yourDomain.com:21117 change yourDomain.whatever:21117 there and save it.

  5. Run docker compose
    sudo docker compose up -d detached)

  6. Find Docker containers IP addresses
    sudo docker inspect -f '{{.Name}} - {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(sudo docker ps -aq)

Snímka obrazovky 2025-10-08 o 16 05 36
  1. Open Directory
    /opt/rustnpm/data
    Find and open file id_ed25519.pub
    nano id_ed25519.pub it will be something like this: HypOPa2nh6njLd1gfQOn8T+CGT7lxlg5t7pkjS1xI9s=
    This is your ID/Relay server Key
    We are done with first part.

B. .......... Setting Firewall. UFW

  1. I use SSH tunnelling and non standard port for SSH, ufw already is started, port 2223 is my OpenSSH port
Snímka obrazovky 2025-10-08 o 19 36 54

rustdesk-net is a internal network so all open ports goes to NPM. Personally I deny external access to NPM admin port.
I allow it through SSH tunnelling only. localhost:8081
So allow just necessary ports and deny port 8081:

sudo ufw allow 80
sudo ufw allow 443
sudo ufw allow 21115:21119/tcp
sudo ufw allow 21116/udp
sudo ufw deny 8081

then reload UFW

sudo ufw reload

We are done B part

C. .......... Setting Nginx Proxy Manager

I use SSH tunnelling and PublicKey instead of password so in terminal I use .ssh/config file to make access easier a set port forwarding
Snímka obrazovky 2025-10-08 o 19 56 02

Host merlin
HostName server IP or domain name
Port 2223 (your OpenSSH port)
User merlin (your user name)
IdentityFile ~/.ssh/merlin.pem (your key file)
LocalForward 9090 127.0.0.1:9090 (not necessary)
LocalForward 8081 127.0.0.1:8081 (NPM will be on localhost:8081 in your browser)

  1. After ssh merlin port forwarding is set and just type localhost:8081 in your browser
    default credentials:
    email: admin@example.com
    password: changeme
Snímka obrazovky 2025-10-08 o 20 05 56
  1. Then edit Administrator and change password

  2. Now go to Dashboard. We need to make two settings.
      - Secure your Domain address via SSL certificate on 404 Hosts (Free version of Rustdesk doesn't use HTTPS)
      - Rustdesk can be accessed through Streams only. 
    
  3. Go to 404 Hosts and Add your domain to 404 Host and request SSL certificate, select Force SSL, and Agree with terms

Snímka obrazovky 2025-10-08 o 20 21 36
  1. We looked for Docker containers hbbs and hbbr IP and necessary ports

    IP adresses check with sudo docker inspect -f '{{.Name}} - {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(sudo docker ps -aq)

In this case:
/hbbs - 172.18.0.4 Ports 21115/tcp, 21116/tcp and udp, 21118/tcp
/hbbr - 172.18.0.3 Ports 21117/tcp, 21119/tcp

Now we set streams go to Streams and set like this

There is a catch. After restart docker there is a change of internal IP. So the most safe way is add streams like this
Snímka obrazovky 2025-10-15 o 23 04 20

And WE ARE DONE

Now set Rustdesk application (You can skip API server in free version)
Snímka obrazovky 2025-10-08 o 20 46 59

@Zahorone
Copy link
Author

Zahorone commented Oct 17, 2025

If you want to change NFT (IP) tables instead of changing line 16 - '127.0.0.1:8081:81' in docker-compose.yml
Here is how to do it.
Problem: DOCKER-USER Chain Rules Disappear After Reboot
After server restart, the DOCKER-USER chain is empty and custom firewall rules (e.g., blocking port 8081) disappear.

Root Cause
Docker completely resets its firewall on every startup. The daemon clears all custom rules and recreates the necessary chains (DOCKER, DOCKER-ISOLATION, DOCKER-USER). Since /etc/nftables.conf loads before the Docker service starts, Docker subsequently overwrites any custom modifications in DOCKER-USER.

Solution: Systemd Service with Automatic Rule Injection
Step 1: Create Firewall Rules Script

sudo nano /usr/local/bin/docker-firewall-rules.sh

Script content:

#!/bin/bash
#Add custom rules to DOCKER-USER after Docker starts
# Block port 8081 for IPv4
/usr/sbin/nft add rule ip filter DOCKER-USER tcp dport 8081 drop
# Block port 8081 for IPv6
/usr/sbin/nft add rule ip6 filter DOCKER-USER tcp dport 8081 drop

This version includes all the status messages so it’s easy to see which rules were removed,
left unchanged, newly added, or already present in your firewall.
It's fully idempotent and always logs every action

#!/bin/bash
PORTS=(82 8081 8888) # Add more ports to this list as needed

# First, get all ports currently present in chains (IPv4/IPv6)
# IPv4
for RULE_PORT in $(sudo nft list chain ip filter DOCKER-USER | grep -oP 'tcp dport \K[0-9]+'); do
  if [[ ! " ${PORTS[@]} " =~ " ${RULE_PORT} " ]]; then
    # Remove old rule
    INDEX=$(sudo nft list chain ip filter DOCKER-USER | grep -n "tcp dport $RULE_PORT drop" | awk -F: '{print $1}')
    sudo nft delete rule ip filter DOCKER-USER "$INDEX"
    echo "Removed rule for IPv4 port $RULE_PORT"
  else
    echo "Rule for IPv4 port $RULE_PORT left unchanged (present in PORTS)"
  fi
done

# IPv6
for RULE_PORT in $(sudo nft list chain ip6 filter DOCKER-USER | grep -oP 'tcp dport \K[0-9]+'); do
  if [[ ! " ${PORTS[@]} " =~ " ${RULE_PORT} " ]]; then
    INDEX=$(sudo nft list chain ip6 filter DOCKER-USER | grep -n "tcp dport $RULE_PORT drop" | awk -F: '{print $1}')
    sudo nft delete rule ip6 filter DOCKER-USER "$INDEX"
    echo "Removed rule for IPv6 port $RULE_PORT"
  else
    echo "Rule for IPv6 port $RULE_PORT left unchanged (present in PORTS)"
  fi
done

# Then, add ports idempotently
for PORT in "${PORTS[@]}"; do
  # IPv4
  if ! sudo nft list chain ip filter DOCKER-USER | grep -q "tcp dport $PORT drop"; then
    sudo nft add rule ip filter DOCKER-USER tcp dport $PORT drop
    echo "Added: drop for IPv4 port $PORT"
  else
    echo "Rule already exists for IPv4 port $PORT"
  fi
  # IPv6
  if ! sudo nft list chain ip6 filter DOCKER-USER | grep -q "tcp dport $PORT drop"; then
    sudo nft add rule ip6 filter DOCKER-USER tcp dport $PORT drop
    echo "Added: drop for IPv6 port $PORT"
  else
    echo "Rule already exists for IPv6 port $PORT"
  fi
done

Make it executable:

sudo chmod +x /usr/local/bin/docker-firewall-rules.sh

Step 2: Create Systemd Service Unit

sudo nano /etc/systemd/system/docker-firewall.service

Service file content:

[Unit]
Description=Docker Firewall Rules
After=docker.service
Requires=docker.service

[Service]
Type=oneshot
ExecStart=/usr/local/bin/docker-firewall-rules.sh
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target

This service runs automatically after the Docker daemon starts, ensuring rules are added only after Docker creates its chains.

Step 3: Enable the Service

sudo systemctl daemon-reload
sudo systemctl enable docker-firewall.service
sudo systemctl start docker-firewall.service

Step 4: Verify Functionality

Check if the rule was added:

sudo nft list ruleset | grep -A 5 'chain DOCKER-USER'

Expected output:

chain DOCKER-USER {
		tcp dport 8081 drop
	}
}

table ip6 filter {
	chain ufw6-before-logging-input {
--
	chain DOCKER-USER {
		tcp dport 8081 drop
	}
}

Testing Persistence After Reboot

Reboot the server:

sudo reboot

After reboot, verify the rule persists:

sudo nft list ruleset | grep -A 5 'chain DOCKER-USER'

Troubleshooting
If rules still disappear, check service status:

sudo systemctl status docker-firewall.service
sudo journalctl -u docker-firewall.service -n 20

The service should show Active: active (exited) with status=0/SUCCESS.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment