Marzban install: panel and nodes with VLESS Reality

Marzban install on a VPS: the Docker panel, marzban-node servers, a VLESS + Reality + Vision inbound, subscriptions, backups and fixes for common failures (Sept 2026).

This guide covers the layout most of our customers run: the panel on a separate small VPS and one node per location, for example in the Netherlands and Germany. Versions at the time of writing: panel v0.8.4, marzban-node v0.5.2, stable Xray-core v26.3.27.

What you'll need

  • A VPS for the panel. 1 vCPU and 1 GB of RAM are enough, 2 GB with MariaDB or MySQL: the script gives the database a 256 MB buffer. Ubuntu 22.04/24.04 or Debian 12.
  • One VPS per location. Only outbound traffic counts toward a ForestsNet Cloud traffic package, but on a VPN node almost everything is outbound: what a client downloads goes out to the client, and what it uploads goes out to the internet.
  • Your own domain: a subdomain for subscriptions with an A record pointing to the panel IP, plus one subdomain per node, see Pointing a domain at your server.
  • SSH access as root or a sudo user. Disable password logins first: How to harden SSH on your VPS.

Marzban in 2026: should you start with it

It works, but it's no longer developed.

  • The latest release is v0.8.4 from January 9, 2025, and master hasn't changed since. marzban-node v0.5.2 came out in March 2025, and in August one of the developers wrote that it's no longer supported.
  • Version 1.0 reached beta images in summer 2025, then its developers left for a separate project, PasarGuard: in their words, the owners replied once every few months. PasarGuard is now at v5.x with its own Go node.
  • A burst of commits in the dev branch in June 2026 ended without a release, and the "is this project dead?" issue got no answer.
  • Since September 2025 there's an open incompatibility with newer Xray cores; the workaround is in step 3.

If you're starting from scratch, especially a commercial service with several nodes, pick Remnawave: it's actively developed, its nodes keep working without the panel, and each node can get its own Xray profile, see Remnawave installation. Marzban makes sense if it already runs for you, or for a small setup you're ready to fix yourself. For moving away there's Remnawave's migration tool (old Marzban subscription links keep working there) and PasarGuard's migration guide.

How Marzban is built

  • The panel is a Python (FastAPI) app running in Docker with network_mode: host. It keeps users in a database, builds the Xray config from xray_config.json by injecting them into clients, and runs its own Xray core. So the panel server listens on every inbound too.
  • Nodes run the marzban-node agent. The panel connects to it over REST with mutual TLS on SERVICE_PORT (62050) and pushes the config, then uses the Xray gRPC API on XRAY_API_PORT (62051) to add users without restarts and to collect stats.
  • Subscriptions live at /sub/<token>/ on the same panel. Server entries come from Host Settings: one host means one entry in the subscription.

Consequences:

  • Every node gets the same config and runs every inbound of the panel; the INBOUNDS variable on a node narrows that set.
  • On marzban down, restart and update the panel disconnects the nodes, and Xray there stays down until the panel is back; saving Core Settings restarts the cores everywhere. Work on the panel means an outage for all clients.
  • If the panel dies abruptly, nodes keep serving with the last config, but a rebooted node won't start Xray: the panel is what delivers its config.
  • The certificate used to talk to nodes and the secret that signs subscription links live in the database. Lose the database and you re-connect every node and hand out new links.

Step 1. Install the panel

SQLite, MariaDB or MySQL

  • SQLite is the default: a single file, /var/lib/marzban/db.sqlite3, nothing to maintain. Fine for a personal VPN and a few dozen users.
  • MariaDB or MySQL runs as a separate container with data in /var/lib/marzban/mysql and port 3306 on 127.0.0.1 only. Choose it for hundreds of users or several nodes: every 10 seconds the panel writes each user's traffic on each node to the database, and SQLite starts answering database is locked.

Moving from SQLite to MySQL later is manual work with downtime, following a recipe from the docs, so decide up front.

Run the install script

# SQLite
sudo bash -c "$(curl -sL https://github.com/Gozargah/Marzban-scripts/raw/master/marzban.sh)" @ install

# MariaDB (use --database mysql for MySQL)
sudo bash -c "$(curl -sL https://github.com/Gozargah/Marzban-scripts/raw/master/marzban.sh)" @ install --database mariadb

The script installs Docker, asks for a database password with MariaDB or MySQL (Enter generates one), starts the containers and tails the logs; Ctrl+C only leaves the logs. docker-compose.yml and .env live in /opt/marzban, the Xray config, database and certificates in /var/lib/marzban, and the marzban command manages it all.

Key .env variables

Edit with marzban edit-env, apply with marzban restart.

  • UVICORN_HOST, UVICORN_PORT: app address and port, 0.0.0.0 and 8000 by default.
  • UVICORN_SSL_CERTFILE, UVICORN_SSL_KEYFILE: a certificate if the panel serves HTTPS itself. The files must be inside /var/lib/marzban, the only host directory mounted into the container. The panel rejects a self-signed certificate unless UVICORN_SSL_CA_TYPE = "private".
  • DASHBOARD_PATH: the admin UI path, /dashboard/ by default.
  • XRAY_SUBSCRIPTION_URL_PREFIX: the public subscription URL, such as https://sub.example.com:8443. Leaving it empty is the most common mistake: links come out relative. The path is set by XRAY_SUBSCRIPTION_PATH, sub by default.
  • SUB_UPDATE_INTERVAL, SUB_PROFILE_TITLE, SUB_SUPPORT_URL: auto-update interval (hours, 12 by default), profile name and support link.
  • TELEGRAM_API_TOKEN, TELEGRAM_ADMIN_ID: the built-in bot and panel login notifications.
Important: don't create an admin through SUDO_USERNAME and SUDO_PASSWORD in .env: the developers advise against it, and tokens issued to such an admin survive a password change and stay valid for a day.

Step 2. Expose the panel and subscriptions

Without a certificate Marzban listens on 127.0.0.1:8000 only, even with UVICORN_HOST = "0.0.0.0" in .env; that's how its startup code works. Create the first admin (the command asks for a username and password) and open a tunnel from your computer:

marzban cli admin create --sudo
ssh -L 8000:127.0.0.1:8000 root@PANEL_IP

The panel opens at http://127.0.0.1:8000/dashboard/. Subscriptions, however, must be reachable by clients at all times, and the same app serves them, so only the /sub/ path goes public. Port 443 on the panel server belongs to Reality (the panel's core listens on the same inbounds as the nodes), so subscriptions go on 8443: get a certificate as described in Free SSL with Let's Encrypt and add an nginx site.

server {
    listen 8443 ssl;
    server_name sub.example.com;

    ssl_certificate     /etc/letsencrypt/live/sub.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/sub.example.com/privkey.pem;

    location /sub/ {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
    }

    location / {
        return 404;
    }
}

Set XRAY_SUBSCRIPTION_URL_PREFIX = "https://sub.example.com:8443" in .env and run marzban restart.

Important: don't put the subscription domain behind Cloudflare if your users are in Russia. Since June 9, 2025 Russian DPI lets only the first 15–20 KB of a connection to Cloudflare IPs through, so anything longer never reaches the client. Keep the subscription subdomain DNS-only.

The docs' own route is simpler — a certificate in /var/lib/marzban/certs/ plus the UVICORN_SSL_* variables — but then the admin UI and API are public. If you choose it, change DASHBOARD_PATH, turn on login notifications, and don't set UVICORN_PORT = 443: Reality needs that port.

Step 3. Configure the VLESS + Reality + Vision inbound

The script places a sample in /var/lib/marzban/xray_config.json with a single Shadowsocks inbound on port 1080; replace it entirely. Generate the keys and a shortId:

docker exec marzban-marzban-1 xray x25519
openssl rand -hex 8

Up to v25.8.3 the core prints Private key and Public key; later versions print PrivateKey and Password, which is the public key. In links it becomes the pbk parameter.

{
  "log": {
    "loglevel": "warning"
  },
  "routing": {
    "rules": [
      { "type": "field", "ip": ["geoip:private"], "outboundTag": "BLOCK" },
      { "type": "field", "protocol": ["bittorrent"], "outboundTag": "BLOCK" }
    ]
  },
  "inbounds": [
    {
      "tag": "VLESS TCP REALITY",
      "port": 443,
      "protocol": "vless",
      "settings": { "clients": [], "decryption": "none" },
      "streamSettings": {
        "network": "tcp",
        "security": "reality",
        "realitySettings": {
          "dest": "127.0.0.1:8443",
          "serverNames": ["nl.example.com", "de.example.com"],
          "privateKey": "YOUR_PRIVATE_KEY",
          "publicKey": "YOUR_PUBLIC_KEY",
          "shortIds": ["6ba85179e30d4fc2"]
        }
      },
      "sniffing": { "enabled": true, "destOverride": ["http", "tls", "quic"] }
    }
  ],
  "outbounds": [
    { "protocol": "freedom", "tag": "DIRECT" },
    { "protocol": "blackhole", "tag": "BLOCK" }
  ]
}

Key points:

  • dest is your site on the same node, nginx at 127.0.0.1:8443 (set up below). Current Xray docs call the field target, but older cores understand dest too.
  • serverNames lists your nodes' domains, with no empty string. Marzban picks the SNI for each link at random from this list, so each node's SNI is set explicitly in Host Settings.
  • shortIds needs at least one value or the panel won't start: even-length hex, up to 16 characters.
  • publicKey is ignored by the Xray server, but Marzban v0.8.4 takes the key from here. Without it the panel parses xray x25519 output in the old format and crashes on startup with core v25.8.29 or newer.
  • Keep network as tcp: Marzban copies it into links, and the link standard doesn't know the new name raw.
  • Vision (xtls-rprx-vision) is set per user, not in the inbound; see step 5.

Paste the config into Core Settings: the panel validates the JSON, saves the file and restarts the core on every node. Or edit the file and run marzban restart. The logs should show Xray core ... started.

Your own site for Reality on each node

On ForestsNet Cloud servers Reality may only run on your own domain and your own site. Someone else's domain (google.com, microsoft.com and the like) in the SNI, dest or certificate gets addresses blocked, which hurts every customer on the network, so our Terms of Service prohibit it, and certificates on public ports are checked automatically.

The scheme: each node gets its own subdomain, such as nl.example.com and de.example.com, with an A record pointing to its IP and no Cloudflare proxying. On the node, nginx with a Let's Encrypt certificate listens on 127.0.0.1:8443, dest points at it, and anyone who opens the node's port 443 without your key sees your site with a real certificate. One inbound fits all nodes: dest is local everywhere, and serverNames lists every node's domain. On each node run:

sudo apt install -y nginx certbot
sudo certbot certonly --webroot -w /var/www/html -d nl.example.com --deploy-hook "systemctl reload nginx"

Certbot validates the domain through nginx's default site on port 80 and then renews the certificate on its own, so keep port 80 open on the node. Put your own page into /var/www/html/index.html and create /etc/nginx/conf.d/reality.conf:

server {
    listen 127.0.0.1:8443 ssl http2;
    server_name nl.example.com;

    ssl_certificate     /etc/letsencrypt/live/nl.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/nl.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;

    root /var/www/html;
}

Apply it with sudo nginx -t && sudo systemctl reload nginx and check: curl -sSI --http2 --resolve nl.example.com:8443:127.0.0.1 https://nl.example.com:8443 | head -1 should print HTTP/2 200.

Once the node is connected (step 4), enter its domain in the SNI field of its host in Host Settings: otherwise Marzban puts a random name from serverNames into the link. On the panel server dest leads to the subscription nginx from step 2; if the panel serves clients too, add the subscription domain to serverNames and to its host's SNI.

Step 4. Connect the nodes

In the panel open Node Settings → Add New Marzban Node → Show certificate. Copy it whole, from BEGIN to END: there's one certificate per panel, valid for every node. On the node server run:

sudo bash -c "$(curl -sL https://github.com/Gozargah/Marzban-scripts/raw/master/marzban-node.sh)" @ install

The script asks you to paste the certificate (an empty line ends the input), then asks about the protocol and ports. Answer yes to REST: this way the node refuses to start without the panel certificate, while in legacy rpyc mode anyone can connect to a node that has none. Keep ports 62050 and 62051. The certificate lands in /var/lib/marzban-node/cert.pem, and the marzban-node command manages the node.

Open the management ports to the panel IP only. The Xray API on 62051 is shielded from strangers by nothing but a routing rule on the source IP, and management ports left open are visible to scanners.

sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow from PANEL_IP to any port 62050,62051 proto tcp
sudo ufw enable

The node runs with network_mode: host, so ufw applies as usual, see The ufw firewall: managing ports.

Back in the panel, fill in the form: a name (for example NL-1), the node's IPv4, ports 62050 and 62051. Usage Ratio multiplies the node's traffic when limits are counted, such as 1.5 for an expensive location. Add this node as a new host for every inbound creates hosts with the node IP in every inbound.

Done: the node shows as connected with its Xray version, and user subscriptions now include an entry with its IP.

Every inbound already has a default host, {SERVER_IP}, which is the panel's own IP. If the panel shouldn't serve clients, delete that host and close 443 on its firewall.

Update the core with marzban core-update on the panel and marzban-node core-update on nodes; keep one stable version everywhere. The menu lists the latest releases including pre-releases (10 on the panel, 5 on a node), and stable v26.3.27 is no longer among them: enter it manually via option M. Pre-releases v26.9.8 and newer reject clients whose handshake lacks an X25519MLKEM768 key share, which as of September 2026 means every sing-box and mihomo client.

Step 5. Create users and hand out subscriptions

In the panel click Create new user:

  • Protocol VLESS, and in its settings set Flow to xtls-rprx-vision. Without it the link works, but without Vision.
  • Data Limit and Periodic Usage Reset: no reset, daily, weekly, monthly or annually.
  • Expiry Date, or On Hold mode: the term starts at the first connection, so a key bought in advance doesn't expire unused.

If a link leaks, click Revoke Subscription and the old one stops working.

A subscription link is https://sub.example.com:8443/sub/<token>/. Clients built on mihomo (Clash Verge, FlClash) get YAML, sing-box, Hiddify and Karing get sing-box JSON, other clients get base64 links, and a browser gets a page with QR codes. To force a format, append v2ray, clash-meta or sing-box to the link.

Clients that support Reality and Vision:

  • Android: v2rayNG, Happ, v2RayTun, Hiddify.
  • iOS and macOS: Happ, Streisand, V2Box, OneXray (formerly FoXray), Karing, Shadowrocket.
  • Windows and Linux: v2rayN, Hiddify, Happ, Clash Verge Rev, Throne (the successor of NekoRay).

Check clients in regional app stores: as of September 2026 the official Happ, Streisand and V2Box are missing from the Russian App Store, while apps with similar names from other developers are there.

Step 6. Backups, updates and logs

marzban backup builds /opt/marzban/backup/backup_<date>.tar.gz: the database (a SQLite copy or a MariaDB/MySQL dump), .env, docker-compose.yml and /var/lib/marzban minus alternative cores and raw MySQL files. marzban backup-service adds a cron job and sends archives to a Telegram bot. Caveats:

  • Each run deletes the previous archive, so the server only keeps the latest one: copy it elsewhere.
  • SQLite is copied live with a plain cp. A consistent copy comes from sqlite3 /var/lib/marzban/db.sqlite3 ".backup '/root/marzban.sqlite3'" (the sqlite3 package).
  • Retention schemes are covered in Database backups.

marzban update and marzban-node update pull the latest image and recreate the containers; for v0.8.4 there's nothing to update until a new release appears. Don't use --dev in production: the dev image was built in June 2025, and the 2026 builds failed.

marzban logs and marzban-node logs follow the logs, -n prints them without following; Xray logs of the panel and of every node are visible in the Core Settings window. Docker doesn't rotate container logs by default: set max-size and max-file in /etc/docker/daemon.json and recreate the containers.

For handling abuse complaints, turn on the connection log with "access": "/var/lib/marzban/access.log" in the log section; the path works on nodes too. Without logrotate this log fills the disk quickly.

Troubleshooting

Node stuck in connecting or error

  • Stuck in connecting, or Xray on the node restarts every 10 seconds: the panel can't reach 62050 or 62051. Check from the panel: nc -vz NODE_IP 62050, then 62051.
  • TLS errors. The panel logs show EOF occurred in violation of protocol and marzban-node logs shows certificate errors: the certificate was pasted partially or comes from another panel. Copy it again into /var/lib/marzban-node/cert.pem and run marzban-node restart. After reinstalling the panel without restoring the database the certificate is new, and every node needs it.
  • Port in use. An error status with address already in use means the inbound port on the node is busy, usually nginx on 443; check with ss -ltnp | grep ':443'.

Reality doesn't connect

  • The log shows the reason. With "loglevel": "info", cores since v25.10.15 log REALITY: processed invalid connection with a cause: server name mismatch means the SNI isn't in serverNames, authentication failed means a wrong key or shortId, target sent incorrect server hello points at dest.
  • The client didn't refresh its subscription after you changed keys, shortIds or flow. On a flow mismatch the server rejects the connection.
  • The local site doesn't respond. If nginx isn't listening on 127.0.0.1:8443, Reality lets nobody in. Run the same curl check on the node: connection refused means nginx isn't listening on the port (ss -ltnp | grep 8443); a certificate error means it expired and wasn't renewed, so check certbot renew --dry-run and port 80; HTTP/1.1 instead of HTTP/2 means listen lacks http2, and the REALITY README requires HTTP/2.
  • The host address is a domain behind Cloudflare. Reality only works directly to the node IP.
  • A v26.9.x pre-release core with a sing-box or mihomo client: roll the core back to v26.3.27.
  • The connection opens but stalls after 15–20 KB. Since June 2025 Russian DPI throttles connections to subnets of a number of hosting providers this way, and no inbound setting changes that. Test the client's network with the TCP 16-20 checker; a different IP or location, or a relay server inside Russia, helps.
  • It fails on one network only. Censors in Russia, Iran and China also block whole IP ranges or put mobile networks on allowlists where no foreign IP is reachable. ForestsNet Cloud IPs are checked daily against Russian DPI (TSPU) blocks, so start with the client's network.

The panel won't start after a core change

TypeError: 'NoneType' object is not subscriptable in marzban logs is the new xray x25519 output format. Add publicKey as in step 3, or go back to the built-in core: remove XRAY_EXECUTABLE_PATH from .env and run marzban restart.

Subscription doesn't update

  • Links point to a local address: XRAY_SUBSCRIPTION_URL_PREFIX is empty, so the panel uses the address you opened the admin UI from, such as 127.0.0.1.
  • The client gets 404: the nginx location doesn't match XRAY_SUBSCRIPTION_PATH, the subscription was revoked, the user was recreated or the database is new.
  • TLS error in the client: the certificate was renewed but nginx didn't reload it; add systemctl reload nginx to the certbot deploy hook.
  • Empty server list: the panel didn't recognize the User-Agent; append the format to the link.

High load

  • The panel loads the CPU and database: raise JOB_RECORD_USER_USAGES_INTERVAL and JOB_RECORD_NODE_USAGES_INTERVAL to 30–60 seconds. If you don't need per-node stats, set DISABLE_RECORDING_NODE_USAGE = True: the hourly per-node traffic table grows fastest.
  • SQLite locking (database is locked): move to MariaDB using the recipe in the docs.
  • You can't add uvicorn workers: Marzban is single-process, and the worker count is hardcoded with a "do not change" comment. If that becomes the limit at thousands of users, look at Remnawave or PasarGuard.

What's next

Sources