Sharing Port 8080 Between qBittorrent and V2Ray with Nginx (One Public Port, Two Applications) A case study on reverse proxying, WebSocket routing, firewall debugging, and safe staged deployment on an Oracle Ubuntu VPS

Introduction

I recently faced a network configuration problem that initially looked simple:

How can I install V2Ray on a VPS when the only usable general-purpose public port is already occupied by qBittorrent?

The server was an Oracle Cloud Ubuntu VPS running qbittorrent-nox. Its WebUI was publicly accessible on TCP port 8080, while BitTorrent peer traffic used TCP and UDP port 47374.

Other candidate ports, including 80, 443, and 56894, were locally unused. However, external testing showed that they were not reachable through the Oracle Cloud network configuration. I did not want to modify the Oracle Virtual Cloud Network rules again, interrupt qBittorrent whenever I needed V2Ray, or expose unnecessary additional ports.

The eventual solution was to make Nginx the sole public listener on port 8080 and route requests according to their HTTP path:

  • ordinary HTTP requests go to qBittorrent;
  • requests to a secret WebSocket path go to V2Ray;
  • qBittorrent peer traffic remains completely separate on port 47374.

The final architecture was:

Internet
   |
   | TCP 8080
   v
Nginx
   |
   |-- ordinary HTTP requests
   |       |
   |       v
   |   qBittorrent WebUI
   |   127.0.0.1:8081
   |
   `-- secret WebSocket path
           |
           v
       V2Ray VMess-WS
       127.0.0.1:10000

qBittorrent peer traffic:
TCP and UDP 47374

This arrangement allowed qBittorrent and V2Ray to run simultaneously without adding another publicly reachable application port. In the completed deployment, Nginx owned public port 8080, qBittorrent moved to 127.0.0.1:8081, and V2Ray listened only on 127.0.0.1:10000.


1. The original port allocation

Before making any changes, the important ports were:

Port Protocol Service Status
22 TCP OpenSSH Public and required
8080 TCP qBittorrent WebUI Public and working
47374 TCP and UDP qBittorrent peer traffic Required
80 TCP None Locally free but externally blocked
443 TCP None Locally free but externally blocked
56894 TCP None Locally free but externally blocked
8081 TCP None Available as a private backend
10000 TCP None Available as a private backend

A crucial distinction emerged during the investigation:

A locally unused port is not necessarily publicly reachable.

For an internet-facing service to work, three separate conditions must be satisfied:

  1. No local process is already using the port.
  2. The operating-system firewall permits the traffic.
  3. The cloud provider’s Security List or Network Security Group permits the traffic.

Oracle explicitly warns that both the cloud security rules and the instance’s operating-system firewall must be configured correctly.

In this case, ports 80, 443, and 56894 passed the first condition but failed the third.


2. Performing a read-only port audit

Before installing anything, I used a read-only audit to inspect the relevant processes, listeners, qBittorrent settings, and firewall rules.

for port in 22 80 443 8080 8081 10000 47374 56894; do
    tcp="$(
        sudo ss -H -lntp "sport = :$port" 2>/dev/null ||
        true
    )"

    udp="$(
        sudo ss -H -lnup "sport = :$port" 2>/dev/null ||
        true
    )"

    if [ -n "$tcp$udp" ]; then
        echo
        echo "----- PORT $port: IN USE -----"
        [ -n "$tcp" ] && printf '%s\n' "$tcp"
        [ -n "$udp" ] && printf '%s\n' "$udp"
    else
        echo
        echo "----- PORT $port: FREE LOCALLY -----"
    fi
done

echo
echo "===== qBittorrent process ====="
pgrep -a qbittorrent-nox || true

echo
echo "===== qBittorrent configured ports ====="
grep -E \
    '^(Session\\Port|WebUI\\Port|WebUI\\Address|WebUI\\HTTPS)' \
    "$HOME/.config/qBittorrent/qBittorrent.conf" \
    2>/dev/null ||
    true

echo
echo "===== Firewall ====="
sudo iptables -L INPUT -n -v --line-numbers

The output confirmed that:

  • qBittorrent owned TCP port 8080;
  • qBittorrent used TCP and UDP 47374 for peer traffic;
  • ports 8081 and 10000 were free;
  • ports 80, 443, and 56894 were locally free;
  • a general iptables REJECT rule appeared before some later port-specific rules.

The configured qBittorrent peer port was:

Session\Port=47374

3. Testing whether Oracle Cloud allowed the candidate ports

Because I could not rely on the Oracle dashboard history, I tested whether packets to the candidate ports actually reached the VPS.

The basic diagnostic method was:

  1. Insert a temporary, labelled iptables rule before the general rejection.
  2. Attempt a connection from another network.
  3. Check whether the rule’s packet counter increased.
  4. Remove the temporary rule.

For example:

PORT="443"

REJECT_LINE="$(
    sudo iptables -L INPUT -n --line-numbers |
    awk '$2 == "REJECT" {print $1; exit}'
)"

sudo iptables -I INPUT "$REJECT_LINE" \
    -p tcp \
    --dport "$PORT" \
    -m conntrack \
    --ctstate NEW \
    -m comment \
    --comment "VCN_PROBE_$PORT" \
    -j REJECT \
    --reject-with tcp-reset

From an external computer:

nc -vz -G 5 <VPS_PUBLIC_IP> 443

Then, on the VPS:

sudo iptables -L INPUT -n -v --line-numbers |
    grep 'VCN_PROBE'

Interpretation:

  • an increasing packet counter means the request reached Ubuntu;
  • a zero counter means the packet was probably blocked before reaching the VPS;
  • a timeout combined with a zero counter strongly suggests an Oracle Security List or NSG block.

The tests showed:

Port External result
22 Reachable
8080 Reachable
80 Timeout; no VPS packets
443 Timeout; no VPS packets
56894 Timeout; no VPS packets

This ruled out simply installing V2Ray on 80, 443, or 56894 without changing Oracle Cloud.


4. Why raw VMess TCP could not share port 8080

At first, I considered giving port 8080 directly to V2Ray and making qBittorrent private.

That would have produced:

Internet:8080 → V2Ray raw VMess TCP
127.0.0.1:8081 → qBittorrent WebUI

However, I still wanted to access qBittorrent normally through the same public address. Repeatedly stopping V2Ray to access qBittorrent would have been inconvenient and fragile.

Nginx solved this problem, but only after changing V2Ray’s transport.

Nginx is an HTTP reverse proxy. It can route HTTP requests based on hostnames and paths, but it cannot inspect an arbitrary raw VMess TCP stream and decide that one connection belongs to qBittorrent and another belongs to V2Ray.

Therefore, V2Ray had to use an HTTP-compatible transport:

VMess over WebSocket

Nginx can distinguish:

GET /                         → qBittorrent
GET /secret-websocket-path    → V2Ray

Nginx requires explicit WebSocket upgrade handling, while both sides of a V2Ray connection must use the same transport mode.


5. The final design

The selected layout was:

Public port 8080
        |
        v
      Nginx
        |
        |-- location /
        |      |
        |      v
        |   qBittorrent
        |   127.0.0.1:8081
        |
        `-- location = /<SECRET_WS_PATH>
               |
               v
            V2Ray
            127.0.0.1:10000

This preserved the original qBittorrent browser URL:

http://<VPS_PUBLIC_IP>:8080/

No /qbt/ prefix was introduced.

The public port allocation became:

Port Owner Purpose
22/TCP SSH Administration
8080/TCP Nginx Shared public HTTP/WebSocket endpoint
47374/TCP and UDP qBittorrent Peer connections
8081/TCP qBittorrent Private WebUI backend
10000/TCP V2Ray Private WebSocket backend

qBittorrent’s own documentation supports running the WebUI on a local address behind Nginx and forwarding the recognised proxy headers.


6. Why I used a staged installation

I deliberately separated the deployment into four stages:

  1. Read-only preflight.
  2. Nginx and qBittorrent preparation.
  3. Installation of only the official V2Ray files.
  4. V2Ray configuration and WebSocket integration.

This was safer than using an all-in-one V2Ray installer.

Some community installation scripts automatically create a default raw VMess TCP configuration. That is useful for a normal standalone installation, but it conflicts with a custom Nginx-shared WebSocket design.

The official V2Fly FHS installer installs the binary, systemd units, data files, log directories, and configuration path, but intentionally does not generate an operational proxy configuration.

That separation made it possible to verify qBittorrent after every important change.


Stage 1: Read-only preflight

The preflight checked:

  • the current user;
  • CPU architecture;
  • available disk space;
  • qBittorrent’s process;
  • ports 8080, 8081, 10000, and 47374;
  • whether Nginx or V2Ray was already installed;
  • whether qBittorrent answered locally;
  • whether the official installer source was reachable.

A simplified version is:

set -euo pipefail

QBT_CONF="$HOME/.config/qBittorrent/qBittorrent.conf"

[ "$EUID" -ne 0 ] || {
    echo "Run this as the normal Ubuntu user."
    exit 1
}

for command in sudo bash python3 curl ss pgrep awk grep df; do
    command -v "$command" >/dev/null 2>&1 || {
        echo "Missing command: $command"
        exit 1
    }
done

[ -f "$QBT_CONF" ] || {
    echo "qBittorrent configuration not found."
    exit 1
}

pgrep -x qbittorrent-nox >/dev/null || {
    echo "qBittorrent is not running."
    exit 1
}

sudo ss -H -lntp 'sport = :8080' |
    grep -q qbittorrent-nox || {
        echo "qBittorrent does not own port 8080."
        exit 1
    }

if sudo ss -H -lntp 'sport = :8081' | grep -q .; then
    echo "Port 8081 is occupied."
    exit 1
fi

if sudo ss -H -lntp 'sport = :10000' | grep -q .; then
    echo "Port 10000 is occupied."
    exit 1
fi

sudo ss -H -lntup |
    grep -E ':47374\b' |
    grep -q qbittorrent-nox || {
        echo "qBittorrent peer port was not found."
        exit 1
    }

HTTP_CODE="$(
    curl -sS \
        -o /dev/null \
        -w '%{http_code}' \
        --max-time 10 \
        http://127.0.0.1:8080/ ||
        true
)"

[ "$HTTP_CODE" != "000" ] || {
    echo "qBittorrent WebUI is not responding."
    exit 1
}

echo "Preflight passed."

Stage 2: Moving qBittorrent behind Nginx

6.1 Back up the qBittorrent configuration

Before editing anything:

STATE="$HOME/.shared-8080-v2ray"
QBT_CONF="$HOME/.config/qBittorrent/qBittorrent.conf"
STAMP="$(date +%Y%m%d-%H%M%S)"
QBT_BACKUP="$STATE/qBittorrent.conf.before-stage2-$STAMP"

mkdir -p "$STATE"
chmod 700 "$STATE"

cp -a "$QBT_CONF" "$QBT_BACKUP"
chmod 600 "$QBT_BACKUP"

echo "Backup: $QBT_BACKUP"

6.2 Stop qBittorrent cleanly

Because qBittorrent may rewrite its configuration when it exits, the safest sequence is:

  1. stop qBittorrent;
  2. wait until the process has exited;
  3. modify the configuration;
  4. restart it.
QBT_PID="$(pgrep -xo qbittorrent-nox || true)"

if [ -n "$QBT_PID" ]; then
    kill -TERM "$QBT_PID"

    for _ in $(seq 1 30); do
        kill -0 "$QBT_PID" 2>/dev/null || break
        sleep 1
    done
fi

When qBittorrent is managed by systemd, use the corresponding service command instead.

6.3 Modify the qBittorrent configuration with Python

I used Python rather than manual search-and-replace:

from pathlib import Path

path = Path.home() / ".config/qBittorrent/qBittorrent.conf"

settings = {
    r"WebUI\Address": "127.0.0.1",
    r"WebUI\Port": "8081",
    r"WebUI\UseUPnP": "false",
    r"WebUI\HTTPS\Enabled": "false",
    r"WebUI\LocalHostAuth": "true",
    r"WebUI\ReverseProxySupportEnabled": "true",
    r"WebUI\TrustedReverseProxiesList": "127.0.0.1",
}

lines = path.read_text(encoding="utf-8").splitlines()
found = {key: False for key in settings}
result = []

for line in lines:
    key = line.split("=", 1)[0] if "=" in line else None

    if key in settings:
        result.append(f"{key}={settings[key]}")
        found[key] = True
    else:
        result.append(line)

missing = [
    f"{key}={value}"
    for key, value in settings.items()
    if not found[key]
]

try:
    preferences_index = result.index("[Preferences]")
except ValueError:
    result.extend(["", "[Preferences]"])
    preferences_index = len(result) - 1

insert_at = len(result)

for index in range(preferences_index + 1, len(result)):
    if result[index].startswith("[") and result[index].endswith("]"):
        insert_at = index
        break

result[insert_at:insert_at] = missing

path.write_text("\n".join(result) + "\n", encoding="utf-8")

The resulting settings moved only the WebUI. The peer port 47374 was not changed.

6.4 Restart and verify qBittorrent

QBT_LOG="$STATE/qbittorrent-stage2.log"

nohup qbittorrent-nox \
    >"$QBT_LOG" \
    2>&1 \
    </dev/null &

sleep 5

pgrep -x qbittorrent-nox >/dev/null || {
    echo "qBittorrent failed to restart."
    tail -n 50 "$QBT_LOG"
    exit 1
}

sudo ss -H -lntp 'sport = :8081' |
    grep -q qbittorrent-nox || {
        echo "qBittorrent did not bind to 127.0.0.1:8081."
        exit 1
    }

7. Installing and configuring Nginx

Install Nginx from the Ubuntu repository:

sudo apt-get update
sudo apt-get install -y nginx

Then create:

/etc/nginx/sites-available/shared-8080

with this configuration:

server {
    listen 8080 default_server;
    listen [::]:8080 default_server;

    server_name _;
    client_max_body_size 100M;

    location = /<SECRET_WS_PATH> {
        proxy_pass http://127.0.0.1:10000;
        proxy_http_version 1.1;

        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        proxy_redirect off;
        proxy_buffering off;

        proxy_read_timeout 86400;
        proxy_send_timeout 86400;
    }

    location / {
        proxy_pass http://127.0.0.1:8081;
        proxy_http_version 1.1;

        proxy_set_header Host $proxy_host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Host $http_host;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_redirect off;
    }
}

The WebSocket location requires the HTTP/1.1 upgrade headers, while the root location forwards the headers recognised by qBittorrent.

Enable the site:

sudo rm -f /etc/nginx/sites-enabled/default

sudo ln -sfn \
    /etc/nginx/sites-available/shared-8080 \
    /etc/nginx/sites-enabled/shared-8080

sudo nginx -t
sudo systemctl enable nginx
sudo systemctl restart nginx

Validate the public arrangement:

sudo ss -lntp |
    grep -E ':(8080|8081)\b'

curl -sS \
    -o /dev/null \
    -w 'HTTP status: %{http_code}\n' \
    http://127.0.0.1:8080/

The expected result is:

0.0.0.0:8080       nginx
[::]:8080           nginx
127.0.0.1:8081      qbittorrent-nox
HTTP status: 200

In the completed deployment, the original qBittorrent URL continued to work, Nginx owned 8080, and qBittorrent answered privately on 8081.


Stage 3: Installing only the official V2Ray files

The official FHS installer can be downloaded and executed as follows:

STATE="$HOME/.shared-8080-v2ray"
INSTALLER="$STATE/install-release.sh"

curl -fL \
    --retry 3 \
    --connect-timeout 15 \
    https://raw.githubusercontent.com/v2fly/fhs-install-v2ray/master/install-release.sh \
    -o "$INSTALLER"

chmod 700 "$INSTALLER"
sudo bash "$INSTALLER"

The installer creates paths such as:

/usr/local/bin/v2ray
/usr/local/share/v2ray/geoip.dat
/usr/local/share/v2ray/geosite.dat
/usr/local/etc/v2ray/config.json
/etc/systemd/system/v2ray.service
/var/log/v2ray/

The installer does not automatically construct the final proxy node, which is exactly what I wanted for this custom architecture.

I stopped the service until the real configuration was ready:

sudo systemctl stop v2ray 2>/dev/null || true

At this point:

Nginx: active
qBittorrent: active
V2Ray files: installed
V2Ray service: inactive
Port 10000: unused

Stage 4: Configuring V2Ray as a private WebSocket backend

8.1 Generate private credentials

Generate a new UUID and a random WebSocket path:

VMESS_UUID="$(
    python3 -c 'import uuid; print(uuid.uuid4())'
)"

WS_PATH="/ws-$(
    python3 -c 'import secrets; print(secrets.token_hex(18))'
)"

printf 'UUID: %s\n' "$VMESS_UUID"
printf 'Path: %s\n' "$WS_PATH"

Store these values in a root- or user-readable-only file. Do not commit them to GitHub.

8.2 V2Ray server configuration

Write the following structure to:

/usr/local/etc/v2ray/config.json
{
  "log": {
    "loglevel": "warning"
  },
  "inbounds": [
    {
      "tag": "vmess-ws-in",
      "listen": "127.0.0.1",
      "port": 10000,
      "protocol": "vmess",
      "settings": {
        "clients": [
          {
            "id": "<REDACTED_UUID>",
            "alterId": 0,
            "level": 0
          }
        ],
        "disableInsecureEncryption": true
      },
      "streamSettings": {
        "network": "ws",
        "security": "none",
        "wsSettings": {
          "path": "/<SECRET_WS_PATH>"
        }
      }
    }
  ],
  "outbounds": [
    {
      "tag": "direct",
      "protocol": "freedom",
      "settings": {}
    },
    {
      "tag": "blocked",
      "protocol": "blackhole",
      "settings": {}
    }
  ]
}

The significant choices are:

listen: 127.0.0.1
port: 10000
protocol: vmess
network: ws

V2Ray is not publicly exposed directly. Only Nginx can reach it through the loopback interface. This matches the configuration used in the completed deployment.

8.3 Validate and start V2Ray

sudo /usr/local/bin/v2ray test \
    -config /usr/local/etc/v2ray/config.json

sudo systemctl daemon-reload
sudo systemctl enable --now v2ray

Verify:

systemctl is-active v2ray

sudo ss -H -lntp 'sport = :10000'

Expected:

active
127.0.0.1:10000 users:(("v2ray",...))

9. Clash client configuration

A redacted Clash-compatible configuration is:

- name: "Oracle-Shared-8080"
  type: vmess
  server: <VPS_PUBLIC_IP>
  port: 8080
  uuid: <REDACTED_UUID>
  alterId: 0
  cipher: auto
  udp: true
  tls: false
  network: ws
  ws-opts:
    path: "/<SECRET_WS_PATH>"
    headers:
      Host: "<VPS_PUBLIC_IP>"

The client and server must use the same:

  • UUID;
  • WebSocket path;
  • transport;
  • port;
  • TLS setting.

The direct configuration in this deployment used plain WebSocket rather than WSS because the only confirmed public application port was 8080.


10. Testing the WebSocket route

A useful local handshake test is:

curl --http1.1 \
    -sS \
    -o /dev/null \
    -w '%{http_code}\n' \
    --max-time 3 \
    -H 'Connection: Upgrade' \
    -H 'Upgrade: websocket' \
    -H 'Sec-WebSocket-Version: 13' \
    -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    "http://127.0.0.1:8080/<SECRET_WS_PATH>"

The ideal response is:

101

HTTP 101 Switching Protocols means that Nginx successfully upgraded the HTTP connection to WebSocket and forwarded it to V2Ray.

During my test, curl also printed:

curl: (28) Operation timed out after approximately 3 seconds

This initially looked like an error, but the recorded HTTP code was still 101.

That combination is understandable: after a successful WebSocket upgrade, the connection is expected to remain open. Curl reaches its configured maximum duration because it is no longer handling an ordinary request-response transaction.

For this test:

HTTP 101 = success
curl timeout after the upgrade = not necessarily a failure

Cloudflare’s documentation similarly describes 101 as the successful WebSocket-upgrade response.


Debugging incident 1: APT locks during the Nginx installation

The most time-consuming problem was not Nginx or V2Ray. It was Ubuntu’s package manager.

When Stage 2 attempted to install Nginx, APT reported that package locks were held.

11.1 Do not delete lock files

A common but dangerous reaction is:

sudo rm /var/lib/dpkg/lock
sudo rm /var/lib/dpkg/lock-frontend

I did not do this.

Deleting a lock file does not safely stop the process holding the underlying lock. It risks allowing simultaneous package operations and corrupting the package database.

Instead, I identified the actual lock holder:

for lock in \
    /var/lib/apt/lists/lock \
    /var/lib/dpkg/lock \
    /var/lib/dpkg/lock-frontend \
    /var/cache/apt/archives/lock
do
    echo
    echo "===== $lock ====="
    sudo fuser -v "$lock" 2>/dev/null ||
        echo "Free"
done

11.2 A genuinely stale process

One process turned out to be:

apt-get -qq -y update

It had been running for approximately 46 days.

Before terminating it, I verified:

  • its exact command;
  • its elapsed runtime;
  • its parent process;
  • its child processes;
  • that the PID had not been reused.

Only after those checks was the stale process stopped.

11.3 A new legitimate unattended upgrade

After the stale process was removed, Ubuntu immediately started overdue automatic maintenance.

This new process:

  • had started recently;
  • was actively consuming CPU;
  • had changing child processes;
  • was associated with apt-daily-upgrade.service;
  • was writing current unattended-upgrade logs.

It was therefore not killed.

Ubuntu uses apt-daily.timer and apt-daily-upgrade.timer to trigger package-list refreshes and unattended upgrades. These processes can legitimately delay another package installation.

The lesson was:

Do not classify a process as stale merely because it holds an APT lock. Inspect its age, activity, service ownership, children, and logs.

11.4 Leftover process after service completion

Later, apt-daily-upgrade.service reported success, but one leftover unattended-upgrade process still held two locks.

Before stopping it, I verified that:

  • the service was no longer active;
  • the service result was success;
  • no active dpkg child remained;
  • the PID still belonged to the expected process.

I temporarily paused the two APT timers during the controlled cleanup, released the leftover process, repaired the package state, and restored the timers afterward.

The final validation included:

sudo dpkg --configure -a
sudo apt-get -f install
sudo dpkg --audit
sudo apt-get check

Only after every lock was free and the package state was valid did I retry the Nginx installation.


Debugging incident 2: qBittorrent was working but “unreachable”

After completing the Nginx and V2Ray configuration, an external port test reported that qBittorrent’s incoming peer port was closed.

This was initially surprising because:

  • qBittorrent was running normally;
  • it was listening on TCP and UDP port 47374;
  • its WebUI remained accessible through Nginx;
  • outbound peer connections continued to work.

However, successful outbound traffic does not prove that a service can accept new inbound connections.

12.1 The firewall-order error

The firewall showed:

7  REJECT all incoming unmatched traffic
8  ACCEPT TCP 47374
9  ACCEPT UDP 47374

iptables evaluates rules from top to bottom.

Therefore, the packet was rejected at rule 7 before it could ever reach rules 8 or 9. Their packet counters remained zero.

The original rules existed but were functionally useless.

12.2 Correcting the order

First, back up the firewall:

STAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP="/home/ubuntu/iptables-before-peer-fix-$STAMP.rules"

sudo sh -c "iptables-save > '$BACKUP'"
sudo chmod 600 "$BACKUP"

Find the first general rejection:

REJECT_LINE="$(
    sudo iptables -L INPUT -n --line-numbers |
    awk '$2 == "REJECT" {print $1; exit}'
)"

Insert the UDP and TCP rules before it:

PORT="47374"

sudo iptables -I INPUT "$REJECT_LINE" \
    -p udp \
    --dport "$PORT" \
    -m conntrack \
    --ctstate NEW \
    -m comment \
    --comment "QBITTORRENT_PEER_UDP" \
    -j ACCEPT

REJECT_LINE="$(
    sudo iptables -L INPUT -n --line-numbers |
    awk '$2 == "REJECT" {print $1; exit}'
)"

sudo iptables -I INPUT "$REJECT_LINE" \
    -p tcp \
    --dport "$PORT" \
    -m conntrack \
    --ctstate NEW \
    -m comment \
    --comment "QBITTORRENT_PEER_TCP" \
    -j ACCEPT

Save the corrected rules:

sudo mkdir -p /etc/iptables
sudo sh -c 'iptables-save > /etc/iptables/rules.v4'

if command -v netfilter-persistent >/dev/null 2>&1; then
    sudo netfilter-persistent save
fi

The corrected order became:

ACCEPT UDP 47374
ACCEPT TCP 47374
REJECT everything else

An external port checker then reported:

Port 47374 is open.

The tracker’s “unreachable” notice remained temporarily because its own connectability checks ran only periodically.


13. Final verification

A useful final health check is:

echo "===== Services ====="
echo "Nginx:       $(systemctl is-active nginx)"
echo "V2Ray:      $(systemctl is-active v2ray)"
echo "qBittorrent: $(pgrep -x qbittorrent-nox >/dev/null && echo active || echo inactive)"

echo
echo "===== Listeners ====="
sudo ss -lntup |
    grep -E ':(8080|8081|10000|47374)\b' ||
    true

echo
echo "===== Nginx configuration ====="
sudo nginx -t

echo
echo "===== V2Ray configuration ====="
sudo /usr/local/bin/v2ray test \
    -config /usr/local/etc/v2ray/config.json

echo
echo "===== qBittorrent through Nginx ====="
curl -sS \
    -o /dev/null \
    -w 'HTTP status: %{http_code}\n' \
    http://127.0.0.1:8080/

Expected state:

Nginx: active
V2Ray: active
qBittorrent: active

0.0.0.0:8080       nginx
[::]:8080           nginx
127.0.0.1:8081      qbittorrent-nox
127.0.0.1:10000     v2ray
*:47374              qbittorrent-nox

qBittorrent HTTP status: 200
WebSocket HTTP status: 101

Optional extension: placing Cloudflare in front

After the direct configuration worked, I also tested a Cloudflare Worker in front of the VPS.

The traffic path became:

Local client
   |
   v
Cloudflare Worker
   |
   v
Oracle VPS Nginx :8080
   |
   v
V2Ray :10000
   |
   v
Destination website

This does not change the final exit address: destination websites still see the Oracle VPS IP.

A stricter future Worker could accept only the exact WebSocket path:

export default {
  async fetch(request, env) {
    const incoming = new URL(request.url);
    const upgrade = request.headers.get("Upgrade");

    const validRequest =
      request.method === "GET" &&
      upgrade !== null &&
      upgrade.toLowerCase() === "websocket" &&
      incoming.pathname === env.WS_PATH;

    if (!validRequest) {
      return new Response("Not found", {
        status: 404,
        headers: {
          "Cache-Control": "no-store",
        },
      });
    }

    const upstream = new URL(request.url);

    upstream.protocol = "http:";
    upstream.hostname = env.ORIGIN_HOST;
    upstream.port = "8080";

    return fetch(new Request(upstream, request));
  },
};

Cloudflare recommends Module Worker syntax over the older Service Worker format, although the older format remains supported. Sensitive values should be stored as Worker secrets rather than directly in the source code.

Cloudflare supports proxied HTTP traffic on port 8080 and HTTPS traffic on port 443.

For stronger privacy between the local client and Cloudflare, a later configuration should use:

server: <WORKER_HOST>
port: 443
tls: true
network: ws

rather than:

port: 8080
tls: false

Without TLS, the local ISP may see the HTTP hostname and WebSocket path even though the VMess payload itself is not ordinary plaintext web traffic.


14. Security considerations

This deployment worked, but several security limitations should be acknowledged.

14.1 Plain HTTP on port 8080

The direct qBittorrent WebUI and V2Ray WebSocket route initially used:

HTTP / WS

rather than:

HTTPS / WSS

A future domain, TLS certificate, and public port 443 would be preferable. qBittorrent documents an Nginx HTTPS reverse-proxy architecture in which the external connection is encrypted while the local Nginx-to-qBittorrent connection remains HTTP.

14.2 Do not expose private backends

The following ports should remain bound to loopback only:

127.0.0.1:8081
127.0.0.1:10000

Only Nginx should own the public application port.


15. Operational issues discovered afterward

One unrelated system-maintenance issues appeared after the deployment.

Pending kernel reboot

Ubuntu reported that a newer Oracle kernel had been installed but was not yet running.

The server was not rebooted during the configuration because all services were working and the installation was being performed through SSH.

A controlled maintenance-window reboot should eventually be performed after confirming that:

  • Nginx starts automatically;
  • V2Ray starts automatically;
  • qBittorrent has a reliable startup method;
  • private credentials are backed up.

Conclusion

The key insight from this project was that the solution was not simply “install V2Ray on another port.”

The real problem involved several independent layers:

  • local process ownership;
  • Ubuntu firewall ordering;
  • Oracle Cloud ingress rules;
  • HTTP versus raw TCP routing;
  • qBittorrent’s WebUI and peer-port separation;
  • package-manager locks;
  • WebSocket handshake behaviour;
  • tracker connectability testing.

The final solution was:

Public TCP 8080
    |
    v
Nginx
    |
    |-- / → qBittorrent 127.0.0.1:8081
    |
    `-- /<SECRET_WS_PATH> → V2Ray 127.0.0.1:10000

qBittorrent peer traffic:
TCP/UDP 47374

This configuration achieved the original objectives:

  • qBittorrent remained continuously available;
  • the original WebUI URL continued to work;
  • V2Ray and qBittorrent shared one public application port;
  • no additional Oracle Cloud ingress port was required;
  • V2Ray remained private behind Nginx;
  • qBittorrent’s peer port remained separate;
  • the firewall connectability error was identified and fixed;
  • every important stage had backups and validation.

The broader lesson is simple:

When only one public port is available, do not make two applications compete for it. Give the port to a protocol-aware reverse proxy and move the applications behind it.