PasarGuard on two servers: panel, node, and VLESS + Reality from installation to a working connection

This is an end-to-end guide to deploying PasarGuard — a fork of Marzban, the Xray-core management panel with subscriptions, statistics, and nodes placed on separate machines. We will set up the panel on one server, the node on the second, connect them via gRPC, secure the panel with a Let’s Encrypt battle-tested certificate through Caddy, and configure not a toy Shadowsocks, but the current VLESS + Reality — masqueraded as another TLS. In the end we will have a user whose link/subscription can be opened in the client and connected.

Nearly everything is done through the web panel — I show the exact pages and forms in the screenshots. We only switch to the command line where there is no other way: installing Docker, generating the node certificate, Reality keys, and launching containers. Everything is taken from a live stand: panel v5.2.1, node v0.5.3, Xray core 26.3.27. Every error in the text is a real log, not a retelling.

Along the way we will dissect eight places where the installation can stumble:

  1. the official docker-compose.yml pulls images from Docker Hub, which returns 429;
  2. the .env format from the repository breaks the Docker variable parser;
  3. the panel listens on TLS only on localhost — cannot be reached from outside;
  4. the base Caddy image does not support Cloudflare and does not start with DNS-01;
  5. the static admin from environment variables in recent versions is forbidden;
  6. the CLI is hidden not where you would expect to find it;
  7. node certificate: without a file — endless restarts, without SAN for IP — the panel refuses;
  8. in the Certificate field of the node card you can easily place the wrong certificate. Almost none of these are explained in the interface: the only exception is the ban on env-admin, and even that without a hint on what to do next. The rest is diagnosed only through docker logs.
Note:

The domain in the examples is pg-test.gig.ovh. The server IPs in the screenshots are replaced with addresses from the documentation range (203.0.113.10 — node, 198.51.100.10 — panel). Substitute your own. Private Reality key, node api_key, and user UUID in the text are placeholders — generate your own.

Architecture

The panel and the node live on different machines, and this is not a whim. The panel is the orchestrator: it stores users, cores, hosts, generates subscriptions, and collects statistics. The node is the “hands”: it runs Xray, which accepts clients. Between them is a gRPC control channel, encrypted with a self-signed certificate.

  • Server A (panel): PasarGuard panel + TimescaleDB + Caddy (reverse-proxy and TLS). Exposed to the outside — 80/443 and SSH.
  • Server B (node): PasarGuard node + Xray. Accepts a management connection from the panel on 62050 and VLESS Reality clients on 443.

Hardware requirements

The stand is built on two almost identical VPS. Real numbers from the stand are the minimum on which everything runs without swapping with one node and a handful of users.

Parameter Server A (panel) Server B (node)
OS Ubuntu 26.04 LTS Ubuntu 26.04 LTS
Kernel 7.0.0-14-generic 7.0.0-14-generic
Architecture x86_64 (KVM) x86_64 (KVM)
vCPU 2 2
RAM 3.3 GB 3.3 GB
Disk 99 GB (used ~5 GB) 79 GB (used ~4 GB)

The most demanding component on the panel is not the panel itself, but TimescaleDB: its image weighs about 2 GB, and the process idle takes several hundred megabytes. SQLite would be enough for the panel, but then you lose per-node statistics from the panel (discussed below). For TimescaleDB allocate 2 GB as a working minimum.

Port table

Port Where External? Purpose
22 A, B yes (better restrict) SSH
80 A yes HTTP → redirect to HTTPS (DNS-01 validation not needed for ACME)
443 A yes HTTPS: panel and subscriptions
8000 A no (localhost only) panel UVicorn behind Caddy
5432 A no (127.0.0.1) TimescaleDB
62050 B only from panel IP gRPC: panel↔node management channel
443 B yes inbound VLESS Reality client

Note: 443 is occupied on both servers, but they are different machines — on A it is listened to by Caddy, on B by Xray. There is no conflict.

Why TimescaleDB instead of SQLite

PasarGuard out of the box supports SQLite, and for “panel plus one local node” that is enough. But once nodes are moved to separate servers and you want graphs for each, the limitation appears directly from .env.example:

## Usage recording. Node stats recording is only available for PostgreSQL/TimescaleDB.
# ENABLE_RECORDING_NODES_STATS = False

Node statistics recording only works on PostgreSQL/TimescaleDB. On SQLite the flag does not affect anything, and the node statistics section disappears. Therefore the database should be TimescaleDB (PostgreSQL with a time-series extension, which fits well for minute-by-minute traffic statistics).

Important:

Choosing the database is done before the first run. Migrating SQLite → PostgreSQL on a live panel is a separate pain. If there is a chance that there will be more than one node, go for TimescaleDB from the start.

What to prepare in advance

To avoid stumbling mid-installation, assemble all of this before the first command:

  • two VPS with root access (specs as in the table above) and their public IPs: PANEL_IP for Server A, NODE_IP for Server B;
  • a domain for the panel (in the examples pg-test.gig.ovh), delegated to Cloudflare NS — DNS-01 works through its API;
  • a Cloudflare account and an API token with Zone:DNS rights for your zone (how to create it — in the Caddy section);
  • ensure your provider or cloud firewall does not block the ports listed in the table.

An A-record for the domain is convenient to set up right away — while you install Docker and bring up the panel, it will propagate through DNS.

Preparation of both servers

On a clean Ubuntu, install Docker with the official script — it automatically adds the repository and installs the engine with the compose plugin. Commands are the same for A and B:

# as root
curl -fsSL https://get.docker.com | sh
docker --version          # Docker version 29.7.2
docker compose version    # Docker Compose version v5.5.0

Make sure docker compose works — with a space, as this is a plugin, not the old standalone docker-compose. The whole guide is built around it; the versions in the output above are what was on the stand, your numbers may differ.

Server A: database and panel

Create a directory and generate secrets — the database password and the api_key (shared secret between node and panel) are convenient to generate once:

mkdir -p /opt/pasarguard && cd /opt/pasarguard
DB_PASS=$(openssl rand -hex 24)
API_KEY=$(cat /proc/sys/kernel/random/uuid)   # must be a valid UUID
printf 'DB_PASS=%s\nAPI_KEY=%s\n' "$DB_PASS" "$API_KEY" > .secrets && chmod 600 .secrets
```View values at any time — `cat /opt/pasarguard/.secrets`. Later in the text they appear as placeholders, and they must be substituted manually:

- `DB_PASS` — in the panel's `.env` and in `docker-compose.yml` (in two places: database password and connection string);
- `API_KEY` — needed later, on server B and in the node card. Retrieve it from there: `grep API_KEY /opt/pasarguard/.secrets`.

To avoid getting confused, here are all the guide values in one place — where they are created and where they are substituted. Keep this table handy:

| Value | Where it is taken | Where it is substituted |
|---|---|---|
| `PANEL_IP` | public IP of server A | `A` DNS A record, ufw rule on the node |
| `NODE_IP` | public IP of server B | node card, host address, node SAN certificate |
| `DB_PASS` | `openssl rand -hex 24` on server A | `.env` of the panel and the database `docker-compose.yml` |
| `API_KEY` | UUID on server A (`.secrets`) | node's `docker-compose.yml` and the API Key field in the node card |
| domain | your own, on NS Cloudflare | `Caddyfile`, `VITE_BASE_API`, `ALLOWED_ORIGINS` |
| `CF_API_TOKEN` | Cloudflare → API Tokens | `docker-compose.yml` Caddy |
| `PRIVATE_KEY` / `PUBLIC_KEY` | `docker exec pg-node xray x25519` | private — in the inbound Core Config; public key will be substituted into the link by the panel |
| `shortId` | `openssl rand -hex 8` | inbound Core Config (`shortIds`) |

### .env format, which causes `--env-file` to fail

Official `.env.example` is written with “spaces around the equals sign and values in quotes”: `UVICORN_HOST = "0.0.0.0"`. The Python dotenv that the panel uses will accept that format. But Docker's variable parser does not; this is most clearly seen with `docker run --env-file`:

[error]

docker: invalid env file (/opt/pasarguard/.env): variable 'UVICORN_HOST ’ contains whitespaces


[/error]

Note the trailing space in `'UVICORN_HOST '` — the parser considers the space part of the name. With `env_file:` in compose, the same issue occurs, only the error text is a little different. It is safer to write `.env` without spaces, in the format `KEY=value`. Full panel `.env` with comments:

```ini
# ==== PasarGuard panel .env (clean KEY=value, no spaces) ====

# Listen only on localhost: the panel exposes Caddy to the outside.
UVICORN_HOST=127.0.0.1
UVICORN_PORT=8000

# Database connection. The asyncpg driver is included in the panel image.
SQLALCHEMY_DATABASE_URL=postgresql+asyncpg://pasarguard:DB_PASS@127.0.0.1:5432/pasarguard

# Node statistics — only on PostgreSQL/TimescaleDB. That’s why Timescale was chosen.
ENABLE_RECORDING_NODES_STATS=True
# Double negation: False = node traffic accounting is enabled.
DISABLE_RECORDING_NODE_USAGE=False

# Subscription path: links will look like https://domain/sub/<token>.
# The format is intentionally varied: here is a segment without slashes, below is a path with slashes.
SUBSCRIPTION_PATH=sub

# The dashboard path (through which the web interface opens).
DASHBOARD_PATH=/dashboard/

LOG_LEVEL=INFO

# ==== work behind reverse-proxy Caddy ====
# We will raise the domain next, but we set the keys right away — then the panel
# starts already ready to work behind the proxy, and it won’t need to restart.
UVICORN_PROXY_HEADERS=True                 # trust X-Forwarded-* headers from Caddy
UVICORN_FORWARDED_ALLOW_IPS=127.0.0.1      # Caddy sits on the same host
VITE_BASE_API=https://pg-test.gig.ovh/     # on which domain the frontend looks for API
ALLOWED_ORIGINS=https://pg-test.gig.ovh    # CORS

docker-compose.yml: images from ghcr, not from Hub

In the official compose image it is written as pasarguard/panel:latest — this is Docker Hub, which on pulls regularly returns 429 Too Many Requests. A working mirror is GitHub Container Registry: ghcr.io/pasarguard/panel and ghcr.io/pasarguard/node. I fix the tags to specific versions, and not latest — why, we will discuss in the update section. Full compose for server A:

services:
  timescaledb:
    image: timescale/timescaledb:latest-pg17
    container_name: pasarguard-db
    restart: always
    environment:
      POSTGRES_DB: pasarguard
      POSTGRES_USER: pasarguard
      POSTGRES_PASSWORD: DB_PASS
    volumes:
      - /var/lib/pasarguard-db/pgdata:/var/lib/postgresql/data   # separate from panel state
    ports:
      - "127.0.0.1:5432:5432"     # localhost only
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U pasarguard -d pasarguard"]
      interval: 5s
      timeout: 5s
      retries: 20

  pasarguard:
    image: ghcr.io/pasarguard/panel:v5.2.1     # version from the staging; about :latest — in the update section
    container_name: pasarguard
    restart: always
    env_file: .env
    network_mode: host
    volumes:
      - /var/lib/pasarguard:/var/lib/pasarguard
    depends_on:
      timescaledb:
        condition: service_healthy

Both files are placed in /opt/pasarguard: config — as .env, compose — as docker-compose.yml. Bring up with a single command: the order is controlled by depends_on with condition: service_healthy — compose will wait until the database becomes healthy, and only then start the panel.

cd /opt/pasarguard
docker compose up -d
docker logs -f pasarguard

Panel without TLS listens only on localhost

In the logs after a series of Alembic migrations you’ll see a warning that scares beginners, but it is expected:

Warning:
IMPORTANT!
You're running PasarGuard without specifying UVICORN_SSL_CERTFILE and UVICORN_SSL_KEYFILE.
The application will only be accessible through localhost...
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

This is not an error — it is exactly what we need. The panel listens on 127.0.0.1:8000, and outward it is exposed by Caddy, terminating TLS. That’s why .env contains UVICORN_HOST=127.0.0.1. Next to it will be Attention: You have no node — the node will be added later.

Caddy: TLS for the panel and access by domain

The panel works, but for now only on localhost. Put Caddy in front of it — it will obtain a Let’s Encrypt certificate and terminate TLS. Validation is done via DNS-01 (Cloudflare): no need for port 80 for ACME.

First subtlety: the usual image caddy:2-alpine is built without DNS modules, and on the dns cloudflare directive it won’t start — the config simply won’t load. So take the build ghcr.io/caddybuilds/caddy-cloudflare:latest, which already has dns.providers.cloudflare compiled in; use it in the compose below.

Second subtlety: the domain must be delegated to Cloudflare NS — DNS-01 works through its API. In the zone create an A-record pg-test.gig.ovh → PANEL_IP in DNS only mode (gray cloud), so TLS terminates on Caddy, not on Cloudflare side.

The token for Caddy is created there as well: My Profile → API Tokens → Create Token, template Edit zone DNS, in Zone Resources select your zone, click Create and copy the value — it will be shown once.

Caddyfile

Caddy is a separate stack, so give it its own directory; in it place two files, Caddyfile and docker-compose.yml:

mkdir -p /opt/caddy && cd /opt/caddy

Domain and Cloudflare token are enough. Point reverse-proxy to the panel and request TLS via DNS-01 — Caddy will go to Let’s Encrypt for a real, trusted certificate:

{
    email admin@gig.ovh
}

pg-test.gig.ovh {
    reverse_proxy 127.0.0.1:8000
    tls {
        dns cloudflare {env.CF_API_TOKEN}
        resolvers 1.1.1.1
    }
}

docker-compose.yml for Caddy (Cloudflare token via environment variable):

services:
  caddy:
    image: ghcr.io/caddybuilds/caddy-cloudflare:latest
    container_name: caddy
    restart: always
    network_mode: host
    environment:
      CF_API_TOKEN: "CLOUDFLARE_TOKEN"   # token with Zone:DNS rights for your zone
    volumes:
      - /opt/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - /var/lib/caddy/data:/data
      - /var/lib/caddy/config:/config

Bring up and watch the logs — the certificate is issued in a few seconds:

cd /opt/caddy && docker compose up -d
docker logs -f caddy
# http.acme_client  trying to solve challenge  challenge_type=dns-01
# http.acme_client  authorization finalized  authz_status=valid
# tls.obtain  certificate obtained successfully

While Caddy handles the challenge, a TXT record _acme-challenge.pg-test.gig.ovh briefly appears in Cloudflare and is removed after validation — that’s how DNS-01 works. See what certificate it issues (you can run the command from a home machine as well — then it’s a truly external check):

echo | openssl s_client -connect pg-test.gig.ovh:443 -servername pg-test.gig.ovh 2>/dev/null \
  | openssl x509 -noout -issuer -subject -dates
# issuer=C=US, O=Let's Encrypt, CN=YE2
# subject=CN=pg-test.gig.ovh
# notBefore=... notAfter=... (90 days)

Keys for operating behind the proxy (UVICORN_PROXY_HEADERS, VITE_BASE_API and others) we already set in .env at the start, so you don’t need to restart the panel — the domain immediately serves the dashboard:

curl -s -o /dev/null -w "%{http_code}\n" https://pg-test.gig.ovh/dashboard/   # 200

All set: the panel is available at https://pg-test.gig.ovh with a valid certificate. From here on, work directly in the browser.

Meanwhile, close server A as well. The panel and the database listen only on localhost (UVICORN_HOST=127.0.0.1 and binding 127.0.0.1:5432), so they are not exposed to the outside, but a firewall is a good second line of defense:

ufw allow 22/tcp
ufw allow 80,443/tcp
ufw --force enable

Let’s Encrypt has a limit on failed issuances — five per domain per hour. If you’re blocked, don’t restart Caddy in a loop: the reason is visible in docker logs caddy (most often an incorrect Cloudflare token or a DNS entry that didn’t propagate), and try again after an hour.

Creating the owner: SUDO_USERNAME no longer works

First instinct — put in .env the pair SUDO_USERNAME/SUDO_PASSWORD and log in. In 5.2.1 this yields a login rejection:

Error:
env admin not allowed in production

Static admin from environment variables is intentionally forbidden in production. The owner is created with a one-time temporary key via the CLI. And here’s a second snag: the pasarguard binary is not in PATH, cli_wrapper.sh is not executable. The working invocation — only directly through Python inside the container, working directory /code:

The key lives for 5 minutes and is one-time, so the procedure is: first open the form, then generate.

The panel is already available on the domain — go to https://pg-test.gig.ovh/dashboard/, on the login page click Owner access and switch to Create. The form will ask for a temp key, owner name, and password. Now generate the key and paste it into the open form:

docker exec pasarguard python /code/pasarguard-cli.py generate-temp-key
# Temp key: 3e2a706a-....
# This key is valid for 5 minutes and can only be used once.

Not completed — no problem: generate a new one, using the same key again won’t work.

After logging in, a dashboard opens — from here on, almost everything is done with the mouse.

Panel Overview: what everything does

Before configuring, it’s worth understanding the interface map. The left menu is PasarGuard itself. Let’s go through each item from top to bottom.

Dashboard — the starting screen with a summary: how many users are online, how many are active, total traffic, node status. Nothing is configured, just monitoring at a glance.

Users — the main workspace. Here clients are created and live: each has their own traffic limit, expiration date, status (active/disabled/limited/expired), grouping, and, via the action buttons in the row, a subscription link and QR. Access is issued and withdrawn from here. 90% of an admin’s daily work happens here.

Statistics — traffic charts: by user and by node. That same section that only works on PostgreSQL/TimescaleDB — it’s empty on SQLite. It shows who and how much has been boosted and how the load is distributed among nodes.

This is how this section looks on a working test setup. Yours is currently empty — it will fill once we connect a node and create the first user.

Hosts — the “showcase” of connections. An inbound on a node (for example, VLESS Reality) does not by itself turn into a client link: you need a host that says “this inbound is available at this address and port, with these SNI/fingerprint.” One inbound can provide several hosts (different addresses, wrapper domains, priorities). A user’s subscription is collected from the hosts.

Groups — the linking element between inbound connections and users. A group is a set of inbounds (by tags). A user belongs to one or more groups and receives configurations for all inbounds from them. It’s convenient to distribute a “tariff”: a group “full” with all protocols, a group “trial” with one.

Admins and Admin Roles — multi-user administration. You can create separate admins (e.g., resellers) and restrict by role what they can see and do: their own traffic limits, bans on other users, rights to nodes, and so on. The owner (owner) is the super admin over all of this.

API Keys — keys for programmatic access to the same operations as in the UI: automating user provisioning, integrating with billing, bots. Each key can inherit role rights.

Nodes — node and core management. Inside four tabs:

  • Nodes — list of nodes, their status (green/red), node and core versions, add/edit nodes.
  • Core Configs — Xray core configurations: this is where inbounds (VLESS, VMess, Trojan, Shadowsocks, Reality, etc.), outgoing, routing are defined. The config applies to the assigned nodes.
  • WireGuard — management of WireGuard subnets and peers if you use WG alongside Xray.
  • Logs — live core logs from the node in the panel, handy for debugging inbounds.

Templates — templates: subscription pages, default user settings sets, config templates. Help avoid filling the same things by hand when issuing many users.

Bulk — bulk operations on users: extend expiration, reset traffic, enable/disable a batch of accounts at once.

Settings — general panel settings: subscriptions, webhooks/notifications, styling, service parameters. Part of what used to live in .env is now here.

Keeping this map in mind, we’ll proceed in a logical order: first connect a node (Nodes), then configure the protocol on it (Core Configs), expose the connection externally (Hosts), group into a tariff (Groups) and issue to the client (Users).

Server B: the certificate without which the node won’t start

The node communicates with the panel via gRPC, the channel is TLS-encrypted, and a certificate is needed before the first start — otherwise the container will stay in a perpetual restart:

Error:
open /var/lib/pg-node/certs/ssl_cert.pem: no such file or directory

First the certificate, then the container. And here is the trickiest installation error. If you generate a self-signed certificate “as usual” without a Subject Alternative Name, the panel will reject the node when connecting:

Error:
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
IP address mismatch, certificate is not valid for 'NODE_IP'

The panel checks that the certificate is issued for the node IP and looks at the SAN with the IP address, not CN. The correct command adds subjectAltName=IP:...:

mkdir -p /var/lib/pg-node/certs && cd /var/lib/pg-node/certs
openssl req -x509 -newkey rsa:2048 -keyout ssl_key.pem -out ssl_cert.pem -days 3650 -nodes \
  -subj "/CN=NODE_IP" -addext "subjectAltName=IP:NODE_IP"

openssl x509 -in ssl_cert.pem -noout -text | grep -A1 "Subject Alternative Name"
# X509v3 Subject Alternative Name:
#     IP Address:NODE_IP

docker-compose.yml for the node

The image — again from ghcr. API_KEY — the UUID we generated on the panel: this is the node-panel shared secret, it will also go into the node card. You can get it on the server A with the command grep API_KEY /opt/pasarguard/.secrets — substitute the value for the placeholder. The node runs on the host network and requires NET_ADMIN.

Create the directory and place docker-compose.yml in it:

mkdir -p /opt/pg-node && cd /opt/pg-node
services:
  node:
    image: ghcr.io/pasarguard/node:v0.5.3      # version from the demo
    container_name: pg-node
    restart: always
    network_mode: host
    cap_add:
      - NET_ADMIN
    environment:
      SERVICE_PORT: 62050
      SERVICE_PROTOCOL: "grpc"
      SSL_CERT_FILE: "/var/lib/pg-node/certs/ssl_cert.pem"
      SSL_KEY_FILE: "/var/lib/pg-node/certs/ssl_key.pem"
      API_KEY: "API_KEY"          # valid UUID, shared with the panel
      GENERATED_CONFIG_PATH: "/var/lib/pg-node/generated"
    volumes:
      - /var/lib/pg-node:/var/lib/pg-node

Start and check logs:

docker compose up -d
docker logs pg-node
# Starting Node: v0.5.3
# gRPC Server listening on 0.0.0.0:62050

The line Failed to load env file ... open .env at the very top can be ignored: variables are passed via environment:, the .env file is not needed by the node.

Firewall: 62050 — only for the panel

Control port should not be exposed to the world. Open 62050 only from the panel IP. Allow SSH up to the point of enabling ufw, otherwise you may cut off access:

ufw allow 22/tcp
ufw allow from PANEL_IP to any port 62050 proto tcp   # management channel — only from the panel
ufw allow 443/tcp                                     # client inbound VLESS Reality, configure next
ufw --force enable
ufw status numbered
[ 1] 22/tcp        ALLOW IN    Anywhere
[ 2] 62050/tcp     ALLOW IN    PANEL_IP
[ 3] 443/tcp       ALLOW IN    Anywhere

The default ufw policy is — deny incoming, so a separate deny rule for 62050 isn’t needed: anything that isn’t explicitly allowed is dropped even at SYN. The panel passes through its own IP, the others don’t.

Security:

Check from an external machine: nc -zv NODE_IP 62050 should time out, and from the panel it should respond with succeeded. The management channel — is full access to the node’s kernel, it must not be exposed to the open internet.

Adding a node to the panel (Nodes tab)

Now in the panel go to Nodes → Nodes and click Create Node. The form is simple, but one field — with a twist. Here the last error of the linkage pops up, and it is visible only in the panel logs: in the interface, with an invalid certificate, a red dot lights up without explanations.

Form fields:

Field Value Explanation
Node Name test-node-1 arbitrary name
Node Address NODE_IP public IP of server B
Node Port 62050 node gRPC port
Core Configuration Default Core Config core config; the panel creates it itself on first run, it already exists in the list
API Key API_KEY the same UUID as in the node variables
Certificate the contents of ssl_cert.pem from the node entirely, including BEGIN/END

In the Certificate field (in API this is server_ca) you paste the certificate from the node in full. If you paste the wrong certificate — for example, generate another on the panel — the connection will drop, and only in docker logs pasarguard will you see the reason:

Error:
certificate verify failed: self-signed certificate

Fetch the certificate from server B: cat /var/lib/pg-node/certs/ssl_cert.pem. A successful connection in the panel logs looks like this:

Node-operation - New node "test-node-1" with id "1" added by admin "admin"
Node-operation - Connecting to "test-node-1" node
Node-operation - Connected to "test-node-1" node v0.5.3, core run on v26.3.27

And in the interface on the Nodes tab the status turns green, node versions (0.5.3) and core (26.3.27) appear:

If the status is red — don’t guess from the interface, immediately check docker logs pasarguard.

VLESS + Reality: a protocol that looks like an unrelated site

The default Shadowsocks is fine for “checking if anything at all works,” but in 2026 that is not a choice for a production proxy. The current Xray standard is VLESS + Reality. Its idea: the node does not host its own TLS with its own certificate (which would reveal the proxy’s presence), and at the handshake stage it pretends to be another site — it borrows TLS from a real external host (for example, www.microsoft.com). To any port scanner, 443 on the node is indistinguishable from the real Microsoft: the same certificate, the same chain. No own domain or client certificate is needed at all.

Step 1. Generate Reality keys

Reality uses a pair of x25519 keys and a short identifier shortId. The keys are generated by the Xray core itself inside the node container:

docker exec pg-node xray x25519
# PrivateKey: <PRIVATE_KEY>
# Password (PublicKey): <PUBLIC_KEY>
# Hash32: ...

openssl rand -hex 8    # shortId, например 86d173896823210b

PrivateKey пойдёт в конфиг инбаунда на ноде, PublicKey (pbk) — в клиентскую ссылку (панель подставит его сама). Сохраните обе половины.

Step 2. Add inbound to Core Config

Go to Nodes → Core Configs and open Default Core Config. In the editor switch to JSON mode with the button {} and add to the inbounds array the block below, replacing <PRIVATE_KEY> and shortIds with your values from the previous step. The default Shadowsocks inbound can be left as is (we won’t include it in the group, so clients won’t get it) or removed — on Reality it does not matter.

{
  "tag": "VLESS Reality",
  "listen": "0.0.0.0",
  "port": 443,
  "protocol": "vless",
  "settings": { "clients": [], "decryption": "none" },
  "streamSettings": {
    "network": "tcp",
    "security": "reality",
    "realitySettings": {
      "show": false,
      "dest": "www.microsoft.com:443",
      "xver": 0,
      "serverNames": ["www.microsoft.com"],
      "privateKey": "<PRIVATE_KEY>",
      "shortIds": ["86d173896823210b"]
    }
  },
  "sniffing": { "enabled": true, "destOverride": ["http", "tls", "quic"] }
}

At the bottom of the editor leave the checkbox Restart Nodes and save — the panel will push the config to the node and restart the core. In the Inbounds tab our VLESS Reality / vless / 443 appears:

Choose the “victim” (dest/serverNames) wisely: it should be a real working third-party site on 443 with TLS 1.3, not related to you and not blocked where your clients sit. Classic examples are large CDNs and sites like www.microsoft.com, www.cloudflare.com. And don’t forget to open port 443 on the node for the inbound: ufw allow 443/tcp.

Step 3. Create a host (Hosts tab)

Inbound exists, but the client doesn’t know about it yet — a host is needed. Go to Hosts → Add Host: select inbound VLESS Reality, in the Remark field set the name under which the config will be seen by the client (in the example — VLESS-Reality-node1), address — public IP of the node, port 443, in Security Settings — SNI www.microsoft.com and fingerprint chrome.

Important:

The Security field in the host accepts only Inbound Default, none or tls — values reality are not there. For Reality choose Inbound Default: the host will inherit reality settings from the inbound, and the public key (pbk) and shortId (sid) will be substituted into the link by the panel automatically. Trying to set security: reality manually is a direct path to 422.

Step 4. Group (Groups tab)

To have the inbound assigned to a user, it must be part of a group. In Groups create a group test-group and include the inbound VLESS Reality in it.

User and their connection (Users tab)

Now grant access. Users → Create User: set a name, optionally traffic limits and duration, and most importantly — choose the group test-group.

The user appears in the list with status Active; in their row there are icons for “copy subscription”, “link” and “QR”.

Click the QR icon — the user’s subscription page opens: a QR code and a list of their configurations (VLESS-Reality-node1 with the node address). This is exactly the screen you give to the client: they scan the QR or copy the link into v2rayNG / v2rayN / Hiddify.

The subscription link provides the client with a ready vless:// config containing all Reality parameters:

vless://<UUID>@NODE_IP:443?encryption=none&security=reality&type=tcp&headerType=none&sni=www.microsoft.com&fp=chrome&pbk=<PUBLIC_KEY>&sid=86d173896823210b#VLESS-Reality-node1

What’s here: the <UUID> is issued by the panel to the user, pbk is the public key from step 1, sid is the same shortId, sni is the victim domain, fp is the fake TLS fingerprint of the browser, and after # is the name from the host Remark field.

Verifying that Reality actually works

It’s not enough to generate a link — we need to confirm the camouflage is live. A TLS probe on the node’s port with the victim’s name should return the REAL Microsoft certificate — this is Reality camouflage: to an outside observer port 443 on your node looks like the Microsoft server.

echo | openssl s_client -connect NODE_IP:443 -servername www.microsoft.com 2>/dev/null \
  | openssl x509 -noout -issuer -subject
# issuer=C=US, O=Microsoft Corporation, CN=Microsoft TLS G2 RSA CA OCSP 04
# subject=C=US, ST=WA, L=Redmond, O=Microsoft Corporation, CN=www.microsoft.com

We should see the Microsoft chain — that means Reality is up and correctly “borrows” another TLS. Import the vless:// into the client and verify real network reachability.

Check after restart

Final stability test: the node should reconnect automatically after restarting the entire stack.

# on the node
cd /opt/pg-node && docker compose restart
# on the panel
cd /opt/pasarguard && docker compose restart
cd /opt/caddy && docker compose restart

Within 30–60 seconds the node status should again be connected, in the panel logs you’ll see a fresh pair Connecting to ... / Connected to "test-node-1" node v0.5.3. No manual actions are required.

If it didn’t work: symptom → where to look

The guide keeps repeating about docker logs — the PasarGuard UI explains almost nothing. Here is a quick reference in case something went wrong.

Symptom Where to look Typical cause
Domain doesn’t open, certificate not issued docker logs caddy incorrect Cloudflare token, A-record did not reach DNS, domain not on Cloudflare NS
Domain opens but dashboard is empty or 502 docker logs pasarguard panel did not start (most often — database password in .env), or wrong VITE_BASE_API
Panel won’t start, database errors docker logs pasarguard-db DB_PASS not set, password mismatch between .env and the database compose
Node in perpetual restart docker logs pg-node certificate files missing in /var/lib/pg-node/certs
Red dot on node in panel docker logs pasarguard certificate without SAN on IP, wrong certificate in the Certificate field, api_key mismatch, 62050 locked
Subscription link imports but no traffic docker logs pg-node + Statistics 443 closed on node, pbk/sid/sni mismatch between inbound and client

Backup and update

What to back up. The panel state is the database and /var/lib/pasarguard:

# dump the database
docker exec pasarguard-db pg_dump -U pasarguard pasarguard | gzip > pg_$(date +%F).sql.gz

# configs and panel state (.secrets — the only place where DB_PASS and API_KEY live)
tar czf pasarguard_state_$(date +%F).tar.gz \
  /opt/pasarguard/.env /opt/pasarguard/.secrets /opt/pasarguard/docker-compose.yml \
  /opt/caddy /var/lib/pasarguard

PostgreSQL data directory (/var/lib/pasarguard-db) should not be archived: live database files cannot be reliably backed up by copying — that’s what the pg_dump is for.

On the node save /opt/pg-node/docker-compose.yml and a pair of ssl_cert.pem / ssl_key.pem:

tar czf pgnode_state_$(date +%F).tar.gz /opt/pg-node/docker-compose.yml /var/lib/pg-node/certs

Remember that the node certificate is stored in two places — the file on the node and its copy in the Certificate field of the card; when replacing (for example, when moving the node to a new IP — a new SAN is needed) update BOTH, otherwise the panel will fail with a self-signed certificate.

How to update. We fix tags by version, so updating is a deliberate edit of one line in the compose: change the tag to a new one, review the changelog, and restart. Before updating the panel, always take a database dump: alembic migrations are applied automatically and cannot be rolled back.```bash

in /opt/pasarguard/docker-compose.yml, change the panel tag to the required one, then:

cd /opt/pasarguard && docker compose pull pasarguard && docker compose up -d pasarguard

same on the node

cd /opt/pg-node && docker compose pull node && docker compose up -d


[warn]

The temptation to use `latest` is great, but then you’re not the one choosing the moment of a major update with breaking changes—you’re choosing to restart the container. The fact that `SUDO_USERNAME` stopped working between versions is exactly such a case: you pulled latest and lost access to the panel. Therefore the tags in compose are fixed.

[/warn]

## Conclusion

The assembled stand — panel `v5.2.1` on its own domain with a live Let's Encrypt certificate, TimescaleDB for statistics, Caddy with DNS-01, and a node `v0.5.3` on a separate server with Xray core `26.3.27`, serving VLESS + Reality disguised as `www.microsoft.com`. Eight pain points where the installation stalls — Hub images, `.env` format, localhost bind without TLS, Caddy without Cloudflare module, restrict env-admin, hidden CLI, node certificate (no file and no SAN) and the wrong certificate in the card — are addressed and documented. The main practical takeaway: almost everything in PasarGuard is diagnosed not in the UI, but via `docker logs` — keep the panel log open.

## Sources

- PasarGuard Panel — repository and `.env.example`: [github.com/PasarGuard/panel](https://github.com/PasarGuard/panel)
- PasarGuard Node — repository and `docker-compose.yml`: [github.com/PasarGuard/node](https://github.com/PasarGuard/node)
- Official PasarGuard documentation: [docs.pasarguard.org](https://docs.pasarguard.org)
- Xray-core (VLESS, Reality): [github.com/XTLS/Xray-core](https://github.com/XTLS/Xray-core)
- Caddy — TLS automation and Cloudflare DNS module: [caddyserver.com/docs](https://caddyserver.com/docs/automatic-https)
- Caddy image with Cloudflare: [github.com/caddybuilds/caddy-cloudflare](https://github.com/caddybuilds/caddy-cloudflare)
- Cloudflare API — DNS records: [developers.cloudflare.com/api](https://developers.cloudflare.com/api/)

[question]

Which “sacrifice” for Reality do you use in production — a large CDN, a neutral foreign site, or a domain that you control yourself? And how do you close the management port of the node: bare `ufw` with IP whitelisting for the panel, private network/WireGuard between panel and nodes, or a tunnel? I’m interested in collecting different approaches.

[/question]