Where to Start
Three paths. Pick the one that matches you and you'll be running in under 10 minutes.
NexusPanel is a multi-tenant VPN panel — you sell sub-accounts, your customers connect through any v2ray client, you keep everything in one dashboard. If you're new, the fastest way to see what it does is the free trial. If you already run Marzban, the migration tool brings everything across in one command — users, admins, hosts, certs, even your existing subscription URLs keep working.
Try the Free Trial
Open the Telegram bot, type /start, get a 7-day trial license. You can spin up your own panel with that license and play with everything before paying.
Install on a Fresh Server
One command on a clean Ubuntu 20.04+ VPS. The script asks for your license, domain, and admin password — that's it. SSL is auto-configured if you point a domain at the server.
See install command ↓Migrate from Marzban
Same VPS, no client reconfiguration. The migration tool is dry-run-first and reversible — you preview every change before anything is touched, and you can roll back any time before the final cutover.
Migration guide ↓https://your-panel/sub/<token> link your users already have keeps resolving.
What you'll need
- A VPS with Ubuntu 20.04+ (or any Debian-family distro), 1 GB RAM minimum, 2 GB recommended
- Root SSH access to that VPS
- A domain pointed at the VPS (optional but strongly recommended — without it you get http only)
- A NexusPanel license (grab one from the Telegram bot, free 7-day trial works)
How to get help
If anything fails, try these in order:
- Check the panel logs:
cd /opt/panel && docker compose logs --tail 100 - Read the relevant section of these docs (sidebar on the left)
- Message us on Telegram — link in the bot, response time is hours not days
What is NexusPanel
NexusPanel is a modern, feature-rich proxy management panel built for VPN providers and network administrators. It provides a unified dashboard to manage users, nodes, subscriptions, and analytics across multiple servers.
Key capabilities include:
- Multi-protocol support — VMess, VLESS, Trojan, Shadowsocks via Xray-core, plus Hysteria 2 as a separate sidecar. Transport & obfuscation extensions (XHTTP, Reality, ECH, TLS fragments, Finalmask) are configured per-host in the panel.
- Distributed nodes — connect unlimited remote servers from a single panel
- Real per-user limits — data, expiry, IP & device caps actually enforced via Xray access log parsing
- Admin roles & host scoping — owner, admin, reseller tiers with traffic quotas; assign specific hosts to specific admins
- REST API — 75+ endpoints for automation and integration
- Grafana-style analytics — traffic-over-time, user growth, protocol/status donuts, top consumers, node bandwidth load (auto-refresh)
- Telegram bot — customer-facing payment bot (crypto via NOWPayments) plus admin notifications
- License system — trial → paid tiers with 6-hour heartbeat & auto-update of Docker images
- Encrypted Happ links — real RSA-4096
happ://crypt4/deeplinks that hide the underlying subscription URL - 2FA — TOTP with QR & recovery codes
- Mobile-ready — responsive dashboard with bottom-bar nav and overflow drawer
- Code protection — sensitive Python modules Cython-compiled to
.sobinaries - In-app actionable notifications — expiring users, data caps, offline nodes, license expiry
Requirements
| Component | Minimum | Recommended |
|---|---|---|
| OS | Ubuntu 20.04+ / Debian 11+ | Ubuntu 22.04 LTS |
| RAM | 1 GB | 2 GB+ |
| CPU | 1 vCPU | 2 vCPU |
| Disk | 10 GB | 20 GB+ (SSD) |
| Docker | 20.10+ | Latest stable |
| Domain | Optional | Recommended (for SSL) |
Quick Install
Run this single command on a fresh VPS to install NexusPanel with default settings:
curl -sL https://nexuspanel.store/install | bash
The script will prompt you for:
- License Key & Client ID — from @nexuspanelpayment_bot (skip to install in trial mode)
- Domain — for SSL via Let's Encrypt (skip for IP-only)
- Admin username & password — for the dashboard
- Panel port — defaults to 8443
Then it will:
- Install Docker & Docker Compose if missing
- Pull
ghcr.io/haitovs/nexus:latest(Cython-protected production image) - Create
/opt/panel/with.envanddocker-compose.yml(container name:nexus-panel) - Seed
xray_config.jsonwith access log enabled (required for IP/device limit enforcement) - Start the panel and print dashboard URL + credentials
One-Command Install (Detailed)
The install script accepts optional flags to customize the setup:
# Install with custom port and admin credentials curl -sL https://nexuspanel.store/install | bash -s -- \ --port 8443 \ --username myadmin \ --password securepass123 # Install with SSL certificate (requires domain pointed to server) curl -sL https://nexuspanel.store/install | bash -s -- \ --domain panel.example.com \ --ssl
After installation, the panel is accessible at http://YOUR_IP:8000/dashboard/ (or the configured port).
Manual Install
If you prefer manual control over the installation process:
# Clone the repository git clone https://github.com/your-org/nexuspanel.git /opt/nexuspanel cd /opt/nexuspanel # Copy and edit the environment file cp .env.example .env nano .env # Start with Docker Compose docker compose up -d # View logs docker compose logs -f
With SSL (Certbot)
To enable HTTPS with a free Let's Encrypt certificate:
# Install certbot apt install -y certbot # Obtain certificate (stop panel first if using port 80) docker compose down certbot certonly --standalone -d panel.example.com # Add to .env UVICORN_SSL_CERTFILE="/etc/letsencrypt/live/panel.example.com/fullchain.pem" UVICORN_SSL_KEYFILE="/etc/letsencrypt/live/panel.example.com/privkey.pem" # Mount certs in docker-compose.yml and restart docker compose up -d
Add a cron job for automatic renewal:
0 3 * * * certbot renew --quiet && docker compose -C /opt/nexuspanel restart
With PostgreSQL
For production deployments, PostgreSQL is recommended over SQLite:
# Set in .env SQLALCHEMY_DATABASE_URL="postgresql+asyncpg://nexus:secretpass@db:5432/nexuspanel"
Add the PostgreSQL service to your docker-compose.yml:
services: db: image: postgres:16-alpine environment: POSTGRES_USER: nexus POSTGRES_PASSWORD: secretpass POSTGRES_DB: nexuspanel volumes: - pgdata:/var/lib/postgresql/data restart: always panel: image: ghcr.io/haitovs/nexus:latest container_name: nexus-panel depends_on: - db env_file: .env ports: - "8000:8000" volumes: - /var/lib/nexuspanel:/var/lib/panel restart: always volumes: pgdata:
Docker Compose Examples
Minimal (SQLite)
services: panel: image: ghcr.io/haitovs/nexus:latest container_name: nexus-panel env_file: .env ports: - "8000:8000" volumes: - /var/lib/nexuspanel:/var/lib/panel restart: always
Full Stack (PostgreSQL + SSL + Telegram)
services: db: image: postgres:16-alpine environment: POSTGRES_USER: nexus POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: nexuspanel volumes: - pgdata:/var/lib/postgresql/data restart: always panel: image: ghcr.io/haitovs/nexus:latest container_name: nexus-panel depends_on: - db env_file: .env ports: - "443:8000" volumes: - /var/lib/nexuspanel:/var/lib/panel - /etc/letsencrypt:/etc/letsencrypt:ro restart: always volumes: pgdata:
Configuration Reference
NexusPanel is configured entirely through environment variables. Set them in your .env file or pass them directly to Docker.
.env.example to .env and uncomment the variables you need. All variables have sensible defaults.
Server
| Variable | Default | Description |
|---|---|---|
UVICORN_HOST | 0.0.0.0 | Bind address for the server |
UVICORN_PORT | 8000 | HTTP port |
UVICORN_UDS | — | Unix domain socket path (overrides host/port) |
UVICORN_SSL_CERTFILE | — | Path to SSL certificate (fullchain.pem) |
UVICORN_SSL_KEYFILE | — | Path to SSL private key |
UVICORN_SSL_CA_TYPE | public | CA type: public or private |
DASHBOARD_PATH | /dashboard/ | URL path for the web dashboard |
ALLOWED_ORIGINS | — | Comma-separated CORS origins |
SUDO_USERNAME | — | Initial super admin username |
SUDO_PASSWORD | — | Initial super admin password |
JWT_ACCESS_TOKEN_EXPIRE_MINUTES | 1440 | Token expiration in minutes (default 24h) |
Database
| Variable | Default | Description |
|---|---|---|
SQLALCHEMY_DATABASE_URL | sqlite:///db.sqlite3 | Database connection string |
SQLALCHEMY_POOL_SIZE | 10 | Connection pool size |
SQLIALCHEMY_MAX_OVERFLOW | 30 | Max connections above pool size |
BACKEND_MODE | classic | classic (SQLite/Postgres, default) or modern (adds Redis-backed event queue) |
REDIS_URL | — | Redis connection string; required when BACKEND_MODE=modern |
postgresql+asyncpg://user:pass@host:5432/dbname for async PostgreSQL.
BACKEND_MODE=modern and provide REDIS_URL to enable Redis-backed event queuing. Use docker-compose.modern.yml which ships a redis:7 service alongside the panel. Most deployments don't need this.
Xray
| Variable | Default | Description |
|---|---|---|
XRAY_JSON | xray_config.json | Path to Xray core configuration |
XRAY_EXECUTABLE_PATH | /usr/local/bin/xray | Path to Xray binary |
XRAY_ASSETS_PATH | /usr/local/share/xray | Path to geoip.dat and geosite.dat |
XRAY_SUBSCRIPTION_URL_PREFIX | — | Public URL prefix for subscription links (e.g. https://sub.example.com). Changes take effect only after a full panel restart — use Save & Restart in the Env Editor, not a container restart. |
XRAY_SUBSCRIPTION_PATH | sub | URL path segment for subscriptions |
XRAY_EXCLUDE_INBOUND_TAGS | — | Space-separated inbound tags to exclude |
XRAY_FALLBACKS_INBOUND_TAG | — | Inbound tag used for fallback routing |
Subscription
| Variable | Default | Description |
|---|---|---|
SUB_PROFILE_TITLE | Subscription | Display name shown in client apps |
SUB_SUPPORT_URL | — | Support link included in subscription info |
SUB_UPDATE_INTERVAL | 12 | Client auto-update interval (hours) |
EXTERNAL_CONFIG | — | External config URL for client integration |
USE_CUSTOM_JSON_DEFAULT | false | Enable custom JSON config for default client |
USE_CUSTOM_JSON_FOR_V2RAYN | false | Enable custom JSON for V2RayN |
USE_CUSTOM_JSON_FOR_V2RAYNG | false | Enable custom JSON for V2RayNG |
USE_CUSTOM_JSON_FOR_STREISAND | false | Enable custom JSON for Streisand |
USE_CUSTOM_JSON_FOR_HAPP | false | Enable custom JSON for Happ |
SUB_RATE_LIMIT_PER_MINUTE | 60 | Max subscription fetches per IP per minute (in-process, resets on restart) |
SUB_ENABLE_ETAG | true | Return ETag / honour If-None-Match to save bandwidth on unchanged subs |
SUB_GZIP_MIN_SIZE | 512 | Gzip-compress subscription responses larger than this many bytes |
Templates
| Variable | Default | Description |
|---|---|---|
CUSTOM_TEMPLATES_DIRECTORY | /var/lib/panel/templates/ | Base directory for custom templates |
SUBSCRIPTION_PAGE_TEMPLATE | subscription/index.html | Template for the user subscription page |
HOME_PAGE_TEMPLATE | home/index.html | Template for the panel home page |
CLASH_SUBSCRIPTION_TEMPLATE | clash/default.yml | Clash subscription template |
CLASH_SETTINGS_TEMPLATE | clash/settings.yml | Clash settings template |
V2RAY_SUBSCRIPTION_TEMPLATE | v2ray/default.json | V2Ray subscription template |
V2RAY_SETTINGS_TEMPLATE | v2ray/settings.json | V2Ray settings template |
SINGBOX_SUBSCRIPTION_TEMPLATE | singbox/default.json | Sing-box subscription template |
SINGBOX_SETTINGS_TEMPLATE | singbox/settings.json | Sing-box settings template |
MUX_TEMPLATE | mux/default.json | Multiplex config template |
USER_AGENT_TEMPLATE | user_agent/default.json | User-agent parsing template |
GRPC_USER_AGENT_TEMPLATE | user_agent/grpc.json | gRPC user-agent template |
Telegram
| Variable | Default | Description |
|---|---|---|
TELEGRAM_API_TOKEN | — | Bot token from @BotFather |
TELEGRAM_ADMIN_ID | — | Comma-separated Telegram user IDs for admins |
TELEGRAM_LOGGER_CHANNEL_ID | — | Channel ID for log messages |
TELEGRAM_DEFAULT_VLESS_FLOW | xtls-rprx-vision | Default VLESS flow for bot-created users |
TELEGRAM_PROXY_URL | — | Proxy URL for Telegram API connections |
Notifications
| Variable | Default | Description |
|---|---|---|
NOTIFY_STATUS_CHANGE | true | Notify when user status changes |
NOTIFY_USER_CREATED | true | Notify on new user creation |
NOTIFY_USER_UPDATED | true | Notify on user modification |
NOTIFY_USER_DELETED | true | Notify on user deletion |
NOTIFY_USER_DATA_USED_RESET | true | Notify on usage reset |
NOTIFY_USER_SUB_REVOKED | true | Notify on subscription revocation |
NOTIFY_IF_DATA_USAGE_PERCENT_REACHED | true | Notify when data threshold reached |
NOTIFY_IF_DAYS_LEFT_REACHED | true | Notify when expiry threshold reached |
NOTIFY_LOGIN | true | Notify on admin login |
LOGIN_NOTIFY_WHITE_LIST | — | IPs to exclude from login notifications |
NOTIFY_DAYS_LEFT | 3,7 | Days-left thresholds for notifications |
NOTIFY_REACHED_USAGE_PERCENT | 80,90 | Usage percent thresholds |
RECURRENT_NOTIFICATIONS_TIMEOUT | 180 | Minutes between repeat notifications |
NUMBER_OF_RECURRENT_NOTIFICATIONS | 3 | Max repeat notifications per event |
DISCORD_WEBHOOK_URL | — | Discord webhook for Telegram-style notifications |
WEBHOOK_ADDRESS | — | Legacy: comma-separated static webhook URLs. Prefer the dashboard Webhooks UI for new setups. |
WEBHOOK_SECRET | — | Legacy: HMAC secret for WEBHOOK_ADDRESS delivery. Dashboard webhooks manage secrets per-endpoint. |
Branding (White-Label)
| Variable | Default | Description |
|---|---|---|
BRAND_NAME | Panel | Panel name displayed in UI and emails |
BRAND_LOGO_URL | — | URL to custom logo image |
BRAND_FAVICON_URL | — | URL to custom favicon |
Security
| Variable | Default | Description |
|---|---|---|
CAPTCHA_PROVIDER | disabled | Captcha provider: disabled, turnstile, or builtin |
TURNSTILE_SITE_KEY | — | Cloudflare Turnstile site key |
TURNSTILE_SECRET_KEY | — | Cloudflare Turnstile secret key |
LOGIN_RATE_LIMIT | 10/minute | Max login attempts per window |
LOGIN_LOCKOUT_THRESHOLD | 10 | Failed attempts before lockout |
LOGIN_LOCKOUT_DURATION_MINUTES | 30 | Lockout duration in minutes |
Logging
| Variable | Default | Description |
|---|---|---|
LOG_LEVEL | INFO | Log level: DEBUG, INFO, WARNING, ERROR |
LOG_FORMAT | text | Log format: text or json |
LOG_FILE_PATH | — | Write logs to file (in addition to stdout) |
LOG_MAX_SIZE_MB | 10 | Max log file size before rotation |
LOG_BACKUP_COUNT | 5 | Number of rotated log files to keep |
Metrics (Prometheus)
| Variable | Default | Description |
|---|---|---|
METRICS_ENABLED | false | Enable Prometheus /metrics endpoint |
METRICS_TOKEN | — | Bearer token required to scrape metrics |
Additional Variables
| Variable | Default | Description |
|---|---|---|
ACTIVE_STATUS_TEXT | Active | Custom label for active status |
EXPIRED_STATUS_TEXT | Expired | Custom label for expired status |
LIMITED_STATUS_TEXT | Limited | Custom label for limited status |
DISABLED_STATUS_TEXT | Disabled | Custom label for disabled status |
ONHOLD_STATUS_TEXT | On-Hold | Custom label for on-hold status |
USERS_AUTODELETE_DAYS | -1 | Auto-delete expired users after N days (-1 = disabled) |
USER_AUTODELETE_INCLUDE_LIMITED_ACCOUNTS | false | Include data-limited users in auto-delete |
JOB_CORE_HEALTH_CHECK_INTERVAL | 10 | Health check interval (seconds) |
JOB_RECORD_NODE_USAGES_INTERVAL | 30 | Node usage recording interval |
JOB_RECORD_USER_USAGES_INTERVAL | 10 | User usage recording interval |
JOB_REVIEW_USERS_INTERVAL | 10 | User review/expire check interval |
JOB_SEND_NOTIFICATIONS_INTERVAL | 30 | Notification dispatch interval |
DISABLE_RECORDING_NODE_USAGE | false | Disable node usage recording |
DEBUG | false | Enable debug mode with hot-reload |
DOCS | false | Enable Swagger UI at /docs |
VITE_BASE_API | /api/v1/ | Base API path for frontend build |
Dashboard
The NexusPanel dashboard is a modern React-based web application accessible at /dashboard/. It provides a complete interface for managing your proxy infrastructure.
Overview Page
The dashboard home page displays real-time statistics at a glance:
- Total users — active, expired, limited, disabled counts
- Bandwidth usage — total upload/download with trend graphs
- Node status — online/offline indicators with load percentages
- Recent activity — latest user creations, connections, and admin actions
- Protocol distribution — pie chart of protocols in use
Users Management
The Users page supports full lifecycle management:
- Create user — set username, data limit, expiry date, protocols, device limit, IP limit
- Edit user — modify all fields including status (active, disabled, on-hold)
- Bulk operations — select multiple users for bulk update, reset usage, or delete
- Search and filter — filter by status, admin, protocol, or search by username
- Subscription links — copy subscription URL, QR code generation
- Usage stats — per-user upload/download with historical data
Nodes
Manage remote Xray nodes connected to the panel:
- Add node — provide address, port, and usage coefficient
- Connection status — real-time online/offline with latency
- Country flags — automatic flag display based on node location (60+ countries)
- Reorder — drag or use arrow buttons to set display order
- Certificate — view and copy the node SSL certificate for remote setup
- Uptime tracking — historical uptime percentage per node
Hosts & Advanced TLS Settings
Each Xray inbound has one or more host rows that tell the subscription renderer what address, port, and TLS options to emit in client configs. The full field set per host:
| Field | Purpose |
|---|---|
| Remark | Display name shown in client apps |
| Address | Server domain or IP the client connects to |
| Port | Override the inbound's listen port |
| SNI / Host | TLS Server Name Indication and HTTP Host header |
| Security / ALPN / Fingerprint | TLS profile: none / tls / reality; h2/http1.1; Chrome/Firefox/Safari uTLS |
| Allow Insecure | Skip TLS cert verification (use only behind CDN where cert isn't exposed) |
| Country code | ISO 3166-1 alpha-2 — drives regional subscription reorder |
| Allowed / Denied Admins | Restrict host to specific sub-admins (leave blank = all admins) |
ECH (Encrypted Client Hello)
ECH hides the SNI from passive observers — the TLS handshake extension is encrypted using a public key published in DNS. Enable per host: toggle ECH and paste the ECHConfig blob from your CDN/DNS provider. Requires a client that supports ECH (Happ, Chrome 117+).
TLS Fragmentation
Splits the TLS ClientHello into smaller TCP segments, bypassing DPI pattern matching on the first packet. Use when SNI-based blocking is active but a CDN isn't available.
- Fragment size — bytes per fragment, e.g.
100-200(random range) - Fragment delay — ms between fragments, e.g.
10-20
TLS Record Fragmentation
Fragments at the TLS record layer rather than TCP. More aggressive than ClientHello fragmentation; use when standard TLS fragmentation is still fingerprinted.
Noise Settings
Injects random noise packets before the real TLS handshake to defeat flow-based fingerprinting. JSON field:
[{"type": "rand", "packet": "10-50", "delay": "5-10"}]
Type rand sends random bytes; type str sends a literal hex string. Packet size and delay accept range notation.
Random User-Agent
Randomises the HTTP User-Agent on each request to avoid client fingerprinting on WS/HTTP transports.
Sessions
Monitor and manage active device connections:
- Active sessions — view all currently connected devices
- Per-user sessions — see which devices a specific user is using
- Disconnect — forcibly terminate individual sessions
- IP history — track user connection history by IP
Analytics
Comprehensive analytics dashboard with:
- Summary — total users, active connections, bandwidth, revenue overview
- Protocol distribution — usage breakdown by protocol (VMess, VLESS, etc.)
- Node load — per-node connection counts and bandwidth usage
- Node uptime — uptime percentages over 24h, 7d, 30d periods
- Top users — highest bandwidth consumers
- Expiring users — users expiring within configurable days
Admin Management
Role-based admin system with three tiers:
| Role | Capabilities |
|---|---|
| Owner | Full access: manage admins, nodes, system settings, all users |
| Admin | Manage users (all), view nodes and analytics, limited settings |
| Reseller | Manage own users only, limited by max_users and max_traffic_bytes quotas |
Each admin can have quotas:
max_users— maximum number of users the admin can createmax_traffic_bytes— total traffic quota across all their users
Settings
- Two-Factor Authentication — enable/disable TOTP 2FA from the settings page
- Xray Core Config — edit the raw Xray JSON in a 2-column layout (editor on left, live logs & status on right)
- Env Editor — edit SMTP, tokens, feature flags inline with secret masking; Save & Restart self-restarts the panel
- Hysteria2 — manage hy2 inbounds from the settings page (Standard+)
- License info — tier, days remaining, current vs max users/nodes
User Groups
User Groups (called Squads in Remnawave) let you segment users for inbound-visibility control and subscription overrides. Pro license, sudo-only.
Each group can do any or all of:
- Inbound filter (
applies_to_inbounds) — CSV of inbound tags. Users in the group only get subscription entries for matching inbounds. Empty = all inbounds. - Template override (
override_template_id) — use a different subscription template for members of this group. - Host override (
override_hosts) — inject different host rows into member subscriptions (e.g., give a VIP group a direct-IP host that's hidden from everyone else).
Add users to a group from the User Detail page or via API. A user can be in at most one group.
# List groups curl /api/v1/user-groups -H "Authorization: Bearer TOKEN" # Create a VIP group that only gets the hy2 + VLESS-Reality inbounds curl -X POST /api/v1/user-groups \ -H "Authorization: Bearer TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"VIP","applies_to_inbounds":"hy2-main,vless-reality"}' # Add user to group curl -X POST /api/v1/user-groups/1/members \ -d '{"username":"alice"}' -H "Authorization: Bearer TOKEN"
Inbound Sets
An Inbound Set is a named CSV of inbound tags that you assign to a node. When a node has an inbound set, only those inbounds are activated on it — the rest are suppressed. Use this to run different protocol mixes per node: e.g., node A gets VLESS+Trojan, node B gets VLESS+hy2.
Pro license, sudo-only.
# Create an inbound set curl -X POST /api/v1/inbound-sets \ -H "Authorization: Bearer TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"UDP nodes","tags":"hy2-main,vmess-ws"}' # Assign to a node (set inbound_set_id on the node) curl -X PUT /api/v1/node/1 \ -d '{"inbound_set_id": 2}' -H "Authorization: Bearer TOKEN"
Subscription Response Rules
Sub Rules let you customise what a user's subscription response looks like based on their client. Rules match on request properties and apply an action.
| Match field | Operators | Actions |
|---|---|---|
user_agent | equals / contains / regex | template / status / headers |
client_os | equals / contains / regex | template / status / headers |
Examples:
- Match
user_agent contains "Happ"→ actiontemplate = happ-custom— serve a Happ-optimised template to Happ clients - Match
client_os equals "iOS"→ actionheaders = {"Content-Type": "text/plain"} - Global rules (sudo-only,
admin_id = NULL) apply to all users regardless of which admin owns them
Rules are evaluated in ascending priority order. First match wins.
# Create a rule: serve sing-box template to Karing clients curl -X POST /api/v1/sub-rules \ -H "Authorization: Bearer TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"Karing","match_field":"user_agent","match_op":"contains", "match_value":"Karing","action":"template","action_arg":"singbox-default"}'
Webhooks
NexusPanel delivers signed HTTP POST events to any URL you register. Each delivery includes an X-Nexus-Signature header — HMAC-SHA256 of the body with your endpoint's secret.
Event scopes
| Scope | Events |
|---|---|
user.* | user.created, user.updated, user.deleted, user.expired, user.disabled, user.data_used_reset |
node.* | node.connected, node.disconnected, node.reconnecting |
service.* | service.started, service.stopped |
billing.* | billing.renewed, billing.expired |
errors.* | errors.cert_expired, errors.xray_crash |
hwid.* | hwid.mismatch, hwid.reset |
Leave scopes empty to receive all events. Delivery retries with exponential backoff; after max attempts the event is marked failed and dropped.
# Register an endpoint curl -X POST /api/v1/webhooks \ -H "Authorization: Bearer TOKEN" \ -H "Content-Type: application/json" \ -d '{"url":"https://my-server/hook","scopes":"user.*,node.*"}' # Response includes secret (shown once) # Send a test delivery curl -X POST /api/v1/webhooks/1/test -H "Authorization: Bearer TOKEN" # Verify signature in your handler (Python example) # expected = hmac.new(secret, body, sha256).hexdigest() # assert expected == request.headers["X-Nexus-Signature"]
WEBHOOK_ADDRESS (comma-separated URLs) and WEBHOOK_SECRET still work as a static env-var alternative. Use the dashboard UI for new setups — it supports per-endpoint secrets, scopes, and delivery history.
Clients Page
Dashboard → Clients shows a curated list of recommended VPN clients with platform badges, download links, and usage notes. Operators share this page URL with end users.
| Client | Platforms | Notes |
|---|---|---|
| Happ | iOS / macOS / Windows / Android | Recommended — native sub URL, HWID binding, offline cache |
| v2RayTun | iOS / macOS / Android | Popular iOS client, VLESS-Reality support |
| Karing | All platforms | Sing-box-based, strong cross-platform story |
| Shadowrocket | iOS | $2.99 US App Store — rock-solid iOS |
| V2rayNG | Android | Classic Android client |
| FlClashX | Windows / macOS / Linux / Android | Mihomo/Clash-compatible |
| Streisand | iOS / macOS | Supports custom JSON — set USE_CUSTOM_JSON_FOR_STREISAND=true |
License System
NexusPanel uses a central license server (nexuspanel.store) to validate installations and push updates. This is how clients are tiered, billed, and kept up to date.
How heartbeat works
- Every 6 hours the panel calls
POST /api/validateon the license server with itslicense_id,client_id, and full telemetry (panel version, Xray version, hostname, OS, IP, total/active users, total/active nodes, total traffic, uptime). - The license server stores this and replies with
{tier, expires_at, latest_version, update_available, docker_image}. - If
update_availableis true andAUTO_UPDATEis enabled (default), the panel runsdocker compose pull && docker compose up -d --force-recreatein the background — you don't have to do anything.
Tiers
| Tier | Price | Users | Nodes | Duration |
|---|---|---|---|---|
| Trial | Free | 50 | 1 | 7 days |
| Standard | $4/mo | Unlimited | 10 | 30 days/mo |
| Pro | $10/mo | Unlimited | Unlimited | 30 days/mo |
Trial
No credit card, no signup — open the Telegram bot and type /start. You get a 7-day trial license with 50 users and 1 node. Full dashboard access: all protocols, analytics, Hysteria 2, webhooks, everything. Enough to evaluate on real traffic.
Standard — $4/month
For operators running a live service. Removes the 50-user cap, adds 10 nodes, and unlocks:
- Hysteria 2 protocol on all nodes
- Bulk operations (enable/disable/reset/delete hundreds of users at once)
- API access for automation and integrations
- Multi-month billing (3/6/12 months at 5%/10%/15% discount)
Pro — $10/month
Everything in Standard, plus no node cap and the full feature set:
- Unlimited nodes across any number of countries
- ECH (Encrypted Client Hello) — hides SNI from DPI
- Finalmask — anti-fingerprinting transport layer
- White-label branding (custom panel domain + logo)
- User Groups & Inbound Sets for reseller tier segmentation
- Middle Server Relay with auto-generated iptables rules
- Priority support
Feature comparison
| Feature | Trial | Standard | Pro |
|---|---|---|---|
| Max users | 50 | Unlimited | Unlimited |
| Max nodes | 1 | 10 | Unlimited |
| Duration | 7 days | 30 days/mo | 30 days/mo |
| All protocols (VLESS, VMess, Trojan, SS) | ✓ | ✓ | ✓ |
| Hysteria 2 | ✓ | ✓ | ✓ |
| Grafana-style analytics | ✓ | ✓ | ✓ |
| Live session view | ✓ | ✓ | ✓ |
| Audit log | ✓ | ✓ | ✓ |
| Webhooks | ✓ | ✓ | ✓ |
| Operator CLI | ✓ | ✓ | ✓ |
| Bulk operations | — | ✓ | ✓ |
| API access | — | ✓ | ✓ |
| ECH + Finalmask | — | — | ✓ |
| White-label branding | — | — | ✓ |
| User Groups & Inbound Sets | — | — | ✓ |
| Middle Server Relay | — | — | ✓ |
Buying a license
Open @nexuspanelpayment_bot on Telegram. Tap View Plans, pick a tier, choose duration (1/3/6/12 months at increasing discounts), pick a crypto currency (USDT TRC20, BTC, ETH, LTC, TRX, and 200+ others), and send the exact amount shown to the displayed wallet. Once NOWPayments confirms, the bot delivers your License Key and Client ID.
Grace period
If your license expires, the panel keeps running in grace mode for 72 hours so you can renew without an outage. After that the API switches to read-only until a valid license is restored.
IP & Device Limit Enforcement
NexusPanel enforces per-user IP and device limits in real time by parsing the Xray access log — not just at subscription import. This is what makes ip_limit and device_limit actually work.
How it works
- Xray writes one line to
$XRAY_ACCESS_LOGfor every accepted connection. - The
enforce_limitsjob runs every 60 seconds, tails the log (offset-tracked, rotation-aware), and extracts(user_id, client_ip)pairs from the lastLIMIT_WINDOW_SECONDS(default 600 = 10 minutes). - For each user, unique IPs are counted. If the count exceeds
ip_limit(ordevice_limitif noip_limitis set) and the user is currentlyactiveandip_limit_mode == "limit", the user is flipped to statuslimited. - All seen IPs are written to
user_ip_history. View per-user IPs viaGET /api/v1/user/{username}/ips.
Required xray config
The default install enables this automatically. For existing installs, the panel auto-patches your xray_config.json on startup to add the access log path. Manual config:
{
"log": {
"loglevel": "warning",
"access": "/var/lib/panel/xray-access.log"
}
}
Tunables
| Env Var | Default | Purpose |
|---|---|---|
XRAY_ACCESS_LOG | /var/log/xray/access.log | Path to the Xray access log file |
LIMIT_WINDOW_SECONDS | 600 | Rolling window for unique-IP counting |
LIMIT_ENFORCE_INTERVAL | 60 | How often (seconds) the enforcement job runs |
Backups
NexusPanel runs an automatic database backup every day at 03:00 UTC via the backup APScheduler job.
Where backups go
- Local files:
/var/lib/panel/backups/backup_YYYYMMDD_HHMMSS.sqlite3(or.sqlfor PostgreSQL) - Last 7 backups are kept; older ones are auto-pruned
- If
TELEGRAM_API_TOKENandTELEGRAM_ADMIN_IDare configured, each backup is also pushed to your Telegram as a document so you have an off-server copy
Manual backup
# SQLite docker exec nexus-panel cp /var/lib/panel/db.sqlite3 /var/lib/panel/backups/manual.sqlite3 # Or grab the file directly from the host cp /var/lib/panel/db.sqlite3 ~/panel-backup-$(date +%F).sqlite3
Restoring
- Stop the panel:
cd /opt/panel && docker compose down - Replace the DB file:
cp /path/to/backup.sqlite3 /var/lib/panel/db.sqlite3 - Restart:
docker compose up -d
BACKUP_DIR — where backups are written (default /var/lib/panel/backups)BACKUP_RETENTION — how many recent backups to keep (default 7)
Encrypted Happ Subscriptions
The dashboard's per-user "H" button generates a real happ://crypt4/<base64> deeplink using RSA-4096 PKCS1v15 with Happ's official public key. Once added to a Happ client, the user cannot view, edit, or share the underlying subscription URL.
Subscription URLs longer than 501 bytes (RSA-4096 + PKCS1v15 limit) automatically fall back to the plain happ://add/<base64> format.
Nodes
What is a Node
A node is a remote server running the Xray core that connects back to your NexusPanel instance. Nodes allow you to distribute proxy endpoints across multiple servers and geographic locations while managing everything from a single dashboard.
The panel communicates with nodes over a secure gRPC connection using mutual TLS. User configurations and traffic data flow through this channel.
Node Install
The dashboard generates a ready-to-paste one-liner with the panel certificate baked in. No manual cert file writing.
- Dashboard → Nodes → Add New Node
- Click Copy Install Command — the command includes the cert, port, and API port
- Paste and run on the node server
- Enter the node's IP and ports in the panel → Add Node
curl -sL https://your-domain.com/node-install.sh | bash -s -- \ --cert-b64 <base64-cert> \ --port 62060 \ --api-port 62061
The installer waits for apt/dpkg locks automatically — safe to run on a freshly provisioned VPS.
Certificate & Ports
NexusNode authenticates to the panel using the panel's signing certificate:
- Panel connection port:
62060 - Xray API port:
62061 - Certificate CN:
Panel— the node config'sssl_target_namemust match - The cert is fetched once from
GET /api/v1/node/settingsand stored at/var/lib/nexus-node/cert.pem
Docker Compose for Node
services: nexus-node: image: ghcr.io/haitovs/nexus-node:latest environment: SERVICE_PORT: 62060 XRAY_API_PORT: 62061 SSL_CLIENT_CERT_FILE: "/var/lib/nexus-node/cert.pem" ports: - "62060:62060" - "62061:62061" - "443:443" - "80:80" volumes: - /var/lib/nexus-node:/var/lib/nexus-node restart: always
Multiple Nodes
To add nodes across different locations:
- Install the node service on each server using the generated one-liner
- In the panel, add each node with its public IP and ports
- Assign a country flag — drives both the visual grid and the regional subscription reorder
- Drag-and-drop to set display order in the grid
- Set a usage coefficient per node (e.g.,
1.5means traffic counts 1.5×)
CF-IPCountry (Cloudflare) or local MaxMind DB. Set country_code on every host row to activate.
Node Troubleshooting
| Issue | Solution |
|---|---|
| Node shows "Offline" | Check firewall allows TCP 62060 from the panel; verify certificate in /var/lib/nexus-node/cert.pem |
| Connection refused | Ensure Docker container is running: docker compose ps |
| Certificate error | Re-copy cert from panel (GET /api/v1/node/settings); verify ssl_target_name = Panel |
| High latency | Check network route between panel and node; ensure BBR congestion control + 64 MB socket buffers are set |
| Users can't connect via node | Verify proxy ports (443, 80, etc.) are open to end users on the node firewall |
API Reference
All API endpoints are under /api/v1/. Enable the interactive Swagger UI by setting DOCS=true and visiting /docs.
Authentication
Obtain a JWT access token by posting credentials:
/api/v1/admin/tokencurl -X POST https://panel.example.com:8443/api/v1/admin/token \ -d "username=admin&password=admin&grant_type=password" # Response: # {"access_token": "eyJ...", "token_type": "bearer"} # Use the token in subsequent requests: curl -H "Authorization: Bearer eyJ..." https://panel.example.com:8443/api/v1/system
If 2FA is enabled for the admin, include the TOTP code in the X-TOTP-Code header.
POST /api/v1/admin/2fa/setup — generate TOTP secret + recovery codesPOST /api/v1/admin/2fa/enable — verify code and activate 2FAPOST /api/v1/admin/2fa/disable — deactivate 2FA
Users
/api/v1/userCreate a new user with protocols, data limit, expiry, device limit, and IP limit.
/api/v1/usersList all users. Automatically scoped by admin for non-sudo accounts.
/api/v1/user/{username}Get detailed user info including usage stats and subscription links.
/api/v1/user/{username}Update user fields (data limit, expiry, status, protocols, etc.).
/api/v1/user/{username}Permanently delete a user and all associated data.
Bulk Operations
/api/v1/users/bulk/update/api/v1/users/bulk/delete/api/v1/users/bulk/resetExport
/api/v1/export/usersDownload all users as a CSV file.
/api/v1/export/subscription-linksExport all subscription links as plain text.
Admins
/api/v1/adminCreate a new admin with role (owner, admin, reseller), max_users, and max_traffic_bytes.
/api/v1/adminsList all admin accounts.
Nodes
/api/v1/inboundsList all protocol inbounds.
/api/v1/hostsGet host configurations (sudo only).
Analytics
/api/v1/analytics/summaryDashboard overview statistics.
/api/v1/analytics/protocolsProtocol distribution breakdown.
/api/v1/analytics/nodes/loadPer-node connection counts and bandwidth.
/api/v1/analytics/nodes/uptimeNode uptime percentages.
/api/v1/analytics/users/expiring?days=30Users expiring within the specified number of days.
/api/v1/analytics/users/top?limit=10Top users by bandwidth consumption.
Sessions
/api/v1/sessions/active?hours=24Active device sessions in the last N hours.
/api/v1/sessions/user/{username}Sessions for a specific user.
/api/v1/sessions/{session_id}Forcibly disconnect a device session.
System
/api/v1/systemSystem stats including CPU, memory, and bandwidth. Non-sudo admins see zeroed values for sensitive metrics.
/api/v1/healthHealth check endpoint returning database and Xray core status.
/metricsPrometheus-compatible metrics endpoint. Requires METRICS_ENABLED=true and METRICS_TOKEN for authentication.
DOCS=true in your .env and visit http://your-panel/docs for the interactive Swagger UI.
Telegram Bot
Setup
- Open Telegram and message @BotFather
- Send
/newbotand follow the prompts to create your bot - Copy the bot token (e.g.,
123456789:AAAA...) - Get your Telegram user ID (message @userinfobot)
- Add to your
.env:
TELEGRAM_API_TOKEN="123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" TELEGRAM_ADMIN_ID="987654321" TELEGRAM_LOGGER_CHANNEL_ID=-1001234567890
Restart the panel after adding the token. The bot will start automatically.
Bot Commands
| Command | Description |
|---|---|
/usage | Check data usage and remaining quota |
/sub | Get subscription link and QR code |
/stats | Panel statistics (admin only) |
/devices | View connected devices |
/help | List all available commands |
/broadcast | Send message to all users (admin only) |
Notification Settings
The Telegram bot sends notifications for various events when configured. Control each notification type individually via .env variables (see Notifications configuration).
Notifications are sent to:
- Admin IDs — direct messages to each admin in
TELEGRAM_ADMIN_ID - Logger channel — all events to the
TELEGRAM_LOGGER_CHANNEL_ID
Fields included in Created / Modified alerts
User Created and User Modified notifications include the following fields when set on the user:
| Field | Shown as | When included |
|---|---|---|
| Username | Username: alice | Always |
| Traffic limit | Traffic Limit: 50 GB | Always (shows "Unlimited" if unset) |
| Expire date | Expire Date: 2026-06-01 | Always (shows "Never" if unset) |
| Protocols | Proxies: vless, trojan | Always |
| Data limit reset | Data Limit Reset Strategy: monthly | Always |
| Device limit | Device Limit: 3 | Only when > 0 |
| IP limit | IP Limit: 5 (limit) | Only when > 0; mode shown inline |
| HWID limit | HWID Limit: 2 | Only when > 0 |
| Has next plan | Has Next Plan: True | Always |
| Note | Note: Paid in advance 6mo | Only when non-empty; truncated at 120 chars |
DISCORD_WEBHOOK_URL to receive the same notifications in a Discord channel. The Discord embeds include the same enriched fields.
Subscription Channels
Deliver sub URLs through infrastructure that censors can't block.
When your panel domain is blocked in Russia, Iran, China, or Turkmenistan, customers can't pull their subscription updates. Subscription channels solve this by publishing each user's config to a static file on Google / Cloudflare / GitHub / Telegram infrastructure — hostnames that censors can't blanket-block without breaking mainstream apps used by millions.
How it works
- You configure one or more channels in Settings → Subscription channels.
- Each user gets a stable public URL on that channel (e.g.
https://firebasestorage.googleapis.com/…?alt=media&token=…). - The ⊞ (grid) icon on every user row in the Users table opens a popover with all available URLs — Direct, Happ-encrypted, and every configured channel. Copy or show QR in two clicks.
- When you edit Hosts or the Xray config, the panel automatically republishes all active users to Firebase (and other channels) within ~10–30 seconds via the background worker. No manual Backfill needed after routine config edits.
Available channels
| Channel | Provider | Free tier | Best for |
|---|---|---|---|
| Firebase Storage | ~50K polls/day on Spark plan | Primary anti-censorship channel | |
| Cloudflare R2 | Cloudflare | 10 GB/month, no egress fees | Secondary; different vendor from Firebase |
| GitHub Gist | GitHub / Microsoft | Unlimited public gists | Lo-fi fallback; extremely durable |
| Telegram delivery | Telegram | Free | Emergency delivery when everything else is down |
| Nginx-proxy pool | Your VPSes | Cost of VPS | Full operator control over the relay |
Firebase Storage
Recommended first channel. Free tier covers ~50K subscription polls/day. Hosted on Google's IP space — censors can't blanket-block without breaking Google Maps, Gmail, and countless other apps.
One-time setup at console.firebase.google.com
- Create project — Add project → name it (e.g.
nexus-subs) → Spark plan (free) → Create. - Enable Storage — Build → Storage → "Get started" → "Start in production mode" → choose location → Done.
- Set storage rules — Storage → Rules → replace with:
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /sub_{file=**} {
allow read: if true;
allow write: if false;
}
}
}
- Generate service account key — Project Settings (⚙) → Service accounts → "Generate new private key" → Download. Treat as a password.
- Find bucket name — Storage → top shows
gs://your-project.firebasestorage.app. Copy the part aftergs://.
In the panel
- Settings → Subscription channels → Firebase Storage → ⚙
- Paste Bucket name and Service account JSON (entire file contents)
- Toggle Enabled, set Priority (lower = preferred;
10is a good start) - Save → click Test (refresh icon)
Reading the test result
A passing test looks like:
firebase: end-to-end OK in 1840ms ✓ creds (180ms): bucket reachable ✓ upload (650ms): published to https://firebasestorage.googleapis.com/… ✓ fetch (820ms): GET 200 (87 bytes, attempt 1) ✓ match (1ms): content matches ✓ cleanup (180ms): test blob deleted
| Step that failed | Likely cause | Fix |
|---|---|---|
creds | Service account JSON wrong or expired | Regenerate the key in Firebase Console |
upload | Storage not enabled or wrong rules | Re-check setup steps 2–3 |
fetch | Public read rule not applied | Re-paste the rules from step 3 |
match | Edge cache served stale blob (rare) | Usually retries hide this; file a bug if persistent |
cleanup | Service account read-only | Delete nexus_test_* blobs manually |
Apply to existing users
After the test passes, click Backfill at the bottom of the Subscription channels card. This immediately fans out Firebase uploads for all active users via the background worker. For 200 users expect 30–120 seconds to complete.
Cloudflare R2
S3-compatible object storage with no egress fees. Use as a secondary channel alongside Firebase — different vendor means a region-specific block on one doesn't take both down.
Setup at dash.cloudflare.com
- R2 (left sidebar) → Create bucket → name it (e.g.
nexus-subs). - Open bucket → Settings → Public access → enable. Copy the
https://pub-<id>.r2.devURL. - Top-right of R2 page → Manage R2 API tokens → Create token → Object Read & Write (limit to your bucket) → save Access Key ID + Secret.
- Your Account ID is the 32-char hex in the bottom-right of the R2 dashboard page.
In the panel
Settings → Subscription channels → Cloudflare R2 → ⚙:
| Field | Where to find it |
|---|---|
| Cloudflare account ID | 32-char hex from step 4 |
| Bucket name | e.g. nexus-subs |
| Access key ID | From step 3 |
| Secret access key | From step 3 (shown once) |
| Public URL base | https://pub-<id>.r2.dev from step 2 |
pub-<id>.r2.dev hostname benefits from the same anti-block leverage as Firebase — it's shared with thousands of other R2 buckets.
GitHub Gist
Free, GitHub-hosted (Microsoft IPs). Extremely durable — a good low-priority fallback that doesn't cost anything.
Setup
- github.com/settings/tokens → Personal access tokens → Tokens (classic) → Generate new token.
- Name:
nexus-gists. Scope: check gist ONLY. Expiration: 1 year (calendar a renewal). - Copy the
ghp_…token — you can't see it again.
In the panel
Settings → Subscription channels → GitHub Gist → ⚙ → paste the PAT → Save → Test.
Telegram Delivery
Reachable in IR/RU/TM during exactly the windows when other channels aren't. This is the "panel is on fire and the user has nothing else" fallback channel.
t.me/<bot>?start=sub_<token> deep-link, not a self-updating URL. They click it once, the bot DMs them a .txt file with their config. Set Priority very high (low priority number) only if you've wired the bot's /start handler — otherwise it goes to a bot that doesn't reply.
Setup — dedicated bot (recommended)
- Message
@BotFatheron Telegram →/newbot→ pick a name and username (must end inbot). - Copy the token BotFather gives you.
- Settings → Subscription channels → Telegram → ⚙:
- Bot username: without the @
- Bot token: paste from BotFather
- Save → Test. The test calls
getMeand asserts the returned username matches what you pasted.
If you already have a TELEGRAM_API_TOKEN set in .env for notification delivery, you can leave the bot token field blank — the channel will fall back to that env var. Not recommended for security: a leaked notification-bot token would also expose subscription files.
Nginx-Proxy Pool
When static-storage channels all go down or get region-specifically blocked, fall back to your own fleet of cheap relay VPSes. Each host in the pool gets its own domain; the panel distributes users across hosts by weight.
Pre-provisioning
Spin up cheap VPSes (Hetzner CCX13 / Contabo / etc — €4–5/month each). On each:
# Run as root on each fresh proxy host curl -sSL https://your-panel.tld/setup_proxy.sh | bash
In the panel
Settings → Subscription channels → Nginx-proxy → ⚙. Config is JSON:
{
"hosts": [
{ "host": "alpha.shop", "subscription_path": "sub", "weight": 1 },
{ "host": "beta.shop", "subscription_path": "sub", "weight": 1 },
{ "host": "gamma.shop", "subscription_path": "sub", "weight": 5 }
]
}
weight: 5 host gets 5× the user share of weight: 1. Save → Test verifies TLS handshake for each pool member.
Subscription Channels — Dashboard UI
Subscription popover (per-user)
Every row in the Users table has a ⊞ (grid) icon. Clicking it opens a popover listing every URL the operator can hand to a customer:
| Row | What it is | Copy + QR |
|---|---|---|
| Direct | Plain /sub/<token> URL served by the panel | Copy only |
| Happ (encrypted) | AES-256-CBC encrypted form via /user/<u>/encrypt-sub | Copy only |
| Firebase / R2 / Gist | Static-storage URLs from enabled channels | Copy + QR |
| Telegram | Deep-link to bot delivery | Copy only |
| Nginx-proxy | Relay URL | Copy only |
URLs are pre-fetched when the popover opens so Copy is gesture-safe — no async delay between click and clipboard write.
Channel health badges
Each channel row in Settings → Subscription channels shows a health badge from the last probe. The cron runs every 15 minutes. Force a fresh probe at any time with the Test button.
Auto-republish on config changes
Editing Hosts or the Xray core config triggers an automatic fan-out: all active users whose subscription content changed are re-published to every configured channel within ~10–30 seconds. The worker skips users whose rendered config didn't change (content-hash short-circuit), so a host edit that only affects 50 of 200 users causes only 50 Firebase writes.
Backfill
The Backfill button (at the bottom of the Subscription channels card) immediately uploads all active users to all enabled channels. Use it once after adding a new channel — after that, auto-republish keeps everything current.
Hysteria2
Hysteria2 is a QUIC/UDP-based protocol that delivers 3–5× the throughput of TCP on lossy last-mile networks (mobile, CIS 4G, Iran). It runs as a separate daemon alongside Xray — not as an Xray inbound — because Xray-core does not support the hysteria2 protocol natively.
License gate: Standard and above. Trial tier can see hy2 subscription entries but cannot create or manage inbounds.
2053) is open in your hosting provider's control panel — Contabo, Aeza, PTR all gate UDP by default.
Add a Hysteria2 Inbound
Dashboard → Settings → Hysteria2 → Add Inbound
| Field | Value | Notes |
|---|---|---|
| Tag | hy2-main | Any unique name |
| Listen port | 2053 | UDP — must be open in firewall |
| Obfs type | salamander | Recommended — hides UDP from DPI in CN/IR/RU |
| Obfs password | strong random | openssl rand -hex 24 |
| Masquerade URL | https://www.bing.com | HTTPS site hysteria impersonates for DPI probes |
| SNI | bing.com | TLS SNI presented to clients |
| TLS cert / key | leave blank for auto | Panel auto-generates a 10-year self-signed cert if omitted |
Creating the inbound syncs the config to every connected node and spawns the hysteria daemon on each. No SSH required.
Add Hosts per Node
Dashboard → Hosts → click the Hysteria2 inbound card → Add Host
Add one host row per node you want to expose hy2 on:
| Field | Example | Required |
|---|---|---|
| Remark | DE Frankfurt hy2 | Yes |
| Address | de.example.com | Yes — node's public domain or IP |
| Port | 2053 | Yes — the UDP port on that node |
| Country code | DE | Recommended — drives regional sub reorder |
Subscription renderers automatically emit hy2:// entries for every enabled host alongside the existing VLESS/VMess links. Clients see it on the next sub refresh.
Via API (sudo admin credentials required):
# 1. Get a token using your superadmin username and password TOKEN=$(curl -s -X POST /api/v1/admin/token \ -d "username=YOUR_ADMIN&password=YOUR_PASSWORD" \ | jq -r .access_token) # 2. List hy2 inbounds curl /api/v1/hy2-inbounds -H "Authorization: Bearer $TOKEN" # 3. Add a host to inbound id=1 curl -X POST /api/v1/hy2-inbounds/1/hosts \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"remark":"DE Frankfurt","address":"de.example.com","port":2053,"country_code":"DE"}'
Verify & Troubleshoot
After adding a host, fetch a subscription URL — you should see a hy2:// entry alongside the VLESS links. Import into Nekobox, sing-box, or Happ and connect.
| Issue | Diagnosis |
|---|---|
No hy2:// in sub | Check the host is enabled and country_code is set; verify license tier is Standard+ |
| UDP connection refused | Test: nc -vu <node> 2053 from outside the DC. Port not open in provider firewall. |
| UDP timeout | ISP or middlebox eating UDP — try obfs salamander or a different port |
| TLS error | Self-signed cert: ensure client has allowinsecure: true or supply the cert's fingerprint |
| Daemon not starting on node | docker logs nexus-node 2>&1 | grep hysteria on the node server |
Middle Server (NAT Relay)
A middle server is a cheap VPS that sits between your users and your nodes. It relays traffic via iptables DNAT, so users connect to one stable IP regardless of which node handles them. Useful when a node IP gets blocked in a country — swap the node, regenerate NAT on the middle server, update one host entry.
Initial Setup
Generate a one-time install command from the panel, then paste it on the fresh VPS as root:
- In the panel, go to Settings → Middle-servers and click Generate install command.
- Copy the command shown — it looks like:
curl -fsSLk https://panel.example.com:8443/api/v1/middle-server/i/<token> | sudo bash
The token is single-use and expires in 30 minutes. No credentials appear in shell history.
Manual / scripted install (no panel UI access)
read -p "Panel URL: " _P read -p "Admin username: " _U read -sp "Admin password: " _W; echo curl -fsSL -k -u "$_U:$_W" "$_P/api/v1/middle-server/bootstrap.sh" | sudo bash unset _P _U _W
The script is generated from your current node + Hysteria 2 inbound list, so re-run it any time you add or remove either.
The script will:
- Install
iptables-persistent - Apply kernel tuning (BBR, big buffers, conntrack)
- Build all DNAT rules from current panel DB state
- Print a table of host entries to add in the panel
After the script completes, go to Hosts page and add one host entry per row the script printed, using the middle server IP and the printed port.
Swapping to a New Middle Server
When the current middle server is blocked or you want to move to a different VPS:
- SSH into the new VPS and run the same single command above
- In the panel → Hosts page, edit every host whose address points to the old middle server IP and change it to the new IP. Ports stay the same.
- Done — no node changes, no user reconfiguration needed
Migration from Marzban
The nexus cli migrate tool moves a live Marzban installation to NexusPanel with zero end-user reconfiguration. It runs on the same host as Nexus, reads Marzban's data directory directly, and uses a 9-stage atomic state machine with full rollback until the finalize command.
JWT_SECRET_KEY and stores it as MARZBAN_LEGACY_JWT_SECRET in Nexus's env. Every existing Marzban subscription URL keeps working on day one — users never re-import anything.
Prerequisites
- Marzban version 0.6.0–0.8.4 (official install script,
marzbanormarzban_cli) - NexusPanel installed on the same host, or able to read
/var/lib/marzban/ - Free disk for a snapshot of Marzban's SQLite DB
Step 1: Dry Run
Always dry-run first. It snapshots Marzban's DB, replays every import into a scratch copy, and finishes in seconds. Nothing is written to Nexus or Marzban.
# Inspect what was found nexus cli migrate discover # Rehearse: snapshot + import + verify on scratch DB, no side effects nexus cli migrate run --dry-run
Read the dry-run report at /var/lib/nexus/migration/dryrun-<ts>.json. Confirm user count, admin list, and that MARZBAN_LEGACY_JWT_SECRET was extracted. Fix any flagged errors before proceeding.
Step 2: Live Cutover
# Live run — stops Marzban, imports, restarts Nexus
nexus cli migrate run --yes
The critical path (MARZBAN_STOP → NEXUS_RESTART) takes ~15–30 seconds. Node VPN traffic continues uninterrupted — nodes run independently of the panel. Only the subscription URL endpoint is briefly unavailable.
If VERIFY fails, auto-rollback fires: Nexus configs are restored and Marzban is restarted. Check docker logs nexus-panel --tail 200 for the root cause, then re-run.
# After watching prod for a few hours: nexus cli migrate finalize # frees snapshot, closes the run # If you need to undo (pre-finalize only): nexus cli migrate rollback
What Migrates
| Data | Migrated | Notes |
|---|---|---|
| Users (username, data, expiry) | Yes | All profiles, quotas, UUIDs preserved |
| User proxies / protocols | Yes | VMess, VLESS, Trojan, Shadowsocks |
| Admin accounts | Yes | Passwords carried over |
| Hosts (proxy endpoints) | Yes | All host rows copied, Nexus-only fields default to off |
| Xray inbounds | Yes | Copied from Marzban's xray_config.json |
| Telegram bot token, NOTIFY_* flags | Yes | Written to Nexus .env |
| JWT secret (sub URL compat) | Yes | Stored as MARZBAN_LEGACY_JWT_SECRET — existing sub URLs keep working |
| Notification reminder history | Yes | Prevents re-firing "expires in 3 days" alerts |
| Node configurations | No | Nexus uses ports 62060/62061; re-add nodes via dashboard with a fresh cert |
| Xray routing/dns/outbounds | No | Inbounds only; paste custom blocks into Settings → Core editor after migration |
| Hysteria2 hosts | No | Marzban has no hy2 — add via Dashboard → Hosts after migration |
Post-Migration Checklist
After nexus cli migrate run --yes completes, the CLI prints a table of migrated hosts. Verify them and then:
- Test a legacy Marzban subscription URL — it must return a valid config (JWT compat check)
- Check user count:
docker exec nexus-panel sqlite3 /var/lib/panel/db.sqlite3 'SELECT COUNT(*) FROM users;' - Run
nexus cli migrate post-cutoverto scan for stale Marzban daemons (marzguard, certbot cron hooks) - If you use Hysteria2: add inbound + one host per node (see the Hysteria2 section)
- Re-add nodes via Dashboard → Nodes (new cert, ports 62060/62061)
- Run
nexus cli migrate finalizeto free the snapshot once stable
# Health curl -sk https://<your-domain>/api/v1/health # Legacy sub URL must return 200 with config content curl -sk "https://<your-domain>/sub/<marzban-token>" | head -c 200 # Scan for Marzban leftovers nexus cli migrate post-cutover
Security
Two-Factor Authentication (2FA)
NexusPanel supports TOTP-based 2FA (compatible with Google Authenticator, Authy, etc.):
- Navigate to Settings in the dashboard
- Click Enable 2FA
- Scan the QR code with your authenticator app
- Enter the 6-digit code to confirm
- Save recovery codes in a secure location
Via API:
# Generate TOTP secret and recovery codes curl -X POST /api/v1/admin/2fa/setup -H "Authorization: Bearer TOKEN" # Activate 2FA (provide TOTP code to verify) curl -X POST /api/v1/admin/2fa/enable \ -H "Authorization: Bearer TOKEN" \ -H "Content-Type: application/json" \ -d '{"code": "123456"}' # Login with 2FA curl -X POST /api/v1/admin/token \ -H "X-TOTP-Code: 123456" \ -d "username=admin&password=admin&grant_type=password"
Captcha Protection
Protect the login page against brute-force attacks with captcha:
Cloudflare Turnstile
CAPTCHA_PROVIDER="turnstile" TURNSTILE_SITE_KEY="0x4AAAAAAA..." TURNSTILE_SECRET_KEY="0x4AAAAAAA..."
Built-in Captcha
CAPTCHA_PROVIDER="builtin"
The built-in captcha requires no external services and generates simple math challenges.
Rate Limiting
Login endpoint rate limiting is enabled by default:
LOGIN_RATE_LIMIT="10/minute" LOGIN_LOCKOUT_THRESHOLD=10 LOGIN_LOCKOUT_DURATION_MINUTES=30
After 10 failed attempts, the IP is locked out for 30 minutes. The rate limiter is in-memory (per-process) and resets on server restart.
SSL / TLS
For production deployments, always use HTTPS. Options include:
- Direct SSL — set
UVICORN_SSL_CERTFILEandUVICORN_SSL_KEYFILE - Reverse proxy — use Nginx or Caddy in front with SSL termination
- Cloudflare — proxy through Cloudflare with Full (Strict) SSL mode
Nginx Reverse Proxy Example
server { listen 443 ssl http2; server_name panel.example.com; ssl_certificate /etc/letsencrypt/live/panel.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/panel.example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }
FAQ
How to Change Admin Password
Option 1: Update the SUDO_PASSWORD environment variable and restart the panel.
Option 2: Use the API:
curl -X PUT /api/v1/admin/admin \ -H "Authorization: Bearer TOKEN" \ -H "Content-Type: application/json" \ -d '{"password": "newSecurePassword123"}'
How to Backup
SQLite
# Stop the panel first for a clean backup docker compose stop panel cp /var/lib/nexuspanel/db.sqlite3 /backups/db-$(date +%Y%m%d).sqlite3 docker compose start panel # Or use SQLite online backup (no downtime) sqlite3 /var/lib/nexuspanel/db.sqlite3 ".backup /backups/db-$(date +%Y%m%d).sqlite3"
PostgreSQL
docker compose exec db pg_dump -U nexus nexuspanel > /backups/db-$(date +%Y%m%d).sql
.env, xray_config.json, and any custom templates.
How to Update
cd /opt/nexuspanel # Pull latest images docker compose pull # Restart with new version docker compose up -d # Check logs for migration status docker compose logs -f panel
Database migrations run automatically on startup. Always backup your database before updating.
How to Add Custom Templates
Custom templates let you control subscription output for various clients:
- Create your template files in the templates directory:
mkdir -p /var/lib/nexuspanel/templates/clash nano /var/lib/nexuspanel/templates/clash/custom.yml
- Reference the template in
.env:
CUSTOM_TEMPLATES_DIRECTORY="/var/lib/panel/templates/" CLASH_SUBSCRIPTION_TEMPLATE="clash/custom.yml"
Templates support Jinja2 syntax with access to user data, proxy configs, and panel settings.
Subscription Page Customization
The user-facing subscription page (shown when visiting a subscription link in a browser) is fully customizable:
- Copy the default template as a starting point:
cp -r /opt/nexuspanel/app/templates/subscription \ /var/lib/nexuspanel/templates/subscription
- Edit
/var/lib/nexuspanel/templates/subscription/index.html - Set in
.env:
SUBSCRIPTION_PAGE_TEMPLATE="subscription/index.html"
Available template variables include: user, sub_url, clash_url, singbox_url, usage, expire_date, and brand_name.
NexusPanel Documentation — Built with care.