A Python CLI tool that deploys multiple anti-censorship VPN services on a single Linux VM using Docker Compose.
VPN007 generates all the configuration files needed to run Xray (VLESS+Reality), AmneziaWG 2.0, and Tailscale behind an Nginx reverse proxy with a legitimate cover website. Traffic is routed through standard HTTPS ports (443/tcp) using a two-layer architecture: Layer 4 SNI-based routing sends Reality traffic directly to Xray, while everything else goes through Layer 7 path-based routing with TLS termination.
The tool also provisions an nftables firewall with AS/subnet blocking, sets up systemd timers for blocklist updates and hostname resolution, manages TLS certificates via Let's Encrypt, and can generate forwarding scripts for multi-VM relay architectures.
- Features
- Prerequisites
- Installation
- Usage
- Run modes
- Quick start
- Deploying to a remote server without source code
- CLI options
- Configuration precedence
- Input validation
- Generated output structure
- Docker containers
- Common operations
- Firewall management script (
vpn007-fw.sh) - TLS certificate management
- Lab/staging deployment
- Low-memory deployment (1 GB RAM)
- SSH access security
- Brute-force protection
- Admin credentials
- Inter-VM forwarding (relay architecture)
- Supported tunnel types
- Step 1: Configure VM-A
- Step 2: Prepare VM-B
- Step 3: Run the forwarding script on VM-B
- Step 4: Verify the tunnel
- Reverse-initiated connections
- Multiple entrance nodes sharing one exit node
- Dual-role: VM as both VPN node and exit node
- Disabling forwarding to an exit node
- Disabling the exit-node role on this VM
- Backup and restore
- Upgrading
- IPv6 support
- Health checks and monitoring
- Log management
- Kernel parameters
- CLI reference
- Running tests
- Uninstalling VPN007
- Rollback
- Container resource limits
- Docker network isolation
- Web panel rate limiting
- Security hardening (AppArmor)
- Credits
- License
- Xray VLESS+Reality — VPN traffic indistinguishable from legitimate TLS 1.3 connections
- AmneziaWG 2.0 — WireGuard with full obfuscation parameter set (S1-S4, H1-H4, I1-I5) for DPI resistance
- Tailscale — Mesh overlay network for secure management and exit node
- Cover website — Static or reverse-proxied legitimate site served by default
- nftables firewall — Default-deny policy with AS/subnet blocking and automatic prefix resolution
- Multi-IP support — Separate incoming and outgoing IP addresses
- Inter-VM forwarding — Encrypted tunnel relay via WireGuard, SSH, or Tailscale
- TLS certificate management — Automated Let's Encrypt with dynamic port 80 opening
- Documentation generation — README, troubleshooting guide, and client connection guides tailored to your deployment
| OS | Version |
|---|---|
| Debian | 11+ (Bullseye) |
| Ubuntu | 22.04+ (Jammy) |
| Alpine Linux | 3.18+ |
| Dependency | Version | Purpose |
|---|---|---|
| Python | 3.12+ | VPN007 runtime |
| Docker Engine | Latest stable | Container runtime |
| Docker Compose | v2+ (plugin) | Service orchestration |
| nftables | System package | Firewall |
| curl | System package | IP detection, health checks |
| dig (dnsutils) | System package | Hostname resolution for access control |
| git | System package | Repository management |
| Package | Version | Purpose |
|---|---|---|
| python-dotenv | ≥1.0 | .env file parsing |
| Jinja2 | ≥3.1 | Template rendering |
| cryptography | ≥42.0 | Key generation (x25519, WireGuard) |
Dev dependencies (for running tests):
| Package | Version |
|---|---|
| pytest | ≥8.0 |
| hypothesis | ≥6.100 |
| pyyaml | ≥6.0 |
| ruff | ≥0.4 |
| Resource | Bare minimum | Minimum | Recommended |
|---|---|---|---|
| CPU | 1 vCPU | 1 vCPU | 2+ vCPU |
| RAM | 1 GB — up to 10 clients (swap auto-provisioned) | 2 GB — up to 20-30 clients | 4 GB — many concurrent clients |
| Disk | 20 GB | 20 GB | 30 GB |
Disk usage grows with VPN client count, Docker image layers, and log retention. Additional resources are needed when inter-VM forwarding is enabled.
# Clone the repository
git clone https://github.com/Homas/vpn007.git
cd vpn007
# Create a virtual environment
python3 -m venv .venv
source .venv/bin/activate
# Install the package
pip install -e .
# (Optional) Install dev dependencies for testing
pip install -e ".[dev]"VPN007 supports three primary run modes:
| Mode | Command | What it does |
|---|---|---|
| Dry-run | vpn007 --dry-run |
Generates all config files without deploying anything |
| Full deploy | sudo vpn007 |
Generates configs, starts containers, applies firewall, installs timers |
| Non-interactive | AUTO_INSTALL=y sudo vpn007 |
Full deploy without prompts (for scripted/CI use) |
# 1. Copy and edit the environment file
cp .env.sample .env
vim .env # Set DOMAIN, APPROVED_IPS, SSH_APPROVED_IPS, TAILSCALE_AUTH_KEY
# 2. Preview what will be generated
vpn007 --dry-run
# 3. Deploy everything
sudo vpn007The generated output is self-contained. You can generate configs locally and deploy them anywhere:
# On your dev machine
vpn007 --domain vpn.example.com --output-dir ./deploy --dry-run
# Copy to server
scp -r ./deploy/ root@your-server:/opt/vpn007/
# On the server — start services
cd /opt/vpn007
docker compose up -d
# Then manually:
# 1. Install systemd timers (copy systemd/*.service and *.timer to /etc/systemd/system/)
# 2. Apply firewall: nft -f /opt/vpn007/nftables.conf
# 3. Acquire TLS cert (see TLS section below)All parameters can be set via CLI flags, .env file, or both. CLI flags take precedence over .env values.
vpn007 [OPTIONS]
These flags are CLI-only and cannot be set via .env file.
| Flag | Description |
|---|---|
--dry-run |
Generate config files only — no containers, no firewall, no timers |
--debug |
Enable verbose debug logging (full command stdout/stderr on console) |
--env-file PATH |
Path to .env file (default: .env in current directory) |
--version |
Show version and exit |
The AUTO_INSTALL=y environment variable can be set to skip interactive prompts (for scripted/CI deployments). It is not a CLI flag.
| Flag | Env var | Default | Description |
|---|---|---|---|
--domain |
DOMAIN |
(required) | Primary domain name for the VPN server |
--reality-sni |
REALITY_SNI |
www.microsoft.com |
SNI target for Xray Reality (must support TLS 1.3) |
--cover-site-mode |
COVER_SITE_MODE |
static |
Cover site mode: static or proxy |
--cover-site-url |
COVER_SITE_URL |
(none) | URL to proxy (required when mode=proxy) |
--cover-site-static-path |
COVER_SITE_STATIC_PATH |
(none) | Path to static files (optional when mode=static) |
| Flag | Env var | Default | Description |
|---|---|---|---|
--xui-path-prefix |
XUI_PATH_PREFIX |
/secretpanel-<random> |
URL path for 3x-ui web panel |
--awg-panel-path-prefix |
AWG_PANEL_PATH_PREFIX |
/awgadmin-<random> |
URL path for AmneziaWG panel |
--enable-port-8443 |
ENABLE_PORT_8443 |
false |
Enable secondary HTTPS port 8443 |
When not explicitly set, the deployer appends a random 6-character suffix to the default panel path prefixes (e.g., /secretpanel-a7f3b2). This prevents adversaries who know the tool from probing predictable paths. Set explicit values in .env if you need stable URLs.
| Flag | Env var | Default | Description |
|---|---|---|---|
--xray-internal-port |
XRAY_INTERNAL_PORT |
10443 |
Internal container port for Xray |
Reality key pair (private key, public key, short_id) is auto-generated at deploy time if not provided.
| Flag | Env var | Default | Description |
|---|---|---|---|
--awg-listen-port |
AWG_LISTEN_PORT |
(random 10000-65535) | UDP listen port on host (randomized to avoid DPI fingerprinting on standard port 51820) |
--awg-panel-port |
AWG_PANEL_PORT |
51821 |
Web panel port (local-only) |
Obfuscation parameters (env vars only — provide all or none for auto-generation):
| Env var | Range | Description |
|---|---|---|
AWG_S1 |
0-1132 (rec. 15-150) | Random prefix for Init packets |
AWG_S2 |
0-1188 (rec. 15-150) | Random prefix for Response packets |
AWG_S3 |
0-1216 (rec. 15-150) | Random prefix for Cookie packets |
AWG_S4 |
0-32 | Random prefix for Data packets |
AWG_H1 |
Range min-max in 5-2147483647 |
Dynamic header range for Init packets (AmneziaWG 2.0) |
AWG_H2 |
Range min-max in 5-2147483647 |
Dynamic header range for Response packets |
AWG_H3 |
Range min-max in 5-2147483647 |
Dynamic header range for Cookie packets |
AWG_H4 |
Range min-max in 5-2147483647 |
Dynamic header range for Data packets |
AWG_JC |
1-128 (rec. 4-10) | Junk packet count |
AWG_JMIN |
0-1280 | Min junk packet size (default: 50) |
AWG_JMAX |
0-1280 | Max junk packet size (default: 1000) |
CPS signature packets (protocol imitation — makes traffic look like a known UDP protocol):
| Env var | Default | Description |
|---|---|---|
AWG_I1 |
<b 0x000100002112a442><r 12> |
STUN Binding Request (WebRTC) |
AWG_I2 |
<b 0x0101><r 4><t><r 8> |
STUN follow-up with timestamp |
AWG_I3 |
<r 32> |
Random entropy packet |
AWG_I4 |
(empty) | Optional additional signature |
AWG_I5 |
(empty) | Optional additional signature |
The default I1-I3 signatures mimic WebRTC/STUN traffic (video call signaling). This is the most effective protocol for bypassing DPI because STUN is used by Google Meet, Zoom, Teams, and every WebRTC application — blocking it would break video conferencing.
Alternative I1 signatures for different scenarios:
| Protocol | CPS value | When to use |
|---|---|---|
| WebRTC/STUN (default) | <b 0x000100002112a442><r 12> |
Best general-purpose choice |
| DNS response | <r 2><b 0x8580000100010000000004796162730679616e6465780272750000010001c00c000100010000026d000457fa27d1> |
If STUN is throttled |
| QUIC Initial | Capture with Wireshark, wrap in <b 0x...> |
Maximum stealth (unique per server) |
CPS format tags: <b 0xHEX> static bytes, <r N> random bytes, <t> timestamp, <rc N> random letters, <rd N> random digits.
| Flag | Env var | Default | Description |
|---|---|---|---|
--tailscale-auth-key |
TAILSCALE_AUTH_KEY |
(empty — manual auth) | Auth key for automatic registration |
--tailscale-hostname |
TAILSCALE_HOSTNAME |
(empty — uses system hostname) | Node hostname in the tailnet |
--tailscale-extra-args |
TAILSCALE_EXTRA_ARGS |
--advertise-exit-node |
Extra args for Tailscale daemon |
When TAILSCALE_AUTH_KEY is empty, the Tailscale container logs a URL for manual browser-based authentication. Set an auth key (generate at https://login.tailscale.com/admin/settings/keys) for unattended deployments.
All three Tailscale variables (TS_AUTHKEY, TS_HOSTNAME, TS_EXTRA_ARGS) are always present in the generated docker-compose.yml, even when empty. This makes them easy to edit in-place on the server without regenerating.
| Flag | Env var | Default | Description |
|---|---|---|---|
--incoming-ip |
INCOMING_IP |
(all interfaces) | Bind IP for reverse proxy |
--outgoing-ip |
OUTGOING_IP |
(default route) | Source IP for outbound traffic (SNAT) |
--public-ipv4 |
PUBLIC_IPV4 |
(auto-detected) | Public IPv4 for client configs |
--public-ipv6 |
PUBLIC_IPV6 |
(auto-detected) | Public IPv6 for client configs |
| Flag | Env var | Default | Description |
|---|---|---|---|
--tls-versions |
TLS_VERSIONS |
1.2,1.3 |
Accepted TLS versions (comma-separated) |
--skip-certbot |
SKIP_CERTBOT |
false |
Skip Let's Encrypt; use self-signed cert |
--https-port |
HTTPS_PORT |
443 |
Main HTTPS listen port |
ECH/ESNI extensions are never advertised (blocked by Russia's TSPU since November 2024).
Two independent access control layers:
Panel access (Nginx allow/deny — controls 3x-ui and AmneziaWG web panels):
| Flag | Env var | Default | Description |
|---|---|---|---|
--approved-ips |
APPROVED_IPS |
(empty) | IPs/CIDRs allowed to access management panels |
--approved-hostnames |
APPROVED_HOSTNAMES |
(empty) | Hostnames resolved periodically for panel access |
--hostname-resolve-interval-min |
HOSTNAME_RESOLVE_INTERVAL_MIN |
30 |
Re-resolve interval in minutes |
SSH access (nftables port 22 — independent of panel access):
| Flag | Env var | Default | Description |
|---|---|---|---|
--ssh-approved-ips |
SSH_APPROVED_IPS |
(empty — open to all) | IPs allowed to SSH (restricts when set) |
--ssh-approved-hostnames |
SSH_APPROVED_HOSTNAMES |
(empty) | Hostnames resolved periodically for SSH access |
When both SSH_APPROVED_IPS and SSH_APPROVED_HOSTNAMES are empty, SSH is open from all networks. As soon as either is set, SSH is restricted to those addresses only.
| Flag | Env var | Default | Description |
|---|---|---|---|
--blocked-as-numbers |
BLOCKED_AS_NUMBERS |
(empty) | AS numbers to block (e.g. AS196747,AS61280) |
--blocked-subnets |
BLOCKED_SUBNETS |
(empty) | CIDR subnets to block directly |
--blocklist-update-interval-hours |
BLOCKLIST_UPDATE_INTERVAL_HOURS |
6 |
AS prefix re-resolution interval |
| Flag | Env var | Default | Description |
|---|---|---|---|
--forwarding-enabled |
FORWARDING_ENABLED |
false |
Enable traffic forwarding to secondary VM |
--forwarding-mode |
FORWARDING_MODE |
ports |
Forwarding mode: ports (per-port DNAT) or all (full-traffic routing) |
--tunnel-type |
TUNNEL_TYPE |
(none) | Tunnel type: wireguard, ssh, tailscale, or xray |
--exit-node-host |
EXIT_NODE_HOST |
(none) | IP address or hostname of the exit node (secondary VM) |
--reverse-initiated |
REVERSE_INITIATED |
false |
Secondary VM initiates tunnel back |
--forwarding-ports |
FORWARDING_PORTS |
(none) | Port forwards (proto:listen:fwd[:desc],...) |
--reconnect-initial-delay-sec |
RECONNECT_INITIAL_DELAY_SEC |
5 |
Initial reconnect delay (seconds) |
--reconnect-max-delay-sec |
RECONNECT_MAX_DELAY_SEC |
300 |
Max reconnect delay (seconds) |
--tunnel-subnet |
TUNNEL_SUBNET |
10.99.0.0/30 |
WireGuard tunnel subnet |
--tunnel-xray-sni |
TUNNEL_XRAY_SNI |
(same as REALITY_SNI) |
SNI for inter-node VLESS+Reality tunnel |
--tunnel-xray-port |
TUNNEL_XRAY_PORT |
443 |
Port on exit node for VLESS+Reality tunnel |
When a VM runs the full VPN007 stack AND also serves as an exit node for another VPN007 instance, enable the exit node role. This creates a separate tunnel and nftables table that coexists with the main VPN stack without interference.
| Flag | Env var | Default | Description |
|---|---|---|---|
--exit-node-enabled |
EXIT_NODE_ENABLED |
false |
Accept forwarded traffic from another VPN007 node |
--exit-node-tunnel-type |
EXIT_NODE_TUNNEL_TYPE |
(none) | Tunnel type: wireguard, ssh, or tailscale |
--exit-node-peer-host |
EXIT_NODE_PEER_HOST |
(none) | IP address or hostname of the peer VM forwarding traffic to us |
--exit-node-tunnel-subnet |
EXIT_NODE_TUNNEL_SUBNET |
10.99.1.0/30 |
Tunnel subnet (must differ from TUNNEL_SUBNET) |
--exit-node-listen-port |
EXIT_NODE_LISTEN_PORT |
51822 |
Listen port for exit-node tunnel (WireGuard UDP or Xray TCP) |
--exit-node-reverse-initiated |
EXIT_NODE_REVERSE_INITIATED |
false |
Peer initiates tunnel to this exit node |
| Flag | Env var | Default | Description |
|---|---|---|---|
--xray-initial-client |
XRAY_INITIAL_CLIENT |
default-client |
Name for the initial Xray VLESS+Reality client |
--awg-initial-peer |
AWG_INITIAL_PEER |
default-peer |
Name for the initial AmneziaWG peer |
The deployer creates one Xray client and one AmneziaWG peer during initial deployment. A UUID and VLESS share link are generated for the Xray client; WireGuard keys and a .conf file are generated for the AmneziaWG peer. Client configs are saved to {output_dir}/clients/.
Both client configs use the DOMAIN value as the server address (not the IP), since:
- The VLESS+Reality connection requires SNI matching the TLS certificate
- Domain-based endpoints survive IP changes without client reconfiguration
- DNS resolution handles the domain → IP mapping on the client side
| Flag | Env var | Default | Description |
|---|---|---|---|
--output-dir |
OUTPUT_DIR |
./deploy |
Directory for generated files |
--deployment-log-path |
DEPLOYMENT_LOG_PATH |
./deploy/deploy.log |
Deployment log file path |
Parameters are resolved in this order (highest priority first):
- CLI arguments
.envfile values- Built-in defaults
The deployer validates all parameters at startup and exits with a clear error message if any value is invalid. Key validation rules:
| Parameter | Validation |
|---|---|
DOMAIN |
Required; must be a valid hostname |
COVER_SITE_URL |
Required when COVER_SITE_MODE=proxy; must be a valid URL |
XUI_PATH_PREFIX, AWG_PANEL_PATH_PREFIX |
Must start with / |
AWG_S1 |
Integer 0-1132 |
AWG_S2 |
Integer 0-1188 |
AWG_S3 |
Integer 0-1216 |
AWG_S4 |
Integer 0-32 |
AWG_H1-AWG_H4 |
Range format min-max (5-2147483647), non-overlapping |
AWG_JC |
Integer 1-128 |
AWG_JMIN, AWG_JMAX |
Integer 0-1280; JMAX must be > JMIN |
AWG_S1-H4 group |
All eight must be provided together, or all omitted for auto-generation |
TLS_VERSIONS |
Comma-separated; only 1.2 and 1.3 accepted |
HTTPS_PORT |
Integer 1-65535 |
TUNNEL_TYPE |
Must be wireguard, ssh, tailscale, or xray when FORWARDING_ENABLED=true |
FORWARDING_MODE |
Must be ports or all; all requires wireguard, tailscale, or xray tunnel type |
EXIT_NODE_HOST |
Required when FORWARDING_ENABLED=true; valid IP address or hostname |
FORWARDING_PORTS |
Required when FORWARDING_MODE=ports; format proto:port:port[:desc] |
EXIT_NODE_TUNNEL_TYPE |
Required when EXIT_NODE_ENABLED=true |
EXIT_NODE_PEER_HOST |
Required when EXIT_NODE_ENABLED=true; valid IP address or hostname |
EXIT_NODE_TUNNEL_SUBNET |
Must differ from TUNNEL_SUBNET when both are enabled |
APPROVED_IPS, SSH_APPROVED_IPS |
Valid IPv4/IPv6 addresses or CIDR notation |
BLOCKED_AS_NUMBERS |
Must match AS<digits> format |
If validation fails, the deployer prints the invalid parameter name, the provided value, the expected format, and exits with code 1. No files are generated or modified.
After running vpn007 --dry-run (or a full deploy), the output directory contains:
deploy/
├── docker-compose.yml # All services orchestrated here
├── nginx/
│ ├── stream.conf # L4 SNI routing (Reality → Xray)
│ ├── http.conf # L7 path routing + TLS termination
│ ├── approved_panel_ips.conf # Nginx allow list for panels
│ └── self-signed/ # Bootstrap cert (before Let's Encrypt)
├── xray/
│ └── config.json # VLESS+Reality configuration
├── data/ # Persistent data (bind mounts)
│ ├── three_x_ui/ # 3x-ui panel data
│ ├── amneziawg/ # AmneziaWG/WireGuard configs
│ ├── tailscale/ # Tailscale node state
│ ├── letsencrypt/ # TLS certificates
│ └── certbot_webroot/ # ACME challenge files
├── nftables.conf # Firewall rules
├── scripts/
│ ├── blocklist-updater.sh # AS prefix resolver
│ ├── hostname-resolver.sh # Panel IP updater
│ └── certbot-renew.sh # Cert renewal with port 80 hooks
├── systemd/
│ ├── blocklist-updater.service
│ ├── blocklist-updater.timer
│ ├── hostname-resolver.service
│ ├── hostname-resolver.timer
│ ├── certbot-renew.service
│ └── certbot-renew.timer
├── clients/
│ ├── xray-default-client.txt # VLESS share link
│ └── awg-default-peer.conf # AmneziaWG client config
├── docs/
│ ├── README.md
│ ├── troubleshooting.md
│ └── client-guides.md
└── forwarding-install.py # (only when forwarding enabled)
When EXIT_NODE_ENABLED=true, an additional directory is generated:
WireGuard tunnel type (EXIT_NODE_TUNNEL_TYPE=wireguard):
deploy/
└── exit-node/
├── wg-exit-node.conf # WireGuard config for exit-node tunnel
├── nftables-exit-node.conf # Separate nftables table for exit-node NAT
├── exit-node-public.key # Public key to share with the peer VM
└── README.md # Setup instructions for this deployment
SSH tunnel type (EXIT_NODE_TUNNEL_TYPE=ssh):
deploy/
└── exit-node/
├── vpn007-exit-node-ssh.service # systemd unit for autossh tunnel
├── nftables-exit-node.conf # Separate nftables table for exit-node NAT
├── exit-node-ssh-private.key # Ed25519 private key (install on this VM)
├── exit-node-ssh-public.key # Public key (install on peer VM)
├── setup-exit-node.sh # One-command setup script
└── README.md # Setup instructions
Tailscale tunnel type (EXIT_NODE_TUNNEL_TYPE=tailscale):
deploy/
└── exit-node/
├── vpn007-exit-node-tailscale.service # systemd unit for nftables + forwarding
├── nftables-exit-node.conf # Separate nftables table for exit-node NAT
├── setup-exit-node.sh # One-command setup script
└── README.md # Setup instructions
All volume mounts in docker-compose.yml use relative paths (./data/...), so the output directory is portable — copy it anywhere and run docker compose up -d.
| Container | Image | Network | Purpose |
|---|---|---|---|
vpn007_reverse_proxy |
nginx:mainline-alpine |
bridge (vpn_net) | L4/L7 routing, TLS termination |
vpn007_three_x_ui |
ghcr.io/mhsanaei/3x-ui:latest |
bridge (vpn_net) | Xray management + VLESS+Reality |
vpn007_amneziawg |
ghcr.io/wg-easy/wg-easy:15.3.0-beta.2 |
host | AmneziaWG 2.0 VPN + web panel |
vpn007_tailscale |
tailscale/tailscale:latest |
host | Mesh VPN overlay |
vpn007_cover_site |
nginx:alpine |
bridge (vpn_net) | Legitimate cover website |
vpn007_certbot |
certbot/certbot:latest |
(utility) | TLS cert acquisition/renewal |
# Start all services
cd /opt/vpn007 # or wherever your deploy dir is
docker compose up -d
# Stop all services
docker compose down
# View logs
docker compose logs -f reverse_proxy
docker compose logs -f amneziawg
# Update images
docker compose pull
docker compose up -d
# Restart a single service
docker compose restart tailscale
# Acquire/renew TLS certificate manually
docker compose run --rm certbot certonly --webroot -w /var/www/certbot -d your.domain
docker compose exec reverse_proxy nginx -s reload
# Check firewall rules
nft list ruleset
# Check systemd timers
systemctl list-timers 'blocklist*' 'hostname*' 'certbot*'A standalone shell script for managing blocked/allowed IPs, subnets, and AS numbers in the running nftables firewall without regenerating the full config. All changes are applied immediately and automatically saved to /etc/nftables.conf, so they persist across reboots.
Install on the server:
cp scripts/vpn007-fw.sh /usr/local/bin/vpn007-fw
chmod +x /usr/local/bin/vpn007-fwBlock/unblock IPs and subnets:
# Block a single IP
sudo vpn007-fw block ip 192.168.1.100
# Block a subnet
sudo vpn007-fw block ip 10.0.0.0/8
# Unblock
sudo vpn007-fw unblock ip 192.168.1.100Block/unblock entire Autonomous Systems:
# Block all prefixes announced by an AS (resolves automatically)
sudo vpn007-fw block as AS196747
# Unblock
sudo vpn007-fw unblock as AS196747
# Dry-run: see what prefixes an AS announces without blocking
vpn007-fw resolve as AS196747Manage SSH access:
# Allow SSH from a new IP
sudo vpn007-fw allow ssh 203.0.113.50
# Revoke SSH access
sudo vpn007-fw deny ssh 203.0.113.50Manage web panel access:
# Allow panel access from a new IP (updates Nginx and reloads)
sudo vpn007-fw allow panel 10.0.0.5
# Revoke panel access
sudo vpn007-fw deny panel 10.0.0.5List current rules:
# Show everything
sudo vpn007-fw list
# Show only blocked IPs/subnets
sudo vpn007-fw list blocked
# Show SSH-approved IPs
sudo vpn007-fw list sshBuilt-in help:
vpn007-fw --helpThis prints the full command reference, environment variables, and usage examples.
Changes made via vpn007-fw are applied immediately and automatically saved to /etc/nftables.conf (loaded by nftables.service on boot). To override the save path, set the VPN007_NFTABLES_CONF environment variable.
On first deploy, Nginx starts with a self-signed certificate. The deployer then:
- Temporarily opens port 80 in nftables
- Runs
docker compose run --rm certbot certonly --webroot -w /var/www/certbot -d $DOMAIN - Closes port 80
- Reloads Nginx with the real certificate
Subsequent renewals are handled by the certbot-renew.timer (runs twice daily). The renewal script dynamically opens port 80 only during the brief renewal window.
To skip Let's Encrypt entirely (lab/staging), set SKIP_CERTBOT=true.
For testing without a real domain or public IP:
vpn007 --domain lab.local \
--skip-certbot \
--https-port 8443 \
--public-ipv4 192.168.1.100 \
--dry-runThis generates configs with a self-signed cert on port 8443, suitable for local testing.
The full stack runs on 1 GB RAM for light usage (1-10 concurrent clients). Typical memory breakdown:
| Component | RAM usage |
|---|---|
| OS + systemd + nftables | ~100-150 MB |
| Docker daemon | ~100-150 MB |
| Nginx (reverse_proxy + cover_site) | ~20-30 MB |
| 3x-ui + Xray | ~80-120 MB |
| wg-easy (AmneziaWG) | ~50-80 MB |
| Tailscale | ~30-50 MB |
| Total (typical) | ~400-580 MB |
The 2 GB recommendation accounts for spikes during Docker image pulls, cert renewals, and many concurrent clients. To run comfortably on 1 GB:
1. Swap (auto-provisioned):
VPN007 automatically detects low-memory systems (≤1.5 GB RAM) during full deployment and provisions a 1 GB swapfile if no swap is configured. This is persisted in /etc/fstab so it survives reboots. No manual action needed.
If you prefer to provision swap manually (e.g., during dry-run workflows or with a custom size):
fallocate -l 1G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab2. Limit Docker log memory:
cat > /etc/docker/daemon.json << 'EOF'
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
EOF
systemctl restart docker3. (Optional) Drop Tailscale if you don't need mesh management — saves ~40 MB:
# Start only the services you need
docker compose up -d reverse_proxy three_x_ui amneziawg cover_site4. Use static cover site mode — proxy mode enables Nginx caching which uses additional RAM.
5. (Optional) Drop 3x-ui if you only need AmneziaWG — 3x-ui is the heaviest container. You can pre-configure Xray with a static config.json and run a standalone teddysun/xray container instead, but you lose the web panel.
What to expect on 1 GB + 1 GB swap:
- Normal operation: fine for 1-10 concurrent VPN clients
docker compose pull: may use swap briefly (image decompression spikes)- Certbot renewal: brief spike, handled by swap
- Don't go below 1 GB without removing at least one service
SSH access behavior depends on whether SSH_APPROVED_IPS or SSH_APPROVED_HOSTNAMES is configured:
| Configuration | Behavior |
|---|---|
| Both empty (default) | SSH open from all networks |
SSH_APPROVED_IPS=203.0.113.50 |
SSH restricted to listed IPs only |
SSH_APPROVED_HOSTNAMES=admin.example.com |
SSH restricted to resolved IPs only |
| Both set | SSH restricted to combined static IPs + resolved hostnames |
When restricted, the nftables firewall only allows port 22 from addresses in the approved_ssh_v4 set. The hostname resolver periodically re-resolves SSH_APPROVED_HOSTNAMES and updates the nftables set atomically (same interval as panel hostname resolution).
This is completely independent of panel access (APPROVED_IPS / APPROVED_HOSTNAMES), which is enforced at the Nginx level.
Recommendations:
- For production, set
SSH_APPROVED_IPSorSSH_APPROVED_HOSTNAMESto reduce attack surface - For operators with dynamic IPs, use
SSH_APPROVED_HOSTNAMESwith a DDNS hostname - Tailscale provides out-of-band management access regardless of firewall rules (it uses its own overlay network)
The nftables firewall includes rate limiting for SSH connections (port 22): a maximum of 5 new connections per minute per source IP. Connections exceeding this rate are dropped silently.
For additional protection, consider installing fail2ban on the server:
apt install fail2ban
# Enable the SSH jail (enabled by default on Debian/Ubuntu)
systemctl enable --now fail2banThe web panels (3x-ui and AmneziaWG) are protected by:
- IP-based access control (
APPROVED_IPS/APPROVED_HOSTNAMES) — connections from unauthorized IPs never reach the panel - Random admin credentials generated at deploy time (see below)
- Secret URL path prefixes with random suffixes
For environments where IP restriction is not feasible, consider placing the panels behind Tailscale (access via 100.x.x.x tailnet IPs only) instead of exposing them on the public interface.
3x-ui panel:
- Username:
admin(default — change on first login) - Password:
admin(default — change on first login) - Retrieve:
docker exec -it vpn007_three_x_ui /app/x-ui setting -show
AmneziaWG panel (wg-easy):
- Username:
admin+ 3 random alphanumeric characters (e.g.adminx7k) - Password: 16-24 random characters (letters, digits, and
!@#%^&*) - Credentials are written to
docker-compose.ymlasINIT_USERNAME/INIT_PASSWORDenv vars (used only on first container start) - Retrieve:
grep 'INIT_USERNAME\|INIT_PASSWORD' /opt/vpn007/docker-compose.yml
The AmneziaWG panel setup (user creation, host/port configuration) is fully automated via wg-easy's unattended setup mechanism — no manual wizard interaction required.
To retrieve credentials after deployment:
# 3x-ui credentials
docker exec -it vpn007_three_x_ui /app/x-ui setting -show
# AmneziaWG credentials
grep 'INIT_USERNAME\|INIT_PASSWORD' /opt/vpn007/docker-compose.ymlThis section explains how to set up a two-VM relay where VM-A (entrance node) accepts VPN client connections and forwards traffic through an encrypted tunnel to VM-B (exit node), which routes it to the internet. This separates the entry point from the exit point for improved privacy and censorship resistance.
┌─────────────┐ encrypted tunnel ┌─────────────┐
│ VM-A │ ──────────────────────────────── │ VM-B │
│ (entrance) │ WireGuard / SSH / Tailscale │ (exit) │
│ │ │ │
│ Clients ──►│ DNAT ──► tunnel ──► DNAT ──────► │ ──► Internet│
│ connect │ │ │
│ here │ Public IP: 203.0.113.10 │ Public IP: │
│ │ │ 198.51.100.20
└─────────────┘ └─────────────┘
- VM-A runs the full VPN007 stack (Nginx, Xray, AmneziaWG, Tailscale, cover site) and accepts client connections on ports 443/UDP.
- VM-B is a lightweight exit node that receives forwarded traffic from VM-A over an encrypted tunnel and routes it to the internet.
- The deployer generates a standalone Python script (
forwarding-install.py) that you run on VM-B to set up its side of the tunnel.
Security note: The generated forwarding-install.py contains embedded cryptographic keys (WireGuard private keys or SSH private keys). Handle it with care:
- Transfer to VM-B over a secure channel only (SCP, SFTP, or via Tailscale)
- Set restrictive permissions before execution:
chmod 700 /root/forwarding-install.py - Delete the script from VM-B after successful installation — the keys are copied to their final locations during setup
- Do not commit this file to version control or leave it on intermediate machines
- If the script must be stored temporarily, ensure it is readable only by root (
0700)
VM-B only runs a tunnel endpoint and NAT — no Docker, no web panels, no TLS termination.
| Resource | Minimum | Recommended |
|---|---|---|
| CPU | 1 vCPU | 1+ vCPU |
| RAM | 512 MB | 1 GB |
| Disk | 5 GB | 10 GB |
| OS | Debian 11+ / Ubuntu 22.04+ / Alpine 3.18+ | Same |
VM-B's bandwidth is the bottleneck for client internet speed. Choose a VM-B with good network throughput in the geographic location you want traffic to exit from.
If the tunnel between VM-A and VM-B goes down:
- Traffic is dropped, not leaked. Forwarding uses nftables DNAT rules pointing to the tunnel peer IP (e.g.,
10.99.0.2). When the tunnel is down, that IP is unreachable — packets are silently dropped. - Clients see a connection timeout, not a fallback to VM-A's own internet connection. No traffic ever exits from VM-A's public IP.
- Reconnection is automatic. The tunnel daemon retries with exponential backoff (default: 5s → 10s → 20s → ... → 300s max). Once the tunnel re-establishes, forwarding resumes immediately.
- This is fail-closed by design — it protects against accidental IP leaks. If you need fail-open behavior (fall back to VM-A's exit when VM-B is unreachable), you would need to add a custom health-check script that removes the DNAT rules on tunnel failure. This is not provided by default because it compromises the privacy guarantee of the relay architecture.
All four tunnel types are supported for both the forwarding role (TUNNEL_TYPE) and the exit-node role (EXIT_NODE_TUNNEL_TYPE):
| Tunnel type | Use case | Requirements on peer VM |
|---|---|---|
wireguard |
Best performance, lowest overhead | WireGuard or AmneziaWG kernel module |
ssh |
Works through most firewalls, no extra software | SSH server + autossh |
tailscale |
Easiest setup, works behind NAT | Tailscale client |
xray |
DPI-resistant (indistinguishable from TLS 1.3) | Xray binary or full VPN007 node |
Edit your .env file on VM-A (or pass CLI flags):
# Enable forwarding
FORWARDING_ENABLED=true
# Choose tunnel type: wireguard, ssh, or tailscale
TUNNEL_TYPE=wireguard
# VM-B's public IP address
EXIT_NODE_HOST=198.51.100.20
# Ports to forward from VM-A to VM-B
# Format: protocol:listen_port:forward_port:description
FORWARDING_PORTS=tcp:443:443:HTTPS,udp:51820:51820:AmneziaWG
# Set to true if VM-B is behind NAT and must initiate the tunnel
REVERSE_INITIATED=false
# Reconnection settings (exponential backoff)
RECONNECT_INITIAL_DELAY_SEC=5
RECONNECT_MAX_DELAY_SEC=300Instead of forwarding specific ports, you can route all VPN client traffic through the tunnel. This means every packet from connected VPN clients exits from VM-B's IP — not just traffic on specific ports.
# Enable forwarding
FORWARDING_ENABLED=true
# Route ALL VPN client traffic through the tunnel (not just specific ports)
FORWARDING_MODE=all
# Must be wireguard or tailscale (ssh does not support full-traffic mode)
TUNNEL_TYPE=wireguard
# VM-B's public IP address
EXIT_NODE_HOST=198.51.100.20
# FORWARDING_PORTS is not needed in "all" modeHow it works:
- Traffic from Docker bridge networks (172.16.0.0/12) and AmneziaWG client subnets (10.0.0.0/8) is marked with nftables
- Marked packets are policy-routed through the tunnel interface to VM-B
- VM-B masquerades the traffic to the internet
- The cover website and management panels still respond from VM-A's own IP (they are not affected by the policy routing)
When to use all vs ports:
| Mode | Use case |
|---|---|
ports (default) |
Forward only specific services; keep some traffic local |
all |
Complete IP separation — all client internet traffic exits from VM-B |
Limitations of all mode:
- Requires
wireguardortailscaletunnel type (SSH tunnels cannot carry arbitrary IP traffic) - All VPN client traffic goes through the tunnel — higher bandwidth usage on the tunnel link
- If the tunnel goes down, all client traffic is dropped (fail-closed, same as
portsmode)
Then run the deployer:
# Generate all configs including the forwarding script
vpn007 --dry-run
# Or full deploy
sudo vpn007This generates deploy/forwarding-install.py — a standalone script to run on VM-B.
VM-B needs minimal setup. It does NOT need the full VPN007 stack.
Requirements on VM-B:
- Linux (Debian 11+, Ubuntu 22.04+, or Alpine 3.18+)
- Python 3.10+
- Root access (for nftables and tunnel setup)
- Internet connectivity
Copy the forwarding script to VM-B:
# From your dev machine or VM-A
scp deploy/forwarding-install.py root@198.51.100.20:/root/ssh root@198.51.100.20
# Make executable and run
chmod +x /root/forwarding-install.py
python3 /root/forwarding-install.pyThe script will automatically:
- Install the tunnel endpoint (WireGuard, autossh, Tailscale, or Xray — depending on
TUNNEL_TYPE) - Configure the encrypted tunnel to VM-A
- Set up nftables DNAT/SNAT rules to route forwarded traffic to the internet
- Configure automatic reconnection with exponential backoff (5s → 10s → 20s → ... → 300s max)
- Enable IP forwarding and NAT masquerading
On VM-A:
# Check if the tunnel interface is up (WireGuard example)
wg show
# Verify forwarding rules
nft list table ip nat
# Test connectivity through the tunnel
ping 10.99.0.2 # VM-B's tunnel IP (WireGuard)On VM-B:
# Check tunnel status
wg show # WireGuard
# or
systemctl status autossh-tunnel # SSH
# or
tailscale status # Tailscale
# Verify DNAT rules are active
nft list table ip nat
# Test that traffic exits from VM-B's IP
curl -4 ifconfig.me # Should show VM-B's public IPThe deployer generates a point-to-point WireGuard tunnel between VM-A and VM-B using subnet 10.99.0.0/30:
- VM-A:
10.99.0.1 - VM-B:
10.99.0.2
Keys are auto-generated and embedded in the forwarding script. Traffic is forwarded via nftables DNAT from VM-A's public ports through the tunnel to VM-B, which then SNATs it to the internet.
Uses autossh for persistent SSH tunnels with automatic reconnection. The deployer generates SSH key pairs and configures port forwarding over the SSH connection. No additional software needed on VM-B beyond an SSH server.
The tunnel connects as a dedicated unprivileged user (vpn007-tunnel) on the remote side. This user has /usr/sbin/nologin as its shell and cannot execute commands — it only holds the SSH connection open for port forwarding. The forwarding-install.py script creates this user automatically on the receiving VM during setup.
# The forwarding script sets up something like:
autossh -M 20000 -N -L 0.0.0.0:443:localhost:443 \
-i /root/.ssh/vpn007_tunnel_key vpn007-tunnel@VM-AThe simplest option — both VMs join the same tailnet and traffic is routed over the Tailscale overlay network. VM-B must have Tailscale installed and authenticated to the same tailnet as VM-A.
# The forwarding script configures VM-B to accept routes from VM-A
tailscale up --accept-routesThe most DPI-resistant option — the inter-node connection is indistinguishable from a legitimate TLS 1.3 connection to a popular website (e.g., www.google.com). Best for scenarios where the link between VM-A and VM-B crosses a censored network.
Lightweight exit node (VM-B has no VPN007 stack):
The forwarding script installs a standalone Xray binary on VM-B that listens for VLESS+Reality connections. VM-A runs an Xray client that connects to VM-B on port 443 — to any observer, it looks like VM-A is browsing a website.
Full VPN007 node as exit (VM-B runs the full stack):
No extra software needed. The primary VM connects as a regular VLESS client to VM-B's existing 3x-ui Xray instance. The tunnel traffic is completely blended with regular VPN client connections — an observer cannot distinguish the relay tunnel from normal client traffic.
# .env on VM-A (entrance node)
TUNNEL_TYPE=xray
EXIT_NODE_HOST=198.51.100.20
TUNNEL_XRAY_SNI=www.google.com # Can differ from client-facing REALITY_SNI
TUNNEL_XRAY_PORT=443 # Must match EXIT_NODE_LISTEN_PORT on VM-BThe deployer generates credentials (UUID, Reality public key, short ID) that VM-B needs. For a lightweight exit node, these are embedded in the forwarding script. For a full node, add the UUID as a client in 3x-ui.
On the exit node side, set EXIT_NODE_LISTEN_PORT to the same value:
# .env on VM-B (exit node)
EXIT_NODE_ENABLED=true
EXIT_NODE_TUNNEL_TYPE=xray
EXIT_NODE_LISTEN_PORT=443 # Xray listens here — must match TUNNEL_XRAY_PORT on VM-AWhen VM-B is behind NAT or a restrictive firewall and cannot accept incoming connections, set REVERSE_INITIATED=true. In this mode:
- VM-B initiates the tunnel connection back to VM-A
- VM-A listens for incoming tunnel connections from VM-B
- Once established, traffic flows in both directions through the tunnel
This is useful when VM-B is on a residential connection or behind a corporate firewall.
# .env on VM-A
REVERSE_INITIATED=trueWith SSH tunnel type, this creates a reverse SSH tunnel where VM-B connects to VM-A and exposes its local ports back through the connection.
VM-A (entrance, IP: 203.0.113.10):
# .env
DOMAIN=vpn.example.com
FORWARDING_ENABLED=true
TUNNEL_TYPE=wireguard
EXIT_NODE_HOST=198.51.100.20
FORWARDING_PORTS=tcp:443:443:HTTPS,udp:51820:51820:AmneziaWG
RECONNECT_INITIAL_DELAY_SEC=5
RECONNECT_MAX_DELAY_SEC=300
# Deploy
sudo vpn007VM-B (exit, IP: 198.51.100.20):
# Copy and run the generated script
scp root@203.0.113.10:/opt/vpn007/forwarding-install.py /root/
python3 /root/forwarding-install.pyAfter setup, clients connect to VM-A (203.0.113.10) but their traffic exits from VM-B (198.51.100.20).
VM-A (entrance):
# .env
DOMAIN=vpn.example.com
FORWARDING_ENABLED=true
TUNNEL_TYPE=tailscale
REVERSE_INITIATED=true
TAILSCALE_AUTH_KEY=tskey-auth-xxxxx
FORWARDING_PORTS=tcp:443:443:HTTPS
# Deploy
sudo vpn007VM-B (exit, behind NAT):
# Install Tailscale and join the same tailnet
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up --authkey=tskey-auth-yyyyy --accept-routes
# Then run the forwarding script
python3 /root/forwarding-install.pyAfter setup, VM-B initiates the Tailscale connection (works behind NAT), and traffic forwarded from VM-A exits through VM-B's internet connection.
| Symptom | Check |
|---|---|
| Tunnel won't establish | Verify VM-B can reach VM-A on the tunnel port (nc -zv VM-A 51821) |
| Traffic not forwarding | Check nftables DNAT rules on both VMs (nft list table ip nat) |
| Intermittent drops | Check reconnection logs; increase RECONNECT_MAX_DELAY_SEC |
| VM-B can't reach internet | Verify IP forwarding is enabled (sysctl net.ipv4.ip_forward) |
| Reverse tunnel fails | Ensure VM-A's SSH/WG port is open in its firewall for VM-B's IP |
If you no longer need the relay architecture and want all traffic to enter and exit from a single VM, follow these steps.
VM-A already runs the full VPN007 stack. You just need to disable forwarding and remove the tunnel.
On VM-A:
- Update
.env:
# Disable forwarding
FORWARDING_ENABLED=false
# Remove or comment out these:
# TUNNEL_TYPE=
# EXIT_NODE_HOST=
# FORWARDING_PORTS=- Re-run the deployer to regenerate configs without forwarding rules:
sudo vpn007
# or dry-run + manual apply:
vpn007 --dry-run
nft -f deploy/nftables.conf- Remove the tunnel interface (if WireGuard was used):
wg-quick down wg-tunnel # or whatever the interface was named
rm /etc/wireguard/wg-tunnel.conf- Verify traffic now exits from VM-A's own IP:
# From a connected VPN client
curl -4 ifconfig.me # Should show VM-A's public IPOn VM-B (decommission):
# Remove the tunnel
wg-quick down wg-tunnel
rm /etc/wireguard/wg-tunnel.conf
# Remove forwarding rules
nft flush table ip nat
# Disable IP forwarding
sysctl -w net.ipv4.ip_forward=0
# (Optional) Shut down the VM entirelyIf VM-A has multiple IP addresses and you want incoming VPN connections on one IP and outbound internet traffic from a different IP — all on the same machine:
- Update
.env:
# Disable forwarding (no more VM-B)
FORWARDING_ENABLED=false
# Configure multi-IP on the single VM
INCOMING_IP=10.0.0.2 # Private IP bound to the interface (for Nginx bind)
OUTGOING_IP=10.0.0.3 # Different private IP for outbound SNAT
PUBLIC_IPV4=203.0.113.10 # Public IP clients connect to- Ensure both IPs are assigned to the VM's network interface:
# Verify IPs are present
ip addr show
# If the outgoing IP isn't assigned, add it:
ip addr add 10.0.0.3/24 dev eth0
# Make persistent via /etc/network/interfaces or netplan- Re-deploy:
sudo vpn007The generated nftables.conf will include a SNAT rule in the postrouting chain that rewrites the source IP of outbound traffic to OUTGOING_IP. Incoming connections arrive on INCOMING_IP, outbound exits from OUTGOING_IP.
- Verify:
# Check the SNAT rule
nft list table ip nat
# Should show: oifname "eth0" snat to 10.0.0.3
# From a VPN client
curl -4 ifconfig.me # Should show the public IP mapped to OUTGOING_IPIf you want to decommission VM-A and run the full stack on VM-B instead:
-
On VM-B, install the VPN007 prerequisites (Docker, nftables, Python 3.12+)
-
Copy your
.envfrom VM-A and update it:
# Disable forwarding
FORWARDING_ENABLED=false
# Update IPs to VM-B's addresses
PUBLIC_IPV4=198.51.100.20
# INCOMING_IP=... (if needed)
# OUTGOING_IP=... (if needed)
# Update DNS: point your DOMAIN to VM-B's IP- Deploy on VM-B:
sudo vpn007-
Update DNS records to point
DOMAINto VM-B's public IP. -
Decommission VM-A:
# On VM-A
docker compose down
# Remove systemd timers
systemctl disable --now blocklist-updater.timer hostname-resolver.timer certbot-renew.timerAfter converting to single-node, ensure these are cleaned up:
| Item | VM-A | VM-B |
|---|---|---|
| Tunnel interface (wg-tunnel) | Remove | Remove |
| Tunnel config (/etc/wireguard/wg-tunnel.conf) | Remove | Remove |
| DNAT/SNAT forwarding rules in nftables | Removed by re-deploy | Flush manually |
| autossh service (if SSH tunnel) | Stop + disable | Stop + disable |
| Tailscale routes (if Tailscale tunnel) | Remove --accept-routes |
Remove |
| forwarding-install.py on VM-B | — | Delete |
| IP forwarding sysctl on VM-B | — | Set to 0 |
A single exit node (VM-B) can serve multiple entrance nodes (VM-A1, VM-A2, ...) simultaneously. This is useful when you have several VPN servers in different censored regions that all need to exit traffic through the same clean IP.
┌─────────────┐
│ VM-A1 │──┐
│ (entrance) │ │
└─────────────┘ │ ┌─────────────┐
├────→│ VM-B │
┌─────────────┐ │ │ (exit) │──→ Internet
│ VM-A2 │──┤ │ │
│ (entrance) │ │ └─────────────┘
└─────────────┘ │
│
┌─────────────┐ │
│ VM-A3 │──┘
│ (entrance) │
└─────────────┘
Support depends on the tunnel type:
| Tunnel type | Multi-entrance support | Notes |
|---|---|---|
xray |
Yes — native | Multiple VLESS connections to the same server; each node can use the same or different UUID |
ssh |
Yes — native | Multiple autossh connections; add each node's public key to vpn007-tunnel's authorized_keys |
tailscale |
Yes — native | All nodes join the same tailnet |
wireguard |
No — one-to-one | Uses /30 subnet (2 hosts only); would need a larger subnet and multiple [Peer] entries |
On VM-B (exit node) — deploy once:
# Option A: Lightweight exit node
EXIT_NODE_ENABLED=true
EXIT_NODE_TUNNEL_TYPE=xray
EXIT_NODE_PEER_HOST=vm-a1.example.com # Any one entrance node (for docs only)
EXIT_NODE_LISTEN_PORT=443 # Xray listens here (443 blends with HTTPS)After deploying, the generated tunnel-credentials.txt contains the UUID, Reality public key, and short ID. Share these with all entrance node operators.
For a full VPN007 node as exit, just add the tunnel UUID as a client in 3x-ui — all entrance nodes connect as regular VLESS clients.
On each entrance node (VM-A1, VM-A2, ...) — same credentials:
# .env on VM-A1
FORWARDING_ENABLED=true
TUNNEL_TYPE=xray
EXIT_NODE_HOST=exit.example.com
TUNNEL_XRAY_SNI=www.google.com
TUNNEL_XRAY_PORT=443
# .env on VM-A2 (identical exit node config)
FORWARDING_ENABLED=true
TUNNEL_TYPE=xray
EXIT_NODE_HOST=exit.example.com
TUNNEL_XRAY_SNI=www.google.com
TUNNEL_XRAY_PORT=443All entrance nodes use the same UUID and credentials. The exit node's Xray server handles concurrent connections from all of them.
On VM-B (exit node):
Run the setup script once. Then add each entrance node's public key to the tunnel user:
# For each entrance node, append its public key
cat vm-a1-tunnel-key.pub >> /home/vpn007-tunnel/.ssh/authorized_keys
cat vm-a2-tunnel-key.pub >> /home/vpn007-tunnel/.ssh/authorized_keys
chown vpn007-tunnel:vpn007-tunnel /home/vpn007-tunnel/.ssh/authorized_keysEach entrance node establishes its own autossh connection independently.
If you want to track which entrance node is which on the exit node, generate a separate UUID for each:
# On the exit node (full VPN007 node with 3x-ui):
# Add a separate client for each entrance node in the VLESS+Reality inbound
# Client 1: UUID for VM-A1
# Client 2: UUID for VM-A2
# etc.This lets you see per-node traffic in 3x-ui's statistics and revoke access to individual entrance nodes without affecting others.
A VM can simultaneously run the full VPN007 stack (serving its own clients) AND act as an exit node for another VPN007 instance. This is useful when you have two VPN servers and want either one to serve as a backup exit for the other.
┌─────────────────────────────────────────────────────────────────┐
│ VM-A (dual-role) │
│ │
│ [VPN007 stack] ─── tunnel (10.99.0.0/30) ──→ VM-B (exit) │
│ ↑ │
│ [Exit node role] ←── tunnel (10.99.1.0/30) ── VM-C (entrance) │
│ │ │
│ └──→ Internet (masquerade) │
└─────────────────────────────────────────────────────────────────┘
The two roles use completely separate resources:
- Different nftables tables: main firewall uses
table inet filter, exit-node usestable ip vpn007_exit_node - Different tunnel interfaces: VPN clients use the AmneziaWG interface, exit-node uses
wg-exit-node(WireGuard), autossh (SSH), or Tailscale overlay - Different tunnel subnets: forwarding uses
10.99.0.0/30, exit-node uses10.99.1.0/30(configurable) - Different listen ports: AmneziaWG uses its own port, exit-node tunnel uses port
51822(configurable, WireGuard only)
VM-A (IP: 203.0.113.10) forwards its traffic through VM-B, and also serves as exit node for VM-B:
# .env on VM-A
DOMAIN=vpn-a.example.com
# Forward my clients' traffic to VM-B
FORWARDING_ENABLED=true
TUNNEL_TYPE=wireguard
EXIT_NODE_HOST=198.51.100.20
TUNNEL_SUBNET=10.99.0.0/30
FORWARDING_PORTS=tcp:443:443:HTTPS,udp:51820:51820:AWG
# Also serve as exit node for VM-B's forwarded traffic
EXIT_NODE_ENABLED=true
EXIT_NODE_TUNNEL_TYPE=wireguard
EXIT_NODE_PEER_HOST=198.51.100.20
EXIT_NODE_TUNNEL_SUBNET=10.99.1.0/30
EXIT_NODE_LISTEN_PORT=51822VM-B (IP: 198.51.100.20) forwards its traffic through VM-A, and also serves as exit node for VM-A:
# .env on VM-B
DOMAIN=vpn-b.example.com
# Forward my clients' traffic to VM-A
FORWARDING_ENABLED=true
TUNNEL_TYPE=wireguard
EXIT_NODE_HOST=203.0.113.10
TUNNEL_SUBNET=10.99.0.0/30
FORWARDING_PORTS=tcp:443:443:HTTPS,udp:51820:51820:AWG
# Also serve as exit node for VM-A's forwarded traffic
EXIT_NODE_ENABLED=true
EXIT_NODE_TUNNEL_TYPE=wireguard
EXIT_NODE_PEER_HOST=203.0.113.10
EXIT_NODE_TUNNEL_SUBNET=10.99.1.0/30
EXIT_NODE_LISTEN_PORT=51822After deploying both VMs:
- VM-A's VPN clients exit through VM-B's IP (198.51.100.20)
- VM-B's VPN clients exit through VM-A's IP (203.0.113.10)
- Both VMs serve their own cover websites and management panels independently
- Deploy with
EXIT_NODE_ENABLED=true— generates configs indeploy/exit-node/
WireGuard:
- Copy
exit-node/wg-exit-node.confto/etc/wireguard/ - Replace
REPLACE_WITH_PEER_PUBLIC_KEYwith the peer's actual public key - Exchange public keys between VMs (your key is in
exit-node/exit-node-public.key) - Bring up the tunnel:
wg-quick up wg-exit-node - Enable on boot:
systemctl enable wg-quick@wg-exit-node
SSH:
- Run the setup script:
chmod +x exit-node/setup-exit-node.sh && sudo ./exit-node/setup-exit-node.sh - Create the
vpn007-tunneluser on the peer VM and install the public key (exit-node/exit-node-ssh-public.key) in/home/vpn007-tunnel/.ssh/authorized_keys - The systemd service (
vpn007-exit-node-ssh) handles autossh with automatic reconnection
Tailscale:
- Run the setup script:
chmod +x exit-node/setup-exit-node.sh && sudo ./exit-node/setup-exit-node.sh - Ensure both VMs are on the same Tailscale tailnet
- The systemd service (
vpn007-exit-node-tailscale) loads nftables rules on boot
Xray (lightweight exit node):
- Run the setup script:
chmod +x exit-node/setup-exit-node.sh && sudo ./exit-node/setup-exit-node.sh - Share
exit-node/tunnel-credentials.txtwith the primary VM operator - The systemd service (
vpn007-xray-tunnel) runs Xray with VLESS+Reality
Xray (full VPN007 node as exit):
- Add the UUID from
exit-node/tunnel-credentials.txtas a client in your existing 3x-ui VLESS+Reality inbound - Share the connection details (UUID, Reality public key, short ID) with the primary VM operator
- No extra processes or ports needed — the tunnel blends with regular VPN client traffic
See deploy/exit-node/README.md for detailed instructions generated for your specific configuration.
To stop forwarding traffic from this VM to a remote exit node (VM-B) and route traffic directly to the internet from this VM instead:
1. Update .env:
FORWARDING_ENABLED=false
# Comment out or remove:
# TUNNEL_TYPE=
# EXIT_NODE_HOST=
# FORWARDING_PORTS=2. Re-deploy to regenerate configs without forwarding rules:
sudo vpn007
# or dry-run + manual apply:
vpn007 --dry-run
nft -f deploy/nftables.conf
docker compose up -d3. Remove the tunnel interface on this VM:
# WireGuard tunnel
wg-quick down wg-tunnel
systemctl disable wg-quick@wg-tunnel
rm -f /etc/wireguard/wg-tunnel.conf
# SSH tunnel
systemctl disable --now autossh-tunnel
rm -f /etc/systemd/system/autossh-tunnel.service
systemctl daemon-reload
# Tailscale — no interface to remove; just stop advertising routes if needed4. Verify traffic now exits from this VM's own IP:
# From a connected VPN client
curl -4 ifconfig.me # Should show this VM's public IP, not VM-B's5. (Optional) Clean up VM-B:
If VM-B is no longer needed as an exit node for anyone:
# On VM-B
wg-quick down wg-tunnel # WireGuard
systemctl disable --now vpn007-exit-node-ssh # SSH
nft delete table ip vpn007_forward
sysctl -w net.ipv4.ip_forward=0
userdel -r vpn007-tunnel 2>/dev/null # Remove the tunnel user if SSH was usedTo stop this VM from accepting forwarded traffic from another VPN007 instance (stop serving as an exit node for a peer):
1. Update .env:
EXIT_NODE_ENABLED=false
# Comment out or remove:
# EXIT_NODE_TUNNEL_TYPE=
# EXIT_NODE_PEER_HOST=
# EXIT_NODE_TUNNEL_SUBNET=
# EXIT_NODE_LISTEN_PORT=2. Tear down the exit-node tunnel:
# WireGuard
wg-quick down wg-exit-node
systemctl disable wg-quick@wg-exit-node
rm -f /etc/wireguard/wg-exit-node.conf
# SSH
systemctl disable --now vpn007-exit-node-ssh
rm -f /etc/systemd/system/vpn007-exit-node-ssh.service
rm -f /root/.ssh/vpn007_exit_node_key
systemctl daemon-reload
# Tailscale
systemctl disable --now vpn007-exit-node-tailscale
rm -f /etc/systemd/system/vpn007-exit-node-tailscale.service
systemctl daemon-reload3. Remove the exit-node nftables table:
nft delete table ip vpn007_exit_node4. (Optional) Remove the tunnel user if SSH was used:
userdel -r vpn007-tunnel5. Re-deploy to regenerate configs without exit-node files:
sudo vpn007
# or:
vpn007 --dry-runThe deploy/exit-node/ directory will no longer be generated.
6. Verify:
# No exit-node tunnel interface
wg show # Should not list wg-exit-node
ip link show wg-exit-node 2>&1 # Should say "does not exist"
# No exit-node nftables table
nft list tables | grep vpn007_exit_node # Should be empty
# No exit-node systemd services
systemctl list-units 'vpn007-exit*' # Should be empty
# Main VPN stack still works
docker compose ps # All containers Up
curl -sk https://your.domain/ # Cover site respondsNote: Disabling the exit-node role does NOT affect the main VPN007 stack on this VM. Your own VPN clients, cover website, firewall, and management panels continue to work unchanged.
The data/ directory contains all persistent state. Back it up regularly:
| Path | Contents | Impact if lost |
|---|---|---|
data/three_x_ui/ |
3x-ui database, Xray configs, client list | Lose all Xray clients and panel settings |
data/amneziawg/ |
WireGuard keys, peer configs | Lose all AmneziaWG peers (must redistribute configs) |
data/tailscale/ |
Tailscale node state | Node re-registers on next start (minor) |
data/letsencrypt/ |
TLS certificates and account keys | Must re-acquire certs (automatic, but brief downtime) |
.env |
Deployment configuration | Must recreate from memory |
nftables.conf |
Firewall rules | Regenerated by re-running deployer |
# Stop services to ensure consistent state
cd /opt/vpn007
docker compose stop
# Create a timestamped backup
tar czf /root/vpn007-backup-$(date +%Y%m%d-%H%M%S).tar.gz \
.env data/ nftables.conf docker-compose.yml
# Restart services
docker compose startFor zero-downtime backups, you can skip the stop/start — the SQLite database in 3x-ui handles concurrent reads, and WireGuard configs are rarely written. However, stopping ensures a fully consistent snapshot.
# On a fresh VM with VPN007 prerequisites installed
cd /opt/vpn007
tar xzf /root/vpn007-backup-YYYYMMDD-HHMMSS.tar.gz
# Start services
docker compose up -d
# Re-apply firewall
nft -f nftables.conf
# Re-install systemd timers
cp systemd/*.service systemd/*.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now blocklist-updater.timer hostname-resolver.timer certbot-renew.timercd /path/to/vpn007
git pull
pip install -e .
# Re-run the deployer to regenerate configs with new templates
sudo vpn007
# or dry-run first to review changes:
vpn007 --dry-run
diff /opt/vpn007/docker-compose.yml deploy/docker-compose.ymlRe-running the deployer on an existing deployment is safe:
- Existing
data/directories are preserved (never overwritten) - Configuration files (Nginx, Xray, nftables, systemd) are regenerated from templates
- Docker containers are recreated with the new configs
- Client configurations remain intact in the 3x-ui database and AmneziaWG state
cd /opt/vpn007
docker compose pull
docker compose up -dThis pulls the latest versions of all container images and recreates containers. Persistent data in data/ bind mounts is unaffected.
After a major OS upgrade (e.g., Debian 11 → 12):
- Verify Docker and nftables still work:
docker info,nft list ruleset - Re-run
sudo vpn007to regenerate systemd units (paths may change) - Check that all timers are active:
systemctl list-timers
The generated nftables.conf includes rules for both IPv4 and IPv6:
table inet filter— applies to both address families (input/forward/output chains)table ip nat— IPv4 NAT (SNAT for outgoing IP, DNAT for forwarding)table ip6 nat— IPv6 NAT (only whenOUTGOING_IPis an IPv6 address or forwarding targets IPv6)
Blocked AS prefixes are resolved for both IPv4 and IPv6 and placed in separate nftables sets:
blocked_v4— IPv4 prefixes from blocked AS numbersblocked_v6— IPv6 prefixes from blocked AS numbers
SSH and panel access sets also have IPv6 counterparts:
approved_ssh_v4/approved_ssh_v6- Panel access is enforced at Nginx level and supports both IPv4 and IPv6 in
APPROVED_IPS
| Scenario | Behavior |
|---|---|
PUBLIC_IPV6 set |
Included in client configs; Nginx listens on [::]:443 |
PUBLIC_IPV6 empty |
IPv6 auto-detected; if unavailable, IPv4-only configs generated |
BLOCKED_AS_NUMBERS set |
Both v4 and v6 prefixes resolved and blocked |
SSH_APPROVED_IPS with IPv6 |
Added to approved_ssh_v6 set |
After deployment, verify all services are running:
# Check all containers are up
docker compose ps
# Expected output: all services "Up" with correct ports
# vpn007_reverse_proxy Up 0.0.0.0:443->443/tcp
# vpn007_three_x_ui Up
# vpn007_amneziawg Up 0.0.0.0:51820->51820/udp
# vpn007_tailscale Up
# vpn007_cover_site Up
# Test the cover site (should return 200)
curl -sk https://your.domain/ | head -20
# Test that Reality SNI routing works (should NOT return your cover site)
curl -sk --resolve www.microsoft.com:443:your-ip https://www.microsoft.com/
# Test panel access (from an approved IP)
curl -sk https://your.domain/secretpanel-XXXXX/
# Check Tailscale status
docker compose exec tailscale tailscale status
# Verify firewall is loaded
nft list ruleset | head -5
# Check systemd timers
systemctl list-timers 'blocklist*' 'hostname*' 'certbot*'Key indicators to monitor:
| Check | Command | Expected |
|---|---|---|
| Containers running | docker compose ps |
All "Up" |
| TLS cert expiry | openssl s_client -connect localhost:443 </dev/null 2>/dev/null | openssl x509 -noout -enddate |
>7 days remaining |
| Disk usage | df -h / |
<80% |
| Docker logs for errors | docker compose logs --since 1h | grep -i error |
Empty or expected |
| Tunnel status (if forwarding) | wg show or tailscale status |
Peer connected |
| Blocklist timer | systemctl status blocklist-updater.timer |
Active, last run <6h ago |
The generated docker-compose.yml includes Docker health checks for critical services:
- reverse_proxy:
curl -f http://localhost:80/health(internal health endpoint) - three_x_ui: TCP check on the Xray port
- amneziawg: WireGuard interface exists and has at least one peer configured
Unhealthy containers are automatically restarted by Docker's restart policy (unless-stopped).
By default, Docker uses the json-file log driver with no size limit. For production, configure log rotation:
# /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}Then restart Docker: systemctl restart docker
This limits each container to 3 log files of 10 MB each (30 MB max per container, ~180 MB total for 6 containers).
The deployment log at {output_dir}/deploy.log records all actions performed during deployment at DEBUG level. It is overwritten on each deployment run. To preserve history, copy it before re-deploying:
cp deploy/deploy.log deploy/deploy-$(date +%Y%m%d-%H%M%S).logTimer execution logs are captured by journald:
# View blocklist updater logs
journalctl -u blocklist-updater.service --since "1 hour ago"
# View hostname resolver logs
journalctl -u hostname-resolver.service --since "1 hour ago"
# View certbot renewal logs
journalctl -u certbot-renew.service --since "1 day ago"Configure journald retention in /etc/systemd/journald.conf:
[Journal]
SystemMaxUse=200M
MaxRetentionSec=30dayVPN007 automatically configures the required Linux kernel parameters during deployment. No manual sysctl configuration is needed.
| Parameter | Value | Set by | When |
|---|---|---|---|
net.ipv4.ip_forward |
1 |
Deployer (sysctl -w) |
Before starting containers |
net.ipv4.conf.all.src_valid_mark |
1 |
Deployer (sysctl -w) |
Before starting containers |
net.ipv4.ip_forward |
1 |
forwarding-install.py | VM-B setup (exit node) |
net.ipv4.ip_forward |
1 |
wg-exit-node PostUp | Exit-node tunnel up |
On VM-A (main VPN node):
The deployer sets net.ipv4.ip_forward=1 and net.ipv4.conf.all.src_valid_mark=1 on the host via sysctl -w before starting containers. These are required because AmneziaWG and Tailscale run with network_mode: host — Docker does not allow setting sysctls via the sysctls: directive on host-network containers. The parameters are set each time the deployer runs; they persist until reboot (the containers re-apply them on restart via their own startup hooks).
On VM-B (exit node via forwarding script):
The generated forwarding-install.py script calls sysctl -w net.ipv4.ip_forward=1 during setup. This enables IP forwarding so that traffic received through the tunnel can be routed to the internet. The setting is applied at runtime; to persist across reboots, the forwarding script also configures the tunnel via wg-quick or systemd, which re-applies the sysctl on interface up (via PostUp hooks).
On exit-node role (dual-role VM):
The generated wg-exit-node.conf includes a PostUp = sysctl -w net.ipv4.ip_forward=1 directive that enables forwarding each time the exit-node WireGuard interface comes up.
# Check IP forwarding is enabled
sysctl net.ipv4.ip_forward
# Expected: net.ipv4.ip_forward = 1
# Check src_valid_mark (needed for WireGuard routing)
sysctl net.ipv4.conf.all.src_valid_mark
# Expected: net.ipv4.conf.all.src_valid_mark = 1If you need to disable IP forwarding after stopping VPN007 (e.g., during decommissioning):
sysctl -w net.ipv4.ip_forward=0This takes effect immediately. The setting reverts to the system default on next reboot (typically 0 unless configured in /etc/sysctl.conf).
$ vpn007 --help
usage: vpn007 [-h] [--version] [--env-file ENV_FILE] [--dry-run] [--debug]
[--domain DOMAIN] [--reality-sni REALITY_SNI]
[--cover-site-mode COVER_SITE_MODE] [--cover-site-url COVER_SITE_URL]
[--cover-site-static-path COVER_SITE_STATIC_PATH]
[--xui-path-prefix XUI_PATH_PREFIX]
[--awg-panel-path-prefix AWG_PANEL_PATH_PREFIX]
[--enable-port-8443 ENABLE_PORT_8443]
[--xray-internal-port XRAY_INTERNAL_PORT]
[--awg-listen-port AWG_LISTEN_PORT] [--awg-panel-port AWG_PANEL_PORT]
[--tailscale-auth-key TAILSCALE_AUTH_KEY]
[--tailscale-hostname TAILSCALE_HOSTNAME]
[--tailscale-extra-args TAILSCALE_EXTRA_ARGS]
[--incoming-ip INCOMING_IP] [--outgoing-ip OUTGOING_IP]
[--public-ipv4 PUBLIC_IPV4] [--public-ipv6 PUBLIC_IPV6]
[--tls-versions TLS_VERSIONS] [--skip-certbot] [--https-port HTTPS_PORT]
[--approved-ips APPROVED_IPS] [--approved-hostnames APPROVED_HOSTNAMES]
[--ssh-approved-ips SSH_APPROVED_IPS]
[--ssh-approved-hostnames SSH_APPROVED_HOSTNAMES]
[--hostname-resolve-interval-min HOSTNAME_RESOLVE_INTERVAL_MIN]
[--blocked-as-numbers BLOCKED_AS_NUMBERS]
[--blocked-subnets BLOCKED_SUBNETS]
[--blocklist-update-interval-hours BLOCKLIST_UPDATE_INTERVAL_HOURS]
[--forwarding-enabled FORWARDING_ENABLED] [--tunnel-type TUNNEL_TYPE]
[--exit-node-host EXIT_NODE_HOST]
[--reverse-initiated REVERSE_INITIATED]
[--forwarding-ports FORWARDING_PORTS]
[--reconnect-initial-delay-sec RECONNECT_INITIAL_DELAY_SEC]
[--reconnect-max-delay-sec RECONNECT_MAX_DELAY_SEC]
[--tunnel-subnet TUNNEL_SUBNET]
[--exit-node-enabled EXIT_NODE_ENABLED]
[--exit-node-tunnel-type EXIT_NODE_TUNNEL_TYPE]
[--exit-node-peer-host EXIT_NODE_PEER_HOST]
[--exit-node-tunnel-subnet EXIT_NODE_TUNNEL_SUBNET]
[--exit-node-listen-port EXIT_NODE_LISTEN_PORT]
[--exit-node-reverse-initiated EXIT_NODE_REVERSE_INITIATED]
[--xray-initial-client XRAY_INITIAL_CLIENT]
[--awg-initial-peer AWG_INITIAL_PEER]
[--output-dir OUTPUT_DIR] [--deployment-log-path DEPLOYMENT_LOG_PATH]
CLI deployer for multiple anti-censorship VPN services on a single Linux VM.
options:
-h, --help show this help message and exit
--version show program's version number and exit
--env-file ENV_FILE Path to the .env configuration file (default: .env)
--dry-run Generate configuration files without deploying
--debug Enable verbose debug logging
See the full parameter reference above for all flags and their corresponding
environment variables.
source .venv/bin/activate
# Run the full test suite
pytest
# Run with verbose output
pytest -v
# Run a specific test file
pytest tests/test_compose.py -v
# Run with the CI Hypothesis profile (fewer examples, faster)
HYPOTHESIS_PROFILE=ci pytestTo completely remove VPN007 from a server, use the cleanup script:
# Full cleanup (removes everything including client configs)
sudo /opt/vpn007/scripts/cleanup.sh
# Or keep client configs and certificates
sudo /opt/vpn007/scripts/cleanup.sh --keep-dataThe cleanup script handles:
- Stopping and removing all Docker containers, networks, and images
- Removing nftables firewall rules (restores accept-all policy)
- Disabling and removing systemd timers/services
- Unloading the AmneziaWG kernel module
- Resetting kernel parameters
- Removing the deployment directory (unless
--keep-data) - Restarting the Docker daemon
Manual cleanup (if the script is unavailable):
cd /opt/vpn007
# 1. Stop and remove all containers and networks
docker compose down --volumes --remove-orphans
# 2. Remove Docker images (optional — frees disk space)
docker compose down --rmi all
# 3. Remove systemd timers and services
systemctl disable --now blocklist-updater.timer hostname-resolver.timer certbot-renew.timer
rm -f /etc/systemd/system/blocklist-updater.{service,timer}
rm -f /etc/systemd/system/hostname-resolver.{service,timer}
rm -f /etc/systemd/system/certbot-renew.{service,timer}
systemctl daemon-reload
# 4. Remove nftables rules (restores default policy)
nft flush ruleset
# Or restore your pre-VPN007 nftables config if you have one:
# nft -f /etc/nftables.conf.backup
# 5. Remove the firewall management script
rm -f /usr/local/bin/vpn007-fw
# 6. Remove WireGuard tunnel interfaces (if forwarding was used)
wg-quick down wg-tunnel 2>/dev/null
wg-quick down wg-exit-node 2>/dev/null
rm -f /etc/wireguard/wg-tunnel.conf /etc/wireguard/wg-exit-node.conf
# 7. Remove the deployment directory
rm -rf /opt/vpn007
# 8. (Optional) Remove swap if it was auto-provisioned
swapoff /swapfile
rm -f /swapfile
sed -i '/\/swapfile/d' /etc/fstab
# 9. (Optional) Remove Docker entirely
apt purge docker-ce docker-ce-cli containerd.io docker-compose-plugin
rm -rf /var/lib/dockerAfter uninstalling, verify:
docker psshows no VPN007 containersnft list rulesetshows no VPN007 tablessystemctl list-timersshows no VPN007 timers- Port 443 and the AmneziaWG UDP port are no longer listening
If a re-deployment breaks your setup, you can roll back to the previous working configuration.
cd /opt/vpn007
# Stop broken services
docker compose down
# Restore from your most recent backup
tar xzf /root/vpn007-backup-YYYYMMDD-HHMMSS.tar.gz
# Restart with the restored config
docker compose up -d
nft -f nftables.confIf you don't have a backup but the previous config was committed to git:
cd /path/to/vpn007
# Check what changed in the last deploy
git diff HEAD -- src/vpn007/templates/
# Revert to the previous version
git checkout HEAD~1
# Regenerate configs with the old version
pip install -e .
vpn007 --dry-run --output-dir /opt/vpn007
# Apply on the server
cd /opt/vpn007
docker compose up -d
nft -f nftables.confIf only one service is broken, you can restore just that component:
# Restore only Nginx config from backup
tar xzf /root/vpn007-backup-YYYYMMDD-HHMMSS.tar.gz deploy/nginx/
docker compose restart reverse_proxy
# Restore only nftables rules
tar xzf /root/vpn007-backup-YYYYMMDD-HHMMSS.tar.gz deploy/nftables.conf
nft -f nftables.conf
# Restore only the 3x-ui database (client configs)
docker compose stop three_x_ui
tar xzf /root/vpn007-backup-YYYYMMDD-HHMMSS.tar.gz deploy/data/three_x_ui/
docker compose start three_x_uiTo make rollbacks easier, always back up before re-deploying:
tar czf /root/vpn007-backup-$(date +%Y%m%d-%H%M%S).tar.gz \
.env data/ nftables.conf docker-compose.yml nginx/ xray/ systemd/ scripts/The generated docker-compose.yml includes memory limits for each container to prevent any single service from exhausting host RAM (especially important on 1-2 GB VMs):
| Container | Memory limit | Memory reservation |
|---|---|---|
vpn007_reverse_proxy |
128 MB | 32 MB |
vpn007_three_x_ui |
256 MB | 64 MB |
vpn007_amneziawg |
128 MB | 32 MB |
vpn007_tailscale |
128 MB | 32 MB |
vpn007_cover_site |
64 MB | 16 MB |
vpn007_certbot |
128 MB | 32 MB |
These limits are set via deploy_resources in the Compose file:
services:
reverse_proxy:
deploy:
resources:
limits:
memory: 128M
reservations:
memory: 32MIf a container exceeds its memory limit, Docker's OOM killer terminates it and the restart: unless-stopped policy brings it back. Monitor OOM events with:
docker events --filter event=oom --since 24h
journalctl -k | grep -i "out of memory"For high-traffic deployments (many concurrent clients), increase the limits for three_x_ui and amneziawg in docker-compose.yml:
# Edit the generated file directly on the server
vim /opt/vpn007/docker-compose.yml
docker compose up -d # Recreates containers with new limitsAll bridge-mode containers (reverse_proxy, three_x_ui, cover_site) communicate over an internal Docker bridge network (vpn_net). This network is intentionally isolated:
- No inter-container access to host network: Bridge containers cannot reach host-only services (e.g., SSH on port 22) unless explicitly published.
- Internal DNS resolution: Containers reference each other by service name (e.g.,
three_x_ui:2053) — no host ports are exposed for internal-only services. - Host-network containers (
amneziawg,tailscale) usenetwork_mode: hostbecause they require direct access to network interfaces for tunnel creation. These containers can reach all host ports and all bridge containers.
| Container | Network mode | Can reach host ports | Can reach internet | Can reach other containers |
|---|---|---|---|---|
| reverse_proxy | bridge (vpn_net) | No | Yes (for proxy mode) | Yes (vpn_net only) |
| three_x_ui | bridge (vpn_net) | No | Yes (Xray outbound) | Yes (vpn_net only) |
| cover_site | bridge (vpn_net) | No | No | Yes (vpn_net only) |
| amneziawg | host | Yes | Yes | Yes (all) |
| tailscale | host | Yes | Yes | Yes (all) |
| certbot | bridge (vpn_net) | No | Yes (ACME) | No (run-once utility) |
For additional network isolation beyond the defaults:
-
Disable ICC (inter-container communication) if you don't need containers to talk to each other directly. Note: this breaks the reverse proxy → backend routing, so only use if you restructure with explicit links.
-
Use Docker's
internalnetwork option for the cover site if it serves only static files:networks: vpn_net: internal: false # default — allows internet access cover_net: internal: true # no internet access for cover_site
-
Drop capabilities for containers that don't need them (already applied in the generated Compose file):
cap_drop: - ALL cap_add: - NET_BIND_SERVICE # only for reverse_proxy (port 443)
When APPROVED_IPS is configured, unauthorized connections are rejected at the Nginx level before reaching the panel. However, if IP-based access control is not feasible (e.g., operators with unpredictable IPs who cannot use DDNS), the panels are additionally protected by Nginx rate limiting:
- Login endpoints: 5 requests per minute per source IP (burst of 3)
- API endpoints: 30 requests per minute per source IP (burst of 10)
- Static assets: No rate limit
Rate limiting is enforced via limit_req_zone in the generated Nginx config:
# Generated in nginx/http.conf
limit_req_zone $binary_remote_addr zone=panel_login:1m rate=5r/m;
limit_req_zone $binary_remote_addr zone=panel_api:1m rate=30r/m;
location ~ ^/secretpanel-.*/login {
limit_req zone=panel_login burst=3 nodelay;
...
}Requests exceeding the rate limit receive HTTP 429 (Too Many Requests). Combined with the random admin credentials and secret URL paths, this provides defense-in-depth against brute-force attacks even without IP allowlisting.
For maximum security, use IP-based access control (APPROVED_IPS) whenever possible — it is strictly superior to rate limiting because unauthorized traffic never reaches the application layer.
Docker applies default AppArmor profiles to all containers on Debian/Ubuntu systems. These profiles restrict containers from:
- Writing to
/procand/sys(except allowed paths) - Mounting filesystems
- Accessing raw network sockets (unless
NET_RAWcapability is granted) - Loading kernel modules
The default Docker AppArmor profile (docker-default) is sufficient for most VPN007 containers. Exceptions:
| Container | Additional requirements | Notes |
|---|---|---|
amneziawg |
NET_ADMIN, NET_RAW capabilities + host network |
Required for WireGuard interface creation; runs with --privileged or explicit caps |
tailscale |
NET_ADMIN, NET_RAW + /dev/net/tun access |
Required for tunnel interface; uses host network |
reverse_proxy |
NET_BIND_SERVICE |
Bind to ports <1024 (443) |
three_x_ui |
None beyond defaults | Xray uses userspace networking |
cover_site |
None beyond defaults | Static file serving only |
# Check AppArmor is active
aa-status
# Verify Docker containers are confined
docker inspect --format='{{.AppArmorProfile}}' vpn007_reverse_proxy
# Expected: "docker-default"
# Containers with host network mode may show "unconfined" — this is expected
# for amneziawg and tailscale which need direct network access
docker inspect --format='{{.AppArmorProfile}}' vpn007_amneziawgFor environments requiring stricter confinement, you can create a custom AppArmor profile for the reverse proxy that additionally restricts file writes:
# /etc/apparmor.d/docker-vpn007-nginx
#include <tunables/global>
profile docker-vpn007-nginx flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
#include <abstractions/nameservice>
network inet stream,
network inet6 stream,
/etc/nginx/** r,
/var/log/nginx/** w,
/var/cache/nginx/** rw,
/run/nginx.pid rw,
/tmp/** rw,
deny /proc/** w,
deny /sys/** w,
}Load and apply:
apparmor_parser -r /etc/apparmor.d/docker-vpn007-nginx
# Then in docker-compose.yml, add:
# security_opt:
# - apparmor=docker-vpn007-nginxFor most deployments, the default Docker AppArmor profile provides adequate confinement. Custom profiles are recommended only for high-security environments or compliance requirements.
VPN007 integrates with the following open-source projects:
- Xray-core — VLESS+Reality protocol engine
- 3x-ui — Xray web management panel
- wg-easy — WireGuard/AmneziaWG with web UI (v15.2+ supports AWG 2.0)
- AmneziaWG — Obfuscated WireGuard fork
- Tailscale — Mesh VPN overlay network
- WireGuard — Base VPN protocol
- Nginx — Reverse proxy with stream module
- Let's Encrypt — Free TLS certificates via certbot
This project is licensed under the GNU General Public License v3.0 (GPL-3.0).
See LICENSE for the full license text and THIRD-PARTY-LICENSES for integrated component licenses.
| Component | License |
|---|---|
| Xray-core | MPL-2.0 |
| 3x-ui | GPL-3.0 |
| AmneziaWG | GPL-2.0 / MIT |
| Tailscale | BSD-3-Clause |
| WireGuard | GPL-2.0 |
© Vadim Pavlov 2026