# Meridian documentation > Deploy censorship-resistant proxy servers with one command, hardened by default. > Source: https://getmeridian.org ## Contents 1. [Getting Started](https://getmeridian.org/en/getting-started.md) 2. [Installation](https://getmeridian.org/en/installation.md) 3. [Windows and WSL Setup](https://getmeridian.org/en/wsl.md) 4. [Choosing hosting](https://getmeridian.org/en/hosting.md) 5. [Deploy Guide](https://getmeridian.org/en/deploy.md) 6. [Domain Mode](https://getmeridian.org/en/domain-mode.md) 7. [Client Management](https://getmeridian.org/en/client-management.md) 8. [Connecting](https://getmeridian.org/en/connecting.md) 9. [Relay Nodes](https://getmeridian.org/en/relay.md) 10. [Split Routing (Russia)](https://getmeridian.org/en/split-routing.md) 11. [Use Meridian on a router](https://getmeridian.org/en/routers.md) 12. [IP Blocked Recovery](https://getmeridian.org/en/recovery.md) 13. [Troubleshooting](https://getmeridian.org/en/troubleshooting.md) 14. [Architecture](https://getmeridian.org/en/architecture.md) 15. [CLI Reference](https://getmeridian.org/en/cli-reference.md) 16. [AI Agents](https://getmeridian.org/en/agents.md) 17. [Security](https://getmeridian.org/en/security.md) 18. [Contributing](https://getmeridian.org/en/contributing.md) --- # Getting Started > Install Meridian and deploy your first proxy server. One command turns a fresh VPS into a hardened proxy and gives you a link to share. This guide takes you from zero to a working connection in a few minutes. Someone sent you a link? You don't need any of this: see [Connecting](https://getmeridian.org/en/connecting.md). ## Prerequisites You need: - A **VPS** running Debian or Ubuntu that you can reach over SSH with a key, as root or a user with passwordless sudo - A **terminal** on your own machine (macOS, Linux, or [WSL on Windows](https://getmeridian.org/en/wsl.md)) ## Install the CLI ``` curl -sSf https://getmeridian.org/install.sh | bash ``` This installs the `meridian` command with [uv](https://docs.astral.sh/uv/) (or pipx as a fallback). See [Installation](https://getmeridian.org/en/installation.md) for other options. Run Meridian on your own machine rather than on the VPS: it connects over SSH, keeps credentials on your device, and lets `meridian test` check the server from the outside. > Coming from Meridian 3.x? Meridian 4 is not an in-place upgrade. Read [Upgrading from 3.x](https://getmeridian.org/en/installation.md#upgrading-from-3x) first. ## Deploy ``` meridian deploy 198.51.100.10 ``` Run `meridian deploy` without an IP and it asks for one. If you are logged into the VPS itself as root, `meridian deploy local` skips SSH. ## What happens 1. **Hardens the server** — SSH key-only login, fail2ban, a UFW firewall that opens only what the deployment needs, BBR congestion control 2. **Installs the control plane** — the [Remnawave](https://remna.st/) panel, plus a node running Xray, all in Docker 3. **Picks a camouflage target** — it looks for a suitable TLS 1.3 site on the server's own network, then falls back to `www.google.com`, `dl.google.com`, and `www.cloudflare.com` (pass `--sni` to choose your own) 4. **Configures the connection methods** — VLESS+Reality and XHTTP on TCP 443 behind nginx, with a Let's Encrypt certificate for the server's IP, and Hysteria2 on UDP 443 5. **Verifies real traffic** — Meridian pushes traffic through the delivered subscription before saying "Done", and moves to the next camouflage target if traffic doesn't pass 6. **Publishes a connection page** for the first client The deploy is checkpointed. If it is interrupted, run the same command again and it finishes where it stopped. ## Connect When the deploy finishes, it prints: - **A connection page link** with a QR code — send this to the person who will connect. The page has app download links, QR codes, and one-tap "Open in app" buttons. - **A subscription URL** — paste it into any Xray/V2Ray app. Apps refresh it, so new servers and relays show up automatically. Recommended apps: | App | Platforms | |-----|-----------| | [Happ](https://happ.su/) | iOS, Android, Windows, macOS, Linux | | [FlClashX](https://github.com/pluralplay/FlClashX/releases/latest) | Android, Windows, macOS, Linux | | [Clash Mi](https://clashmi.app/download) | iOS, Android, Windows, macOS, Linux | | [Throne](https://github.com/throneproj/Throne/releases/latest) | Windows, macOS, Linux | The connection page lists these first and links more apps for each platform. Check the server from your machine at any time: ``` meridian test 198.51.100.10 ``` ## Add more people ``` meridian client add alice ``` Each person gets their own key, connection page, and subscription URL. `meridian client list` shows everyone, and `meridian client disable alice` cuts someone off immediately. See [Client management](https://getmeridian.org/en/client-management.md). ## Where things live | What | Where | |------|-------| | Topology, panel access, and keys | `~/.config/meridian/cluster.yml` on your machine | | Saved SSH servers | `~/.config/meridian/servers.json` on your machine | | Proxy services | Docker, Xray, nginx on the VPS | Client commands talk to the Remnawave panel API recorded in `cluster.yml`; they don't need SSH. Back up `~/.config/meridian/` — it is how Meridian manages your servers. ## More than one server `meridian deploy` covers a single server. For several exits, relays through a domestic server, or routing gateways, use the guided wizard: ``` meridian setup ``` You can also grow a deployment one step at a time with `meridian node add` and `meridian relay add`. Every change goes through the same reviewed topology; `meridian plan` shows what would change and `meridian apply` applies it. ## Next steps - [Deploy guide](https://getmeridian.org/en/deploy.md) — every deploy option, camouflage targets, redeploys - [Domain mode](https://getmeridian.org/en/domain-mode.md) — add a Cloudflare CDN fallback with your own domain - [Relay nodes](https://getmeridian.org/en/relay.md) — give users a domestic entry point - [IP blocked?](https://getmeridian.org/en/recovery.md) — what to do when the server gets blocked - [Troubleshooting](https://getmeridian.org/en/troubleshooting.md) — common issues and fixes --- # Installation > Install the Meridian CLI on your local machine. ## Quick install ``` curl -sSf https://getmeridian.org/install.sh | bash ``` This script: 1. Installs [uv](https://docs.astral.sh/uv/) if it is not present, with pipx or pip as fallbacks. The uv installer is pinned by version and checked against its SHA-256 before it runs. 2. Installs `meridian-vpn` from PyPI (set `MERIDIAN_VERSION=4.0.0` to install one exact release) 3. Adds the tool directory to your shell PATH when needed 4. Creates `/usr/local/bin/meridian` only when passwordless sudo is available ## Verified install Every [GitHub release](https://github.com/getmeridian/meridian/releases) attaches `install.sh` and its `SHA256SUMS`. To check the script before running it, download both from the release and verify: ``` VERSION=4.0.0 curl -fLO https://github.com/getmeridian/meridian/releases/download/v$VERSION/install.sh curl -fLO https://github.com/getmeridian/meridian/releases/download/v$VERSION/SHA256SUMS sha256sum -c SHA256SUMS # macOS: shasum -a 256 -c SHA256SUMS less install.sh # optional: read it MERIDIAN_VERSION=$VERSION bash install.sh ``` To check the copy at getmeridian.org instead, compare `curl -sSf https://getmeridian.org/install.sh | sha256sum` with the release's `SHA256SUMS`; they match unless the script changed after that release. When the newest release is a beta, the website's copy installs that beta by default, and that one line makes its checksum differ from the release asset. ## Manual install With uv (recommended): ``` uv tool install meridian-vpn ``` With pipx: ``` pipx install meridian-vpn ``` ## Update ``` meridian update ``` Meridian checks PyPI now and then before interactive commands and prints a notice when a newer version exists. It never installs updates on its own. `meridian update` upgrades through whichever package manager installed it and confirms that the active `meridian` command reports the new version. Set `MERIDIAN_DISABLE_UPDATE_CHECK=1` to turn off the background check in automation. Updating the CLI changes nothing on your servers. If a server works, there is no rush; read the [release notes](https://github.com/getmeridian/meridian/releases), then run the same `meridian deploy` command (or `meridian apply`) when you are ready to apply the new version. ## Upgrading from 3.x Meridian 4 is **not an in-place upgrade**. It replaces the 3x-ui panel with Remnawave, and links from a 3.x server cannot carry over: every user needs a new link. Your 3.x servers keep working. You have two choices: - **Keep managing them with 3.x.** Reinstall the 3.x CLI: ``` uv tool install 'meridian-vpn<4' ``` (or `pipx install 'meridian-vpn<4'`). Meridian 3.17.3 and later stay on 3.x when you run `meridian update`, unless you pass `--major`. - **Move to Meridian 4.** 1. Deploy a fresh server with Meridian 4: `meridian deploy NEW_IP` 2. Give your users their new connection pages (`meridian client add NAME` for each person) 3. Once everyone has switched, tear down the old server with `meridian teardown OLD_IP` — Meridian 4's teardown also removes 3x-ui Meridian 4 keeps its own state in `~/.config/meridian/` and never reads or changes 3.x state in `~/.meridian/`, so both versions can live on one machine; fleet commands that find only 3.x state explain these options instead. These docs describe Meridian 4. The [Meridian 3.17.3 docs](https://github.com/getmeridian/meridian/tree/v3.17.3/website/src/content/docs/en) stay on GitHub for servers you keep on 3.x. ## Requirements - **Python 3.11+** (uv installs and manages it for you) - **SSH access** to your server, as root or a user with passwordless sudo. Key login is best; if the server accepts only a password, `meridian deploy` and `meridian setup` offer to create a key for Meridian and install it with that password once (see [Password-only servers](https://getmeridian.org/en/deploy.md#password-only-servers)) Terminal QR codes are rendered by the bundled `segno` package; no system dependency is required. ## Verify installation ``` meridian --version ``` --- # Windows and WSL Setup > Set up Meridian on Windows using Windows Subsystem for Linux. Meridian runs from a Unix-like terminal. On Windows, use Windows Subsystem for Linux (WSL) and run Meridian inside the Linux environment. Native PowerShell, Command Prompt, Git Bash, and Cygwin are not the recommended path for Meridian. WSL gives you the same shell, SSH, and filesystem behavior as Linux, which matches the rest of the documentation. ## Install WSL Open PowerShell as Administrator and install WSL: ```powershell wsl --install ``` Restart if prompted, then open the installed Linux distribution from the Start menu. Ubuntu is the safest default because Meridian targets Debian and Ubuntu servers, and most examples use `apt` package names. If you already have Debian installed in WSL, that works too. Check that WSL is using version 2: ```powershell wsl --list --verbose ``` If your distribution shows version 1, convert it: ```powershell wsl --set-version Ubuntu 2 ``` ## Prepare the WSL shell Inside the WSL terminal, update packages and install basic tools: ```bash sudo apt update sudo apt install -y curl openssh-client ca-certificates ``` Install Meridian: ```bash curl -sSf https://getmeridian.org/install.sh | bash ``` Restart the WSL terminal or reload your shell profile if `meridian` is not found immediately: ```bash exec "$SHELL" -l ``` Verify the install: ```bash meridian --version ``` ## Set up SSH keys Meridian connects from WSL to your VPS over SSH. Generate the key inside WSL: ```bash ssh-keygen -t ed25519 -C "meridian-wsl" ``` Copy the public key: ```bash cat ~/.ssh/id_ed25519.pub ``` Add that public key to your VPS provider or to the server user's `~/.ssh/authorized_keys`. Test SSH before running Meridian: ```bash ssh root@198.51.100.10 ``` If your VPS uses a non-root user, test that user instead and pass it to deploy: ```bash meridian deploy 198.51.100.10 --user ubuntu ``` ## Use an SSH agent If your key has a passphrase, start an agent inside WSL: ```bash eval "$(ssh-agent -s)" ssh-add ~/.ssh/id_ed25519 ``` Add those commands to `~/.profile` or `~/.bashrc` only if you understand the security tradeoff. Starting the agent manually is fine for most first deployments. Avoid mixing Windows and WSL SSH agents while getting started. Keep the key and agent inside WSL until deployment works. ## Run Meridian from WSL Run the normal deployment flow from the WSL terminal: ```bash meridian deploy ``` Or provide the server IP directly: ```bash meridian deploy 198.51.100.10 ``` Meridian stores credentials under `~/.config/meridian/` inside WSL. Run follow-up commands from the same WSL distribution so it can find the cached server credentials: ```bash meridian client add alice meridian client list meridian test 198.51.100.10 ``` ## VS Code Remote WSL If you use VS Code, install the "WSL" extension and open the Meridian workspace from WSL: ```bash code . ``` Files edited through Remote WSL live in the Linux filesystem, which avoids path and permission issues. Prefer a path under your WSL home directory, such as `~/projects`, instead of editing Meridian state or SSH keys through `/mnt/c/`. ## Common gotchas ### The wizard appears to skip a prompt Some interactive terminal prompts can behave differently under WSL. If the deploy wizard does not accept input as expected, provide values explicitly: ```bash meridian deploy 198.51.100.10 --sni www.cloudflare.com ``` You can also check the server first: ```bash meridian check 198.51.100.10 ``` ### Permission denied over SSH Confirm the key exists in WSL, not only in Windows: ```bash ls -la ~/.ssh ssh -v root@198.51.100.10 ``` The private key should be readable only by your WSL user: ```bash chmod 700 ~/.ssh chmod 600 ~/.ssh/id_ed25519 ``` ### Meridian was installed but the command is missing Reload your shell: ```bash exec "$SHELL" -l ``` If it still is not found, check common uv and local binary paths: ```bash ls ~/.local/bin ``` Add `~/.local/bin` to your shell profile if needed: ```bash echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc exec "$SHELL" -l ``` ### Windows paths cause permission problems Keep SSH keys, Meridian credentials, and project files in the WSL filesystem: ```bash cd ~ mkdir -p projects ``` Avoid storing `~/.ssh` or `~/.config/meridian` under `/mnt/c/`. Windows-mounted paths can have different permissions, which SSH rejects. ## Next steps - [Getting Started](https://getmeridian.org/en/getting-started.md) — deploy your first server - [Installation](https://getmeridian.org/en/installation.md) — CLI install options - [Troubleshooting](https://getmeridian.org/en/troubleshooting.md) — common deployment and connection issues --- # Choosing hosting > How to pick and test a VPS for an exit or a relay before you commit to it. Meridian configures the server correctly, but it can't change where the server lives. A perfect Reality setup on an address that your users' network blocks still doesn't connect. This page helps you pick and test hosting before you spend much on it. You rent two kinds of servers, and they have different needs: - **Exit**: carries all traffic to the open internet. It should be outside the censored country. - **Relay**: a small TCP forwarder in front of an exit, see [Relay nodes](https://getmeridian.org/en/relay.md). It needs a clean address that users can reach, and little else. ## What to look at **Address and network reputation.** Censors can block or throttle a single IP, a subnet, or everything from one hosting network. Large, well-known hosting networks are the most likely to be blocked wholesale, and deployers in Russia report that blocks sometimes take down neighboring customers who never ran a proxy. A smaller host in a less obvious location often lasts longer, but a host that everyone recommends stops being obscure. You can't know ahead of time, so test (below). Mobile networks are stricter than home connections: the same IP can work on a home line and fail on every mobile operator. **Location and latency.** Pick a country with good routing to your users, close enough for low latency. Measure from the users' own network, not from your laptop abroad. Use `meridian check` before you deploy to see how the server reaches its camouflage target. **Bandwidth and fair use.** "Unlimited" usually comes with a fair-use clause, often at several terabytes a month, and some hosts shape cheap or free plans to a low speed. Read the terms, and check the port speed you'll really get. A relay carries as much traffic as the users send through it, so size it the same way. **IPv4.** Plan on a public IPv4 address, and expect to pay extra for it on some hosts. Some tools, such as `meridian scan`, work on IPv4 only. **Payment and account.** Choose a provider whose payment methods and signup requirements you are comfortable with, and keep the account details separate from your personal accounts if privacy matters. Hourly billing is worth more than a small discount, because it lets you test cheaply. **Clean OS and root access.** Meridian expects a fresh Debian or Ubuntu server with SSH key login. Avoid hosts that preinstall control panels or firewalls you don't control. ## Test the IP before you pay a lot 1. Rent the smallest plan with **hourly billing**, or one with a refund window. 2. Run `meridian check IP` for the pre-flight checks: camouflage target, port 443, outside reachability, OS, disk, and clock. 3. Deploy, then run `meridian test` and `meridian test --probe` **from inside the network your users are on**, on both a home connection and a mobile one. `--probe` also reports whether the server IP or the camouflage SNI looks blocked. Results from outside the censored network prove very little. 4. Leave it running for a few days and test again. Some addresses work at first and get throttled later. 5. If it fails, delete the server and rent another address, then test again. Hourly billing makes this cheap. When a server stops working after it was fine, see [IP blocked recovery](https://getmeridian.org/en/recovery.md). ## Where not to put an exit An exit inside Russia is fragile. The host operates under local rules, can see and act on your server, and reports from Russian hosts describe identity verification and takedowns of proxy servers. Keep exits abroad. Use a domestic server only as a relay, which forwards encrypted bytes and runs no proxy software. ## Domestic relays carry risk A relay on a domestic host helps when foreign IPs are blocked, but the person who rents it carries the risk. It is rented in their name, under local law, and the host can ask for identity checks or end the service. The relay only forwards TCP that is encrypted end to end between the client and the exit, so the host sees connections to your exit's address and their volume, not the content. Abuse complaints about what users do online go to the exit's provider. This isn't legal advice: understand your local rules and your host's terms before you rent a relay for other people. ## Don't depend on one path Blocks change quickly, so plan for them: - Deploy with the defaults (Reality, XHTTP, and Hysteria2), and add [domain mode](https://getmeridian.org/en/domain-mode.md) so XHTTP and WSS travel through Cloudflare. - Keep a second exit at a different provider and network (`meridian node add`), so one blocked address range doesn't take everything down. - Add a relay for networks that block your exit's address. Every client's subscription lists all of them, and apps that use the Xray JSON or Mihomo subscription switch paths automatically. ## Camouflage target The host's network also shapes the best camouflage target. Pick a popular site with TLS 1.3 that sits on a network that looks natural next to your server: `meridian scan` finds candidates in the server's own neighborhood. Don't choose `apple.com` or `icloud.com` (their networks don't match hosting providers), and don't choose `www.microsoft.com`: its certificate chain is too large for REALITY, so the deploy converges but no traffic passes. See [Choosing a camouflage target](https://getmeridian.org/en/deploy.md#choosing-a-camouflage-target). --- # Deploy Guide > Full deployment walkthrough with all configuration options. This guide covers every option of `meridian deploy`. If this is your first deployment, start with [Getting Started](https://getmeridian.org/en/getting-started.md). ## Basic deployment ``` meridian deploy 198.51.100.10 ``` One server becomes both the control plane (the Remnawave panel) and the exit that carries traffic. Meridian builds the same reviewed topology that [`meridian setup`](https://getmeridian.org/en/cli-reference.md#meridian-setup) produces and applies it with checkpoints. Before it prints "Done", it pushes real traffic through the delivered subscription. Run `meridian deploy` without an IP and it asks for one. Add `--yes` to skip confirmations: ``` meridian deploy 198.51.100.10 --client-name alice --yes ``` ## Password-only servers Meridian logs in with SSH keys. Many VPS providers hand you only a root password, so when the server refuses your keys but accepts a password, an interactive `meridian deploy` asks: ``` → This server accepts only a password. Create a key for Meridian and install it now? [y/N] ``` Answer `y` and type the password (hidden). Meridian creates its own key in `~/.config/meridian/ssh/meridian_ed25519`, adds it to the server's `authorized_keys`, checks that it logs in, saves it in the server profile, and carries on. The password is used for that one command and is never saved or logged. The host key is verified first, so the password only goes to the server whose fingerprint you accepted. `meridian setup` offers the same when you add a server. Without a terminal, with `--yes`, or with `--json`, Meridian never prompts: it stops with the manual command instead. Install your own key once, then rerun: ``` ssh-copy-id root@198.51.100.10 ``` Hardening turns password login off after deploy, so keep the key: later commands use it automatically. ## All flags | Flag | Default | Description | |------|---------|-------------| | `--sni HOST` | (auto) | Camouflage target. Without it, deploy prefers a site in the server's own subnet, then `www.google.com`, `dl.google.com`, `www.cloudflare.com` | | `--domain DOMAIN` | (none) | Your domain: XHTTP moves onto it and WSS is added, both through Cloudflare's CDN | | `--hysteria2/--no-hysteria2` | on | Offer Hysteria2 on UDP/443 as the last fallback | | `--client-name NAME` | default | Name of the first client | | `--display-name NAME` | (none) | Label for connection pages (e.g. "Alice's VPN") | | `--icon EMOJI_OR_URL` | (none) | Page icon — emoji or image URL | | `--color PALETTE` | ocean | Page color theme (ocean/sunset/forest/lavender/rose/slate) | | `--user USER` | root | SSH user (non-root users need passwordless sudo) | | `--ssh-port PORT` | 22 | SSH port, if not 22 | | `--harden / --no-harden` | on | SSH key-only login, fail2ban, and UFW firewall. Use `--no-harden` on a server shared with other services | | `--warp / --no-warp` | off | Send outgoing traffic through Cloudflare WARP | | `--geo-block / --no-geo-block` | on | Block Russian domains and IPs on the server | | `--client-ips / --no-client-ips` | off | Let the panel see each client's real IP (see [Client IPs](#client-ips)) | | `--country CC` | looked up | Country shown in connection names, such as `DE`. Meridian looks it up from the address registry; set it if that is wrong | | `--yes` | | Skip confirmation prompts | Flags for scripts and UIs (`--json`, `--events`, `--request`, `--dry-run`) are described in the [CLI reference](https://getmeridian.org/en/cli-reference.md#meridian-deploy). ## Choosing a camouflage target The camouflage target (SNI) is the website your server impersonates. It is **not** a domain you own — it is a real site with TLS 1.3. Anyone who probes your server sees that site's real certificate. The best target lives next to your server. A censor sees the SNI together with your server's IP; when the site's DNS really points into the same network, the pair looks like ordinary traffic, while a famous foreign site on a VPS address stands out. Without `--sni`, a first deploy scans the server's own subnet for such sites (about a minute) and keeps those whose DNS points into the subnet and that pass Reality's TLS 1.3 and HTTP/2 check. Then it falls back to `www.google.com`, `dl.google.com`, and `www.cloudflare.com`. It uses the first target that real traffic passes through. `meridian node add` picks a neighbor the same way. A target you pass with `--sni` is never swapped; if traffic fails, deploy stops and tells you. A redeploy keeps the saved target. To see the candidates yourself, scan the server's IPv4 neighborhood: ``` meridian scan 198.51.100.10 ``` The scan runs a pinned, checksum-verified build of RealiTLScanner on the server and lists certificate domains it found nearby. Then deploy with your pick: ``` meridian deploy 198.51.100.10 --sni dl.google.com ``` To check one target by hand, run `meridian check 198.51.100.10 --sni HOST`. Avoid `apple.com` and `icloud.com` — their networks don't match VPS providers. ### Changing the target later When `meridian test --probe` reports the camouflage SNI blocked, switch the exit to another target without redeploying. Meridian refuses `apple.com`, `icloud.com`, and `www.microsoft.com` (its certificate is too large for Reality), checks that the exit reaches the new site with TLS 1.3 and HTTP/2, shows the review, and applies it. The old name is replaced at once rather than kept alongside, because a Reality listener imitates exactly one site. Apps switch at their next subscription refresh (every 6 hours) or when the user refreshes the subscription. For a relay, use `meridian relay sni RELAY NEW_SNI`. ``` meridian node sni 198.51.100.10 www.cloudflare.com ``` ### Camouflage target vs. domain - **Camouflage target** (`--sni`) — a popular website you **don't** own. Reality impersonates it. - **Domain** (`--domain`) — a domain you **do** own, pointed at your server. XHTTP and Hysteria2 move to it, and it adds a WSS path through Cloudflare's CDN. See [Domain mode](https://getmeridian.org/en/domain-mode.md). ## Hysteria2 ``` meridian deploy 198.51.100.10 --no-hysteria2 ``` Every deploy offers Hysteria2 on UDP/443 with your domain's certificate, or the server's IP certificate without a domain. Its traffic is wrapped in salamander obfuscation, so censors see random UDP rather than QUIC. It helps on lossy or high-latency networks; where UDP is blocked, apps fall back to the TCP methods listed before it. `--no-hysteria2` turns it off. Hysteria2 doesn't pass through relays. ## Branding Personalize connection pages so recipients know who set up their VPN: ``` meridian deploy 198.51.100.10 --display-name "Alice's VPN" --icon 🚀 --color sunset ``` - **`--display-name`** — shown in the page header and title - **`--icon`** — an emoji or image URL shown at the top of the page - **`--color`** — accent palette: `ocean` (default), `sunset`, `forest`, `lavender`, `rose`, `slate` Branding is saved in `cluster.yml` and applies to every client's connection page. ## Redeploying To redeploy, run the same command again. Meridian keeps the saved paths, keys, and users, so existing links keep working. A redeploy without `--sni` keeps the saved camouflage target. At the end of every deploy, Meridian prints the exact redeploy command with your current settings. Running it again with different flags changes the server to match: adding or dropping `--domain` or `--no-hysteria2` adds or removes those paths. `meridian deploy` only redeploys a server that is already an exit in your fleet. To add another server, use `meridian node add NEW_IP`. ## Pre-flight check Not sure a server is suitable? ``` meridian check 198.51.100.10 ``` Checks the camouflage target, whether port 443 is free and reachable from outside, optional domain DNS, OS, disk space, and clock skew, without installing anything. ## Non-root deployment ``` meridian deploy 198.51.100.10 --user ubuntu ``` Commands run through `sudo`, so the user needs passwordless sudo. ## Local deployment If you are logged into the server as root: ``` meridian deploy local ``` This skips SSH. `check local`, `scan local`, and `doctor local` work the same way, and once deployed, later commands on the server run locally too. Running Meridian from your own machine is still recommended, because `meridian test` needs an outside vantage point. ## Cloudflare WARP WARP sends your server's outgoing traffic through Cloudflare, so websites see a Cloudflare address instead of your VPS IP. ``` meridian deploy 198.51.100.10 --warp ``` **When WARP helps:** sites that block datacenter IP ranges, or when you want to hide the VPS address from destination sites. **When you don't need it:** most sites work fine without it, and it adds one hop (VPS → Cloudflare → destination). Meridian installs the `cloudflare-warp` client in proxy mode (SOCKS5 on `127.0.0.1:40000`) and makes it Xray's outbound. Incoming connections are not affected. WARP is free and needs no Cloudflare account. ### WARP for some sites only To send only chosen sites through WARP and keep everything else direct, add a route on the exit instead of turning WARP on for all traffic. Meridian sets up WARP on the exit the first time a route needs it: ``` meridian route add exit-1 --via warp chatgpt.com netflix.com meridian route add exit-1 --via block geosite:category-ads-all meridian route list meridian route remove exit-1 warp ``` Values are domains (subdomains included), `geosite:NAME` lists, or `geoip:NAME` lists from the stock v2fly data files; one route holds one kind. `--via direct` does the opposite on a WARP exit: those destinations skip WARP. Routes apply in the order you added them, before the exit's default egress and before geo-blocking. These routes live on the server; they are separate from the client-side rules that send Russian sites direct on the user's device. ## Geo-blocking By default, the server blocks Russian domains (`geosite:category-ru`) and Russian IP ranges (`geoip:ru`) through the proxy. This keeps your VPS IP out of Russian services' logs, which lowers the chance of it being found and blocked. Turn it off if your users need those sites through Meridian: ``` meridian deploy 198.51.100.10 --no-geo-block ``` For a way to open Russian sites directly while keeping the block, see [Split routing](https://getmeridian.org/en/split-routing.md). ## Client IPs By default the panel never learns where your users connect from: every TCP connection reaches Xray through nginx on the server, so Remnawave records the server's own address. That is the private choice, and nothing about users' locations is stored. Remnawave's per-user IP limits and connection logs need the real addresses. Turn them on with: ``` meridian deploy 198.51.100.10 --client-ips ``` nginx then passes each client's address to Xray and the panel. Hysteria2 is direct UDP and always shows the real address. Connections through a relay show the relay's address, and WebSocket or XHTTP through the Cloudflare CDN show Cloudflare's. The setting applies to the whole fleet; redeploy without the flag to turn it off. ## Adding a relay After the exit is running, add a relay on a domestic server so users keep a way in if the exit IP gets blocked. See the [Relay guide](https://getmeridian.org/en/relay.md). ``` meridian relay add 203.0.113.10 --exit 198.51.100.10 ``` ## Management panel Meridian installs [Remnawave](https://remna.st/) as its panel. The panel is reverse-proxied by nginx on a random secret path, separate from the connection-page path, so links you share never reveal it. `meridian panel` prints the URL and admin login (`--show-password` for the password). Treat the panel path like a password. The CLI uses the same panel API, so CLI changes show up in the panel. Meridian owns the hosts, profiles, and templates it creates: if you edit those in the panel, `meridian plan` reports the difference and the next apply restores Meridian's version. Client status, limits, expiry, and users you add yourself are yours; see [Web panel](https://getmeridian.org/en/client-management.md#web-panel). Meridian pins the Remnawave versions it has tested. `meridian fleet upgrade` moves your servers to the newest Remnawave release in the same major, without waiting for a Meridian update. If you upgrade Remnawave yourself, Meridian does not downgrade it. --- # Domain Mode > Add CDN fallback via Cloudflare for IP-blocking resilience. ## What domain mode adds ``` meridian deploy 198.51.100.10 --domain vpn.example.com ``` Without a domain, the exit already offers Reality, plus XHTTP and Hysteria2 on the server's IP address. Your own domain changes two things: - **XHTTP** moves to your domain, and **WSS** is added there, with a Let's Encrypt certificate that Meridian issues and renews (acme.sh) - Once the domain is proxied through Cloudflare, both paths travel through Cloudflare's CDN — so they keep working even if your server's IP gets blocked The panel and the connection pages stay on the server's IP certificate; the domain carries only the proxy paths. Hysteria2 on UDP/443 switches to the domain's certificate too (see [Hysteria2 and Cloudflare](#hysteria2-and-cloudflare)); `--no-hysteria2` turns it off. ## Cloudflare setup **Follow this order** to avoid certificate problems: 1. Add your domain in Cloudflare and create an **A record** pointing to your server IP 2. Keep the cloud icon **grey** ("DNS only") 3. Run `meridian deploy 198.51.100.10 --domain vpn.example.com` — Meridian obtains the certificate 4. Switch the record to **orange cloud** (Proxied) 5. Set SSL/TLS to **Full (Strict)** and turn on **WebSockets** (Network settings) Deploy prints these steps again when it finishes. > **Important:** the certificate is issued with an HTTP challenge on port 80. Cloudflare's "Always Use HTTPS" breaks that challenge — turn it off, or exempt `/.well-known/acme-challenge/*`. ## Connection links Each client's subscription and connection page list every path: | Path | Route | When it helps | |------|-------|---------------| | Reality | Direct to the server IP | Primary — fastest | | XHTTP | Your domain (through Cloudflare when proxied) | Reality is blocked or throttled | | WSS | Your domain (through Cloudflare when proxied) | Server IP is blocked | | Hysteria2 | Your server's IP over UDP/443, with the domain's certificate | Lossy or high-latency networks, or when every TCP path is blocked | Apps that import the Xray JSON, Mihomo, or sing-box subscription switch between paths automatically when one stops working. Other apps show each path as a separate entry. ## Hysteria2 and Cloudflare Hysteria2 dials your server's IP over UDP and uses the domain only for its TLS certificate, so it keeps working when the record is proxied through Cloudflare (which can't carry it). Salamander obfuscation makes its packets look random rather than like QUIC. Where UDP is blocked, apps fall back to the TCP paths listed before it. ## Adding a domain later Run your deploy command again with `--domain` added. Existing Reality links keep working. To drop the domain later, redeploy without `--domain`: the XHTTP and WSS paths are removed and subscriptions update. --- # Client Management > Add users, share connection details, and manage access keys. ## Add a client ``` meridian client add alice ``` This gives "alice" her own key and prints two things to share: - **A connection page link** with a QR code — the easiest thing to send - **A subscription URL** for importing directly into an Xray/V2Ray app Add several people at once: ``` meridian client add alice bob charlie ``` Clients belong to your whole fleet, not to one server. Every exit and relay you have shows up in their subscription. ### What the recipient sees The connection page is built from the person's real subscription, so it lists exactly what their apps receive: direct exits, relays, and every enabled method. It has: - Two steps: install an app recommended for their platform (Happ first, then FlClashX and Clash Mi, plus Throne on desktop), then add the connection with one tap - A QR code for the subscription, shown beside the steps on a computer so they can set up a phone - Every individual connection with its own Open, Copy, and QR buttons, folded away until needed - A "Not connecting?" section with tips and a "Check my connection" test that copies a link-free report they can send you In the app, entries are named by the exit's country and method: "🇩🇪 DE · Reality · Chrome", "🇩🇪 DE · XHTTP · Chrome", "🇩🇪 DE · WebSocket · CDN", "🇩🇪 DE · Hysteria2 · UDP", with "via relay" added for relay entries. Two exits in one country show their names after the flag ("🇩🇪 main"); an exit without a known country shows its name when you have several. Reality and XHTTP also come as "· Firefox" copies with another TLS fingerprint, for when one fingerprint gets blocked. Apps that switch automatically (Xray JSON apps such as Happ, Mihomo, and sing-box) pick a working entry by themselves but never switch between fingerprints; Happ shows a separate "Automatic · Firefox" entry for that. Send the link through any messenger. The recipient opens it, installs the app, taps "Add to …" (or scans the QR code from another device), and connects. [Connecting](https://getmeridian.org/en/connecting.md) walks them through it and through what to do when it stops working; send them that page too. ## Show connection details To share a client's links again: ``` meridian client show alice ``` This prints the connection page and subscription URL, plus status, traffic, and when the client was last seen. If the page is missing (for example, the upload failed), republish it: ``` meridian client show alice --repair-page ``` ## List clients ``` meridian client list ``` Shows every client with status, traffic, creation time, and last seen. ## Suspend and resume a client ``` meridian client disable alice meridian client enable alice ``` `disable` cuts the client off immediately; `enable` lets them back in with the same links. A disable lasts until `client enable`: apply, redeploys, and fleet changes leave it alone. ## Replacing a leaked link If a client's link or connection page reaches someone it shouldn't, replace it: ``` meridian client reissue alice ``` Remnawave gives the client a new subscription ID and new keys. The old subscription URL and connection page return 404, and the servers refuse the old keys. Meridian deletes the old page, publishes a new one, and prints the new page and subscription URL to send the client. They import the new link once: their apps can't follow a subscription URL that no longer exists. Everything else the panel holds for the client stays: status, traffic limit and usage, expiry, description, and HWID device limit. The HWID device limit lives in the panel, not in the link. Devices already registered stay on the client's HWID list after a reissue and keep taking slots, even though their old link no longer connects. Open the client in the panel and delete devices you don't recognize, so the limit counts only the client's own. Meridian adds no claim tickets or device binding of its own. With `--json`, pass `--yes`. The new links appear only in that command's output. ## Removing a client `meridian client remove NAME` removes the client permanently: their links stop working and their connection page is deleted. A fleet keeps at least one client. Don't delete users in the Remnawave panel directly — Meridian recreates any client still in the topology on the next apply. ## Subscription links Subscription URLs look like `https://198.51.100.10//sub/`. They live under the connection-page path; the Remnawave admin panel stays on a separate secret path that shared links never reveal. Every subscription asks apps to refresh it every 6 hours (the `profile-update-interval` header, which apps such as Happ and Clash Verge follow), so when you add an exit or relay, clients pick it up within a few hours without a new link. Apps that ignore the header refresh on their own schedule; a manual refresh in the app picks up changes at once. An app's support button opens that person's connection page, with its "Not connecting?" help. To send people somewhere else, set your own `support-url` header in the panel's subscription settings; Meridian keeps it. ### Tested apps and engines One subscription URL serves each app the format it reads. Every change to Meridian runs a multi-node test lab that fetches each format from a real Remnawave panel and checks it with the engine those apps are built on: | Format | Apps | Engine tested | What the lab checks | |--------|------|---------------|---------------------| | Xray JSON | Happ, v2rayNG, v2rayN, Streisand, V2Box | Xray 26.7.28 | Traffic passes through every entry and through automatic failover; a wrong key is refused | | Mihomo | FlClashX, Clash Mi | Mihomo 1.19.32 | `mihomo -t` loads the profile, its rules, and its fallback group | | sing-box | sing-box (SFA, SFI), Karing | sing-box 1.14.3 | `sing-box check` loads the profile, and its automatic group is the default route | | Base64 links | other Xray/V2Ray apps (v2RayTun, Shadowrocket) | — | Every link parses and names the same servers and key as the other formats | Apps ship their own engine updates, so an app may run a slightly older or newer engine than the one tested. ## Where client data lives Client accounts (keys, status, traffic) live in the Remnawave panel database on your control-plane server. The list of clients Meridian manages is part of your topology in `~/.config/meridian/cluster.yml`. Client commands use the panel API and don't need SSH; publishing a connection page uses SSH to the control-plane server. ## Web panel The [Remnawave](https://remna.st/) admin panel shows traffic, users, and nodes in a browser, and you are welcome to use it. `meridian panel` prints its URL and admin username; `meridian panel --show-password` also prints the password. Meridian and the panel split the work so your edits don't get overwritten: | Meridian manages (the next apply restores its version) | Yours to change (apply never touches these) | |---|---| | Nodes, config profiles, inbounds, published Hosts, squads | Each client's status: enable/disable, traffic limits, expiry, HWID limits, description | | Subscription templates and the settings Meridian sets (title, refresh interval, Xray JSON for apps that read it, the support link unless you set your own) | Users you create yourself in the panel | | Which clients exist, their names, and their squads | Notifications, plugins, IP control, and other panel settings | `meridian plan` shows any difference in what Meridian manages before anything changes. Users you add in the panel are never listed, changed, or deleted by Meridian; to give someone a Meridian-managed link and connection page, use `meridian client add` instead. ### Node plugins Remnawave's node plugins (Ingress Filter, Egress Filter, Torrent Blocker, Connection Drop) are optional and live in the panel. To use one, open **Node Plugins** in the admin UI, create a plugin configuration, turn on the plugins you want, and attach it to your nodes. Meridian already runs every node with the `NET_ADMIN` capability the plugins need, and never changes or removes your plugin configurations. The plugins write nftables rules on the host, so upstream also requires a Linux kernel 5.7 or newer; on an older one they silently do nothing. `meridian check` reports this as **Node plugin readiness** on every exit and gateway, and warns about an old kernel (move to Ubuntu 22.04+ or Debian 11+). If you don't use plugins, you can ignore that warning. Torrent Blocker recognizes BitTorrent through traffic sniffing, which Meridian turns on (route-only, so destinations are never rewritten) on every inbound it renders. Turn the plugin on in the panel and it works on every exit. ### Connect a bot to the panel Community bots, such as a Telegram bot that tells people how much traffic they have left, talk to the Remnawave API directly. Give each bot its own token with only the access it needs: 1. Open the panel (`meridian panel` prints the URL), go to **Remnawave Settings → API tokens**, and choose **Create API token**. 2. Name the token after the bot and set **Expires in (days)** to a period you will actually rotate, such as 30 or 90 days. 3. Start from the **Read only** preset, or pick single endpoints, and grant only what the bot uses. The panel shows the token once; store it with the bot's secrets. 4. Point the bot at the panel URL from `meridian panel` and send the token as `Authorization: Bearer `. API paths go below that URL, for example `https://198.51.100.10//api/users/by-username/alice`. The URL contains the panel's secret path, so keep it as private as the token. Scopes are named `resource:endpoint`; `resource:read` grants every read endpoint of a resource and `resource:write` every write. A typical read-only bot needs: | Scope | Endpoint | Returns | |---|---|---| | `users:by-username` | `GET /api/users/by-username/{username}` | One user's status, traffic used and limit, and expiry | | `users:by-short-uuid` | `GET /api/users/by-short-uuid/{shortUuid}` | The same, found from the ID in the user's subscription link | | `users:list` | `GET /api/users` | Every user, paginated | | `system:stats` | `GET /api/system/stats` | Panel-wide user and traffic totals | | `nodes:list` | `GET /api/nodes` | Each node's status and traffic | Add a write scope only when the bot changes accounts, for example `users:extend` (`POST /api/users/{id}/actions/extend`) for a payment bot that extends expiry. Status, limits, and expiry are yours to change (see the table above), so the next apply keeps a bot's edits. A bot must not rename or delete Meridian's clients: the next apply recreates them. Don't hand a bot Meridian's own token (`meridian-provisioner` in the token list). It has full access to the panel and practically never expires, so a leak from the bot's host would expose every node, key, and user. A separate token limits the damage to what the bot can do, expires on its own, and can be deleted without affecting Meridian. Leave `meridian-provisioner` alone; every client and apply command uses it. --- # Connecting > You received a Meridian link. Install an app, add the connection, and get it working again when it stops. Someone who runs a Meridian server sent you a link. This page is for you: you need no command line and no server knowledge. ## Open the link Open the link on the device you want to connect. It leads to your **connection page**, which recognizes your device and walks you through two steps: 1. **Install an app.** The page recommends apps for your platform and links to their downloads. 2. **Add the connection.** Tap **Add to …** for the app you installed. The app opens with your connection already in it. To set up a phone from a computer, open the link on the computer and scan the QR code beside the steps with the phone's camera or the app's scanner. Keep the link to yourself. It is your personal access, and anyone who has it can connect as you. ## Apps These apps work with Meridian. Your connection page shows the ones for your device first. - [Happ](https://happ.su/) — iOS, Android, Windows, macOS, Linux - [FlClashX](https://github.com/pluralplay/FlClashX/releases/latest) — Android, Windows, macOS, Linux - [Clash Mi](https://clashmi.app/download) — iOS, Android, Windows, macOS, Linux - [Throne](https://github.com/throneproj/Throne/releases/latest) — Windows, macOS, Linux If you received a subscription link (its address contains `/sub/`) instead of a page, copy it and add it in the app as a subscription, usually with a **+** button and "Import from clipboard" or "Add from URL". ## Turn on VPN (TUN) mode Most apps run either as a proxy or as a VPN. As a proxy only, some apps bypass the connection, and websites in your browser can sometimes see your real IP address (a WebRTC leak). Turn on the app's **VPN** or **TUN** mode so all traffic goes through the connection. To confirm, open any leak-check site: the address it shows should differ from your usual one. ## The entries in your app Your app lists several entries, named by country and method, such as "🇩🇪 DE · Reality · Chrome" or "🇩🇪 DE · XHTTP · Chrome". They are different routes to the same service; one may work on a network where another is blocked. Entries marked "via relay" go through an extra server first. If your app offers an automatic entry, use it; it switches to a working route by itself. ## When it stops working 1. **Refresh the subscription.** Your app updates it on its own; to update it now, open the subscription (or profile) list in the app and tap **Update**. This picks up new servers and routes. 2. **Try another entry.** Pick one with a different method, or one "via relay". 3. **Check your device's clock.** A clock that is off by more than a minute can make connections fail; turn on automatic date and time. 4. **Run the connection check.** Turn your VPN app off so the check sees your real network. Open your connection page, expand **Not connecting?** and **Check my connection**, and tap **Run check**. Then tap **Copy report** and send it to the person who gave you the link; the report contains no links or keys. 5. **Ask for help.** If the connection page itself no longer opens, your network may be blocking the server. Tell the person who runs it; [recovering from a block](https://getmeridian.org/en/recovery.md) is part of their job, and your app picks up the new route on its next refresh. --- # Relay Nodes > Route traffic through a domestic server for IP-blocking resilience. ## What relays solve When your exit server's IP gets blocked, users lose access. A relay gives them a domestic entry point that is harder to block: ``` Client → Relay (domestic IP) → Exit server (abroad) → Internet ``` Censors see traffic to a domestic IP. The relay forwards raw TCP to the exit; encryption is end to end between the client and the exit, so the relay never sees plaintext. ## How relays work A relay runs [Realm](https://github.com/zhboner/realm), a small zero-copy TCP forwarder. It listens on port 443 (configurable) and forwards to the exit's Reality listener. No Docker and no panel run on the relay. Relays carry **Reality** traffic. XHTTP, WSS, and Hysteria2 stay on the exit's own address and domain, and remain in the same subscription as alternatives. ## Deploy a relay Deploy the exit first, then point a relay at it: ```bash meridian relay add 203.0.113.10 --exit 198.51.100.10 ``` Meridian adds the relay to your topology and applies it: 1. Prepares the relay server (packages, BBR, SSH hardening, a firewall that opens only SSH and the relay port) 2. Installs Realm (version-pinned, SHA256-verified) as a systemd service 3. Points the relay at the exit's Reality listener 4. Publishes the relay in every client's subscription ### Flags | Flag | Default | Description | |------|---------|-------------| | `--exit/-e EXIT` | (required) | Exit server IP or name | | `--name NAME` | (auto) | Friendly name for the relay (e.g. `ru-moscow`) | | `--port/-p PORT` | 443 | Listen port on the relay server | | `--sni HOST` | exit's target | Reality camouflage target for this relay; omit it to use the exit's | | `--user/-u USER` | root | SSH user on the relay | | `--ssh-port PORT` | 22 | SSH port on the relay server | | `--yes/-y` | | Skip confirmation prompts | ### Example with all options ```bash meridian relay add 203.0.113.10 --exit 198.51.100.10 --name ru-moscow \ --port 443 --sni dl.google.com --user ubuntu --ssh-port 2222 --yes ``` ## How clients connect Existing clients receive the relay on their next subscription refresh, and their connection pages list it. New clients get it automatically: ```bash meridian client add alice # relay included ``` ## Manage relays ```bash meridian relay list # all relays meridian relay list --exit 198.51.100.10 # relays for one exit ``` Check a relay's hops — each Realm service is running, each hop reaches the next, and the public entry accepts connections — then test real traffic through the whole route: ```bash meridian check 203.0.113.10 meridian test ``` The check also measures each leg: from every hop it downloads the exit's camouflage site through the next hop, sending the same SNI clients send, and reports the connect time, the time to first byte, and how many bytes arrived. A transfer that freezes between roughly 8 and 32 KB is the signature of throttling on that leg and is reported as a warning; the usual fix is a relay host at a different provider (another network, or ASN). If the camouflage site's home page is smaller than 32 KB, the leg check is inconclusive rather than passing. To give a relay another camouflage SNI, for example when `meridian test --probe` reports its SNI blocked, change it in place. Meridian checks that the exit reaches the new site, shows the review, and applies it; the old name stops working at once, and apps switch at their next subscription refresh (every 6 hours) or a manual refresh. Pass the exit's own SNI to make the relay follow the exit again: ```bash meridian relay sni 203.0.113.10 dl.google.com ``` To retire a relay, remove the chains through it. Meridian shows what it will delete, asks for confirmation, and cleans up the Realm service, firewall rules, and published entry points; existing clients lose the relay on their next subscription refresh: ```bash meridian relay remove 203.0.113.10 ``` ## Multiple relays Attach several relays to one exit, for example in different cities or networks: ```bash meridian relay add 203.0.113.10 --exit 198.51.100.10 --name ru-moscow meridian relay add 203.0.113.11 --exit 198.51.100.10 --name ru-spb ``` Clients receive every relay in their subscription. For relay chains with more than one hop, use `meridian setup`. ## Mobile whitelists During some mobile internet restrictions in Russia, operators let through only an approved list of sites and addresses. Reports vary by operator, region, and week, and no operator publishes its list. Meridian does not detect or track whitelists; this section covers what you can set up today and what stays manual. ### What deployers report - **Networks differ.** Some seem to look only at the server name (SNI) a connection announces. Others also check the destination IP. Where only the name is checked, a direct Reality path with an allowed name can pass. Where the IP is checked too, the first hop has to be an address on the list. - **Domestic cloud addresses are the usual first hop.** Not every address from a provider is listed: people describe one subnet of a provider's range being allowed while another from the same provider is not. Providers may also limit private customers, cap how often you can recreate a server, or shape bandwidth. - **Foreign CDN addresses are generally not allowed on mobile networks.** Treat [domain mode](https://getmeridian.org/en/domain-mode.md) through Cloudflare as a fallback for home connections, not as a whitelist answer. - **The relay-to-exit link can be disrupted too.** A relay that reaches users can still lose its connection to a foreign exit. ### A relay on an allowed address Rent a small server on a domestic provider whose address is allowed on your users' mobile networks. Community-maintained lists of allowed ranges exist, but only a test from a mobile connection while restrictions are active proves it. Then add it as a relay and give it an allowed name: ```bash meridian relay add 203.0.113.10 --exit 198.51.100.10 --name ru-mobile --sni allowed.example.com ``` With `--sni`, only this relay's entry in the subscription announces that name. Meridian gives the relay its own listener on the exit, reachable only from the relay's last hop, and a Reality listener (same keys) that imitates the name. It routes only that name to Reality and sends everything else to that site, so a prober that reaches the relay without a valid key sees the site's real certificate instead of a proxy answer. The exit's own camouflage target and its other entries don't change. The exit must reach the name with TLS 1.3 and HTTP/2; change it later with `meridian relay sni RELAY NEW_SNI`. Then check the relay and test from the network that matters: ```bash meridian check 203.0.113.10 meridian test ``` `meridian test` from your own machine can't tell you what a whitelisted mobile network allows. Try it on a real device on that network. ### Pick an exit SNI `--sni` on `meridian deploy` and `meridian node add` sets the camouflage target for the exit's direct Reality path. The target must be a site the exit can reach with TLS 1.3, and deploy checks real traffic through it and stops if none passes. Before deploying, check a candidate with `meridian check IP --sni HOST`. On a network that checks names only, an allowed name may let the direct path through, but whether a name works depends on the operator, so test each one. ### What Meridian doesn't do - It doesn't tell you whether a relay's IP is on any operator's list, or whether restrictions are active. - It doesn't set up fronting through a domestic CDN. Domain mode is built and tested for Cloudflare. - A relay carries Reality only. XHTTP, WSS, and Hysteria2 stay on the exit. - It doesn't tunnel through third-party calling or messaging services. ## Troubleshooting ### Port conflict Another service is using the listen port on the relay. Check with `ss -tlnp sport = :443` and stop it, or pick another port with `--port 8443`. ### Firewall blocking Make sure the relay port is open in your cloud provider's firewall or security group, not only in UFW. ### Exit unreachable The relay must reach the exit's Reality port. Run `meridian test` to check the route end to end, and `meridian check EXIT` to check the exit itself. ### Relay service not running On the relay server, list the forwarder services and read their logs. Relay hosts use neutral names (`netfwd-*` units, `/etc/netfwd/` configs) so nothing on them names Meridian, Realm, or a relay: ```bash systemctl list-units 'netfwd-*' journalctl -u 'netfwd-*' --no-pager -n 20 ``` --- # Split Routing (Russia) > Route Russian traffic directly while proxying everything else — keep VPS IP protected and Russian sites working. ## The problem Meridian turns on **server-side geo-blocking** by default: the exit drops traffic to Russian domains (`geosite:category-ru`) and Russian IP ranges (`geoip:ru`). This keeps your VPS IP out of Russian services' logs, where it could be spotted and blocked. The catch: Russian websites don't open while the VPN is on. Users have to disconnect to reach Yandex, VK, Gosuslugi, and other domestic services. ## The solution: split routing on the device Split routing sends Russian traffic **directly** from the device and everything else through the VPN: ``` Russian sites → Device → Direct → Internet (local ISP) Everything else → Device → Proxy → VPS → Internet ``` You get both: - **VPS protection** — the server-side block stays on as a safety net - **Working Russian sites** — the device sends them directly, never through the proxy ## How it works in Meridian Meridian ships split routing in its **Xray JSON** and **Mihomo** subscriptions, so most apps need no setup. Every subscription sends these destinations directly: - local network addresses (`geosite:private`, `geoip:private`) - `.ru`, `.su`, and `.рф` domains, plus `geosite:category-ru` - Russian IP ranges (`geoip:ru`) It also refuses QUIC (UDP 443) to everything else, so apps fall back to TCP, where the proxy routes them by name. Apps that import the plain link list (Base64) don't receive routing rules. In those apps, add **direct** rules for `geosite:category-ru` and `geoip:ru` in the routing settings, or switch to an app that imports Xray JSON or Mihomo subscriptions. Hiddify users can instead set the region to **Russia**. ### Why not edit the template in the panel? Meridian manages its own Xray JSON and Mihomo subscription templates in Remnawave. They carry the automatic fallback between your exits and relays, and the routing above. Meridian restores them on every apply (including redeploys and `client add`, `node add`, `relay add`), so rules added there in the panel won't last. ## How the layers work together | Layer | What it does | Protects against | |-------|-------------|-----------------| | **Server-side geo-block** | Drops Russia-bound traffic at the exit | VPS IP showing up in Russian service logs | | **Split routing on the device** | Sends Russian traffic directly | Broken Russian sites, needless proxy load | Russian traffic never reaches the server. If a device doesn't have the rules (a Base64 import, an older app), the server still blocks Russian destinations, so the VPS IP stays protected. If your users need Russian sites through the VPN instead, deploy with `--no-geo-block`. ## DNS considerations In Xray JSON subscriptions, names that match no rule are resolved through the proxy, so foreign lookups don't leak to the local ISP. Mihomo subscriptions match Russian IP ranges only for connections made by IP and never resolve other names locally. If your app has a separate DNS setting, enable its DNS leak protection for proxied traffic. --- # Use Meridian on a router > Connect a home router to your Meridian server with podkop on OpenWrt or XKeen on Keenetic, so every device is covered without an app. A router that runs a proxy client covers every device in the house, including TVs and consoles that can't run an app. This guide shows two common setups: **podkop** on OpenWrt and **XKeen** on Keenetic. Both are community projects, so check their pages for the current steps before you start. ## Get the link Give the router its own client, so you can disable it without cutting anyone else off: ``` meridian client add home-router meridian client show home-router ``` `client show` prints the connection page and the subscription URL. Open the connection page: every path (Reality, XHTTP, WSS, and each relay) has a **Copy link** button that copies a `vless://` link. Which one to paste: - **Reality first.** It is the primary path and the one every router client below can run. - **XHTTP** needs an Xray-based client that is recent and close to the server's Xray version. Stock sing-box, and so podkop, has no XHTTP transport; XKeen runs Xray and can use it. - **Relay entries** are Reality links too. If your router sits in Russia and the exit's IP is blocked, paste the relay's link instead. - **Hysteria2** is UDP and does not pass through relays. Use it only if your router client supports it. A pasted link is a snapshot. Subscription URLs update on their own, but podkop's FAQ says it has no built-in subscription support, so when you add an exit or relay later, paste the new link. ### Client versions Recent Xray servers turn away sing-box (podkop) and Mihomo clients by default, because those clients announce an older version number. Meridian's exits explicitly accept every client that speaks Reality, so this doesn't affect you. If a router still never connects while `meridian test` passes from your laptop, update its proxy core. On Keenetic, `xkeen -ux` updates XKeen's Xray. ## OpenWrt with podkop [podkop](https://podkop.net/) routes only the domains and lists you choose through a proxy, and sends everything else directly. That suits Russian users: domestic sites stay on the normal connection with no extra rules. **Requirements** (from the [podkop docs](https://podkop.net/docs/install)): OpenWrt 24.10 (25.12 works through apk), and about 20 MB of free flash. If sing-box isn't installed yet, podkop needs 40 MB free in `/overlay`. When space is short, the podkop FAQ suggests `sing-box-tiny`. **Install** from the router's SSH shell: ``` sh <(wget -O - https://raw.githubusercontent.com/itdoginfo/podkop/refs/heads/main/install.sh) ``` If GitHub isn't reachable from the router, podkop documents a mirror bootstrap on its install page. **Configure** in LuCI, on the Podkop page: 1. In the `Main` section, set **Connection Type** to `Proxy`. 2. Set **Configuration Type** to `Connection URL` and paste the Reality `vless://` link. 3. Select the community lists you want to send through Meridian, or add your own domains. 4. **Save & Apply**, then open the Diagnostics tab. Run it from the LAN, not through a remote port forward. To fail over between the direct Reality link and a relay link, choose **URLTest** instead and add both links. sing-box then probes them and uses a working one. ## Keenetic with XKeen [XKeen](https://github.com/jameszeroX/XKeen) installs and manages Xray on Keenetic routers through Entware. The actively maintained fork also supports Mihomo. **Requirements:** a USB drive formatted as EXT4 with Entware, and the router's Netfilter kernel module components enabled. The XKeen guide recommends a swap partition on the drive and turning off RAM compression in the router's system settings. **Install** from the Entware SSH shell as root: ``` opkg update && opkg upgrade && opkg install curl tar && cd /tmp sh -c "$(curl -sSL https://raw.githubusercontent.com/jameszeroX/XKeen/main/install.sh)" ``` **Configure** the Xray files in `/opt/etc/xray/configs/`. Put the Reality link's values into `04_outbounds.json`: ```json { "outbounds": [ { "tag": "vless-reality", "protocol": "vless", "settings": { "vnext": [{ "address": "198.51.100.10", "port": 443, "users": [{ "id": "UUID", "flow": "xtls-rprx-vision", "encryption": "none" }] }] }, "streamSettings": { "network": "tcp", "security": "reality", "realitySettings": { "fingerprint": "chrome", "serverName": "www.google.com", "publicKey": "PBK", "shortId": "SID" } } } ] } ``` The link carries every value: the part before `@` is the UUID, then the address and port. In the query, `pbk` is `publicKey`, `sid` is `shortId`, `sni` is `serverName`, and `fp` is `fingerprint`. Keep `flow` as `xtls-rprx-vision` when the link has it. Set routing in `05_routing.json`. The XKeen guide describes two ways: send only chosen domains and IP lists through the proxy, or send `.ru`, `.su`, and `.рф` direct and everything else through it. Then check and start: ``` xkeen -xtest xkeen -start ``` ## Domestic sites stay direct By default the exit refuses traffic to Russian domains and IP ranges (`--geo-block`). A router that sends everything through the tunnel would break Russian sites. Send them directly from the router instead; podkop does this by design, and with XKeen you choose the second routing mode above. [Split routing](https://getmeridian.org/en/split-routing.md) explains the layers. ## Pitfalls - **Flash, RAM, and CPU.** podkop needs room for sing-box in flash, and a router that is already full will fail the install. A weak CPU caps throughput well below your line speed, so measure through the router before you rely on it. - **Wrong clock.** Many routers boot with the wrong time until NTP answers. TLS certificate checks then fail for the subscription page, XHTTP, WSS, and package downloads. The podkop FAQ tells you to check the router's date first when its installer fails with a certificate error. - **DNS leaks.** podkop routes by domain name, so devices must use the router as their DNS server and browsers must not bypass it with their own secure DNS. A device on another DNS server will resolve to the real address and skip the tunnel. Per the podkop docs, this is the most common cause of "works on one device, not another". - **VPN apps on the device.** A phone running its own VPN app skips the router's routing. - **A disabled client.** `meridian client disable home-router` cuts the router off until you run `meridian client enable home-router`. ## Verify From a device behind the router, open an IP-checking page: it should show your exit's address. From your own machine, `meridian test` checks the delivered subscription end to end, and `meridian check` confirms the server side. See [Troubleshooting](https://getmeridian.org/en/troubleshooting.md) if they disagree. --- # IP Blocked Recovery > What to do when your server's IP gets blocked — diagnosis and recovery options. ## Diagnose Run from your own machine (no SSH needed): ``` meridian test 198.51.100.10 ``` If the TCP check on port 443 fails from your network but works from elsewhere (another network or a VPN), the IP is most likely blocked. A TCP failure everywhere usually means a cloud firewall or a server that is down. To see what exactly is blocked, run [`meridian test 198.51.100.10 --probe`](https://getmeridian.org/en/cli-reference.md#censors-view-meridian-test---probe) from inside the censored network: it reports whether the server IP or the camouflage SNI looks blocked. People without the CLI can open their connection page; if it loads on their network, the control server is reachable from there. ## Immediate relief If you deployed with a **domain** (`--domain`) and the record is proxied through Cloudflare, the XHTTP and WSS paths still work — they reach Cloudflare, not your blocked IP. Apps using the Xray JSON or Mihomo subscription switch over on their own; others can pick the XHTTP or WSS entry by hand. If you have a **relay**, clients connecting through it are unaffected as long as the relay itself can still reach the exit. ## Recovery options ### Add a replacement exit The fastest path, as long as your control-plane server (the one running the panel) is still reachable from your machine: ```bash # 1. Get a new VPS from your provider (new IP) # 2. Add it to the fleet meridian node add 198.51.100.20 --name replacement ``` Clients keep their accounts. They receive the new exit on their next subscription refresh — ask them to refresh in their app if it doesn't happen on its own. ### Or move the blocked exit to a new server `node replace` moves an exit to a fresh server, keeping its ID, keys, protocol paths, and relays, then cleans up the old server: ```bash meridian node replace exit-1 198.51.100.20 ``` Clients pick up the new address on their next subscription refresh. The control-plane server can't be moved this way: its address is part of every subscription URL. ### Add a domain for CDN fallback If you weren't using a domain, add one to the new exit: ```bash meridian node add 198.51.100.20 --domain vpn.example.com ``` See [Domain mode](https://getmeridian.org/en/domain-mode.md) for the Cloudflare steps. ### Add a relay A relay on a domestic server gives users an entry point that stays reachable when foreign IPs are blocked: ```bash meridian relay add 203.0.113.10 --exit 198.51.100.20 ``` To point a relay at a different exit, remove it with `meridian relay remove` and add it again with `--exit` set to the new exit. ### If the control-plane server is lost `node add` and `relay add` need the panel. If the server that hosted it is gone and cannot be restored, Meridian can't carry its clients over: deploy a fresh server with `meridian deploy`, then recreate each client with `meridian client add`. Each person gets a new link. ## Managing from more than one machine Everything Meridian knows about a fleet lives in `~/.config/meridian/` on the machine you deploy from. To manage the fleet from a second machine, copy that whole directory over, and copy it again after each change you make elsewhere. You can't accidentally undo another machine's work. The control server keeps a small record of the last change applied (`/var/lib/meridian/fence.json`). Before changing anything, Meridian compares your copy with that record, and `meridian plan` warns ahead of time if an apply would be refused: - **"Another machine changed this fleet"**: your copy is out of date. Copy `~/.config/meridian/` from the machine that applied last, then run the command again. - **"Another apply is running"**: someone is changing the fleet right now. Wait for them to finish. If that run crashed, its claim expires within 30 minutes; a crashed run on your own machine doesn't block you at all. - **"This control server belongs to a different copy of the fleet"**: this `~/.config/meridian/` was never copied from the machine that manages the fleet. ## Prepare before a block 1. **Add a relay** on a domestic server: `meridian relay add RELAY_IP --exit EXIT_IP` 2. **Use a domain** for CDN fallback: deploy with `--domain vpn.example.com` 3. **Both** — clients then have three ways in: relay (domestic), CDN (Cloudflare), and direct 4. **Back up `~/.config/meridian/`** — it is what lets you manage the fleet from another machine ## Keep the old server Don't tear down the blocked server right away — blocks are sometimes lifted after days or weeks. Check now and then: ```bash meridian test 198.51.100.10 ``` When you no longer need it, remove it with `meridian node remove`. --- # Troubleshooting > Common issues, fixes, and diagnostic tools. ## Which tool to use ``` BEFORE INSTALL → meridian check IP "Will this server work for Meridian?" Tests: SNI reachability, port 443, DNS, OS, disk space. AFTER INSTALL, IS IT HEALTHY? → meridian check "Are the panel, nodes, relays, ports, and certificates OK?" Tests every server in the fleet by role, over SSH. AFTER INSTALL, CAN'T CONNECT → meridian test IP "Is the proxy reachable from where I am right now?" Tests: configured listeners, TLS/SNI, the delivered subscription, and real proxy traffic. No SSH needed — runs from the client device. AFTER INSTALL, CHECK EXPOSURE → meridian test IP --probe "Does this deployment reveal a proxy or management surface?" Tests: public ports, TLS/HTTP behavior, paths, SNI, DNS and topology policy. No SSH needed — runs from the client device. AFTER INSTALL, SOMETHING BROKE → meridian doctor IP "Collect everything for debugging." Collects: server OS, Docker, Remnawave node and nginx logs, ports, firewall, SNI, DNS. ``` Add `--ai` to check or doctor for an AI-ready diagnostic prompt. ## Can't connect at all ### Port 443 not reachable **Causes:** 1. Cloud provider firewall / security group blocks port 443 inbound 2. ISP or network blocks the server IP entirely 3. Server is down or proxy is not running 4. UFW on the server doesn't allow port 443 **Fixes:** 1. Check cloud provider console — ensure port 443/TCP is allowed inbound 2. Try from a different network (mobile data, another Wi-Fi) 3. SSH in and check: `docker ps` (are `ctlsvc-api` and `netagent` running?), `systemctl status nginx`, `ss -tlnp sport = :443` 4. Check UFW: `ufw status` — should show 443/tcp ALLOW ### TLS handshake fails **Causes:** 1. Xray is not running inside the Docker container 2. Port 443 is occupied by another service 3. Reality SNI target is unreachable from the server **Fixes:** 1. Check Xray: `meridian logs IP -c node -n 20` 2. Check port: `ss -tlnp sport = :443` — should be nginx 3. Test SNI: `meridian check IP --sni HOST` ### Domain not reachable **Causes:** 1. DNS not pointing to server IP 2. nginx not running or failed to get TLS certificate 3. nginx not routing domain SNI correctly **Fixes:** 1. Check DNS: `dig +short yourdomain.com @8.8.8.8` 2. Check nginx: `systemctl status nginx` 3. Check the nginx routing files: `ls /etc/nginx/stream.d/ /etc/nginx/sites-enabled/` (Meridian's files start with `meridian-v4-`) and `nginx -t` ## Connection drops after seconds **Causes:** 1. MTU issues on the network path 2. ISP resetting long-lived TLS sessions **Fixes:** 1. Try a different network 2. Use the XHTTP or WSS path (domain mode) ## Deploy fails ### "No traffic passes through it" The server deployed, but real traffic didn't flow through the camouflage target you chose with `--sni`. Pick another target — `meridian scan IP` finds ones on the server's own network — and run the deploy command again with the new `--sni`. Without `--sni`, Meridian already tries `www.google.com`, `dl.google.com`, and `www.cloudflare.com` in turn. ### "The server cannot reach any Reality camouflage target" The server has no working outbound HTTPS to any default target. Check the provider's outbound firewall, or pass a reachable target with `--sni`. ### Interrupted or failed deploy Deploys and applies are checkpointed. Fix the reported cause and run the same command again; finished steps are skipped. ### Port 443 conflict Another service (Apache, Nginx) is using port 443. Stop it or use a clean server. `meridian check IP` tells you what's using the port. ### Docker installation fails Conflicting Docker packages from distro repos. Meridian auto-removes them, but if Docker is already running with containers, it skips to avoid disruption. ### Panel rename stops during an upgrade `meridian apply` moves a panel deployed by a 4.0 beta from `/opt/remnawave` to `/opt/ctlsvc`. Before stopping anything it refuses when both directories exist or `/opt/remnawave` is a mount point. Keep the directory that holds the panel's `data/` and `.env` as `/opt/ctlsvc` (or mount the volume there), move the other aside, then run `meridian apply` again. A rename interrupted midway resumes on the next apply. ### SSH connection errors Test SSH manually: `ssh root@SERVER_IP`. Ensure you have key-based access. Use `--user` flag if not root. If the server accepts only a password, run `meridian deploy` in a terminal without `--yes` and let it install a key (see [Password-only servers](https://getmeridian.org/en/deploy.md#password-only-servers)), or run `ssh-copy-id root@SERVER_IP` once. ### Xray fails to start in the node container Check the container: `meridian logs IP -c node -n 50`. Common causes are a port collision on the host (node runs in `network_mode: host`, so ports from `cluster.yml` must be free), an unreachable panel (node registration needs the panel's `node_secret_key` at boot), or a missing `NET_ADMIN` capability. **Fix:** run `meridian check IP` and follow its remediation before redeploying. To verify Remnawave panel state, log into the admin UI at `https:////` and check **Nodes** → the node should be `connected`; `meridian fleet status` surfaces the same information from the CLI. ### Disk space insufficient Less than 2GB free. Free up space: `docker system prune -af`, `journalctl --vacuum-time=1d`, check `/var/log/`. ### DNS resolution fails (domain mode) The certificate for your domain can only be issued once the domain's A record points at the server (grey cloud while deploying). Update the record, wait for it to propagate (usually 5–15 minutes), then run the same deploy command again. `meridian check IP --domain DOMAIN` checks this before you deploy. ## Was working, now stopped **Most common cause:** Server IP got blocked. Run `meridian test IP`; its findings distinguish network reachability from TLS, subscription, and proxy execution failures. A TCP failure can also mean a cloud firewall or an offline server. See the [IP Blocked Recovery guide](https://getmeridian.org/en/recovery.md) for recovery options (replacement exit, relay, CDN fallback). Other causes: - Server rebooted and services didn't start → `meridian plan` shows what is off and `meridian apply` repairs it. By hand: `docker compose up -d` in `/opt/ctlsvc` and `/opt/netagent`, then `systemctl restart nginx` - Disk full → `df -h /`, `docker system prune -af` ## Slow speeds 1. Choose a server geographically closer (Finland, Netherlands, Sweden for Europe/Middle East) 2. Check server load: `htop` or `uptime` 3. Try the XHTTP or WSS path (domain mode) — routing through Cloudflare is sometimes better 4. Verify BBR is enabled: `sysctl net.ipv4.tcp_congestion_control` **Do NOT** run other protocols (OpenVPN, WireGuard) on the same server — it flags the IP. ## AI-powered help ``` meridian doctor --ai ``` Copies a diagnostic prompt to your clipboard for use with any AI assistant. Or collect diagnostics for a [GitHub issue](https://github.com/getmeridian/meridian/issues): ``` meridian doctor ``` ## Relay not working See the [Relay guide — Troubleshooting](https://getmeridian.org/en/relay.md#troubleshooting) section for relay-specific issues (port conflict, firewall, exit unreachable, service not started). ## Interpreting pre-flight checks | Check | What It Tests | If It Fails | |-------|--------------|-------------| | SNI target reachability | Can the server reach the camouflage site? | Server's outbound is restricted. Try a different SNI with `--sni` | | Port 443 availability | Is port 443 free or used by Meridian? | Another service is on 443. Stop it or use a clean server | | Port 443 external reachability | Can the outside world reach port 443? | Cloud firewall blocks it. Open port 443/TCP inbound | | Domain DNS | Does the domain resolve to server IP? | Update DNS A record | | Server OS | Is it Ubuntu/Debian? | Fails without apt (CentOS, Fedora, Alpine); reinstall with Ubuntu or Debian | | Disk space | At least 2GB free? | Free up space | | Clock sync | Is client/server drift under a minute? | One side is not syncing time. Enable NTP and automatic time on both devices | ## Interpreting doctor output | Section | What to Look For | |---------|-----------------| | Local Machine | OS compatibility | | Server | OS version, uptime (recent reboot?), disk/memory usage | | Docker | Are the `ctlsvc-api` (panel) and `netagent` (node) containers running? Status should be "Up" | | Remnawave Node Logs | Node startup errors, "failed to start" entries, certificate issues | | Listening Ports | Port 443 should show nginx. If missing, proxy isn't running | | Firewall (UFW) | Port 443/tcp should be ALLOW. If not listed, it's blocked | | SNI Target | Should show CONNECTED with a certificate chain | | Domain DNS | Should resolve to server IP | ## Interpreting test output | Check | Pass | Fail or skipped | |-------|------|-----------------| | TCP reachability | Every configured public TCP listener is reachable | Firewall, ISP block, server down, or wrong topology | | TLS/SNI camouflage | The target presents the expected camouflage identity | TLS routing, certificate, DNS, or SNI mismatch | | Domain root | Hardened `403`/`404` response is reachable | DNS/nginx failure or unintended public content | | Subscription | Remnawave delivered valid Xray JSON for an active client | Missing/inactive client, panel failure, or broken template | | Automatic fallback | The delivered policy carried real traffic | Balancer, routing, credential, or endpoint failure | | Protocol checks | Each target outbound carried real traffic | Protocol-specific listener, firewall, or delivered config failure | The full test rotates independent public IP observers and checks the same observers directly. If neither the proxy nor control path can obtain an IP, the result is inconclusive rather than a false protocol failure. Set `MERIDIAN_CONNECT_TEST_URL` only when you operate a known observer endpoint. The first full test may spend up to 120 seconds downloading and verifying the pinned Xray release from GitHub. After bootstrap, `--timeout` (default `5`, range `1`-`30`) applies to individual network operations. Exit `0` passed, `4` completed with negative findings, and `3` was inconclusive because required evidence was skipped. Basic mode cannot certify UDP-only Hysteria traffic. If all checks pass but the VPN client still can't connect: re-scan the QR code, check that the device sets its date and time automatically, or try another recommended app (Happ, FlClashX, Clash Mi, or Throne on desktop). --- # Architecture > System architecture, traffic flow, and service topology. ## Technology stack - **VLESS+Reality** (Xray-core) — the primary protocol. It impersonates a real TLS website: anyone probing the server sees that site's genuine certificate (preferably a site hosted next to your server). Only clients with the right key reach the proxy. - **XHTTP** (Xray-core) — a TLS path on your domain, or on the server's IP with a Let's Encrypt IP certificate when there is no domain. - **WSS** (Xray-core) — a TLS path on your own domain, added in domain mode. With Cloudflare proxying the domain, it and XHTTP travel through the CDN and survive an IP block. - **Hysteria2** (Xray-core) — the UDP/443 last resort for lossy networks, wrapped in salamander obfuscation so it doesn't look like QUIC. On by default (`--no-hysteria2` turns it off). - **Remnawave** — the panel stack: `remnawave/backend` (API and admin UI, with PostgreSQL and Valkey) on the control-plane server, `remnawave/node` (runs Xray) on every exit, and `remnawave/subscription-page`. Meridian pins tested versions and talks to the panel over its REST API. - **nginx** — owns public TCP/443. Its stream module routes connections by SNI without terminating TLS; its http module terminates TLS for the panel, connection pages, subscriptions, and domain paths. Certificates come from Let's Encrypt via [acme.sh](https://github.com/acmesh-official/acme.sh). - **Realm** — the TCP forwarder on relay servers. - **uTLS** — client connections copy a browser's TLS Client Hello (Chrome, with Firefox copies as backup entries), so they look like ordinary browser traffic. ## Service topology ### Single server (`meridian deploy IP`) ```mermaid flowchart TD Internet((Internet)) -->|TCP 443| Nginx[nginx stream
SNI router] Nginx -->|"SNI = camouflage target"| XrayReal["Xray Reality
(Remnawave node)"] Nginx -->|"no SNI / server IP"| NginxHTTP["nginx http
:8443"] NginxHTTP -->|/page-path/| Page[Connection pages] NginxHTTP -->|/page-path/sub/| Sub[Subscriptions] NginxHTTP -->|/secret-path/| Panel[Remnawave admin UI] Internet -->|TCP 80| NginxACME["nginx
ACME challenges"] ``` nginx stream **does not** terminate TLS. It reads the SNI from the Client Hello and forwards the raw TCP stream. The panel, connection pages, and subscriptions are served with a Let's Encrypt certificate for the server's IP (short-lived, renewed automatically). Shared links live under the connection-page path: pages at `///`, subscriptions at `//sub/`. The admin panel sits on a separate secret path that shared links never reveal. ### Domain mode ```mermaid flowchart TD Internet((Internet)) -->|TCP 443| Nginx[nginx stream
SNI router] CDN["Cloudflare CDN"] -->|TCP 443| Nginx Nginx -->|"SNI = camouflage target"| XrayReal["Xray Reality"] Nginx -->|"SNI = your domain"| NginxDomain["nginx http
(domain certificate)"] Nginx -->|"no SNI / server IP"| NginxHTTP["nginx http :8443
panel, pages, subscriptions"] NginxHTTP -->|"/xhttp-path (no domain)"| XrayXHTTP NginxDomain -->|/xhttp-path| XrayXHTTP["Xray XHTTP"] NginxDomain -->|/ws-path| XrayWSS["Xray WSS"] Internet -.->|"UDP 443 (salamander)"| XrayHy2["Xray Hysteria2"] ``` The domain carries only the proxy paths; the panel and pages stay on the IP certificate. ### Relays ```mermaid flowchart LR Client([Client]) -->|TCP 443| Relay["Relay
(Realm)"] Relay -->|TCP| Exit["Exit server
(abroad)"] Exit --> Internet((Internet)) ``` A relay forwards raw TCP from a domestic IP to the exit's Reality listener. Encryption is end to end between client and exit; the relay never sees plaintext. Relays carry Reality only. `meridian setup` can also build multi-hop relay chains and routing gateways that pick an exit (or a health-checked pool of exits) by rule. ## How Reality works 1. Each exit has an **x25519 keypair**. Clients get the public key; the private key stays on the server. 2. The client connects on port 443 with a TLS Client Hello naming the camouflage target (e.g. `www.google.com`). 3. To an observer, this is an ordinary HTTPS connection to that site. 4. A **prober** without the key is passed through to the real site and sees its valid certificate. 5. A client with valid authentication gets the VLESS tunnel. 6. **uTLS** makes the Client Hello match Chrome's, defeating TLS fingerprinting. ## State model - **Topology** — `~/.config/meridian/cluster.yml` on your machine holds the reviewed topology (control plane, exits, relays, gateways, protocol paths, and the list of users), panel access, and the keys Meridian generated. `meridian setup`, `deploy`, `client add`, `node add`, and `relay add` change it; don't edit it by hand. - **Clients** — accounts, keys, status, and traffic live in the Remnawave database on the control-plane server. - **Saved servers** — `~/.config/meridian/servers.json` holds SSH connection details. ## Plan and apply Meridian turns the topology into a list of resources: firewall rules, nginx routes, containers, Remnawave profiles, hosts, users, and templates. `meridian plan` observes each one on the live servers and panel and reports what needs repair. `meridian apply` applies the exact plan it showed, checkpointing every step so an interrupted apply resumes safely. Resources whose outcome is uncertain are re-observed before any retry. Anything in the topology can be added and removed: users, protocol paths, exits, relays, and routing gateways. After the new plan converges, apply deletes what left it (panel users, hosts, and nodes, nginx files, Realm services, certificates, firewall rules). The control server is the exception: its address is part of every subscription URL, so it leaves only with `meridian teardown`. Panel edits to resources Meridian owns show up in `meridian plan` and are restored by the next apply. Remnawave settings Meridian doesn't manage are left alone. ## Port assignments | Port | Service | Scope | |------|---------|-------| | 443/TCP | nginx stream (SNI router) | Public | | 443/UDP | Xray Hysteria2 (on by default) | Public | | 80/TCP | nginx (ACME challenges) | Public | | 8443 | nginx http (panel, pages, subscriptions) | Internal | | 8444 | nginx http, PROXY-protocol listener (used with `--client-ips`) | Internal | | 3000 | Remnawave backend (admin UI + API) | localhost | | 3010 | Remnawave node API | host network | | 3020 | Remnawave subscription page | localhost | | 10000+ | Xray Reality (per-exit) | host network, firewalled | | 20000+ | Xray WSS (domain mode, per-exit) | host network, firewalled | | 30000+ | Xray XHTTP (per-exit) | host network, firewalled | Backend Xray ports are reached through nginx; UFW blocks them from the internet. Relay servers open only SSH and their listen port. ## Docker containers Container and directory names are neutral, so a host reading `docker ps` or `/opt` sees nothing that names proxy software (images keep their upstream tags). `meridian check` and `meridian doctor` show the role behind each name. **On the control-plane server:** - `ctlsvc-api` — Remnawave backend API and admin UI on `127.0.0.1:3000` - `ctlsvc-db` — PostgreSQL (users, hosts, profiles) - `ctlsvc-cache` — Valkey cache - `ctlsvc-web` — Remnawave subscription frontend on `127.0.0.1:3020` - `netagent` — Remnawave node (Xray), in host networking with `NET_ADMIN` **On every other exit:** - `netagent` only, registered with the panel through a per-node secret Servers deployed by a 4.0 beta ran these as `remnawave*` from `/opt/remnawave` and `/opt/remnanode`. The next `meridian apply` stops them, moves the panel directory (its PostgreSQL `data/` and `.env` secrets) to `/opt/ctlsvc` in one rename, and starts the renamed containers. Users, client links, and API tokens stay the same. ## Teardown `meridian teardown IP` removes the panel and node containers and volumes, Meridian's nginx files, certificates, Realm services, and firewall rules (plus 3x-ui from Meridian 3.x). Docker and system packages stay. On a single-server deployment it also removes the local `cluster.yml`. In a multi-server fleet it refuses servers that still have a role. ## File locations ### On the control-plane server - `/opt/ctlsvc/` — panel compose file, `.env`, and PostgreSQL `data/` - `/etc/nginx/stream.d/meridian-v4-*.conf` — SNI routing - `/etc/nginx/sites-available/meridian-v4-*.conf` — TLS sites and reverse proxy - `/etc/ssl/meridian/` — certificates ### On each exit - `/opt/netagent/` — node compose file and `.env` ### On each relay - `/etc/netfwd/` — Realm configuration, run from `/usr/local/bin/netfwd` by `netfwd-*` systemd services. Relay hosts carry no Meridian-named units, paths, or firewall comments (`fw-*`) ### On your machine - `~/.config/meridian/cluster.yml` — topology, panel access, keys - `~/.config/meridian/cluster.yml.bak` — backup taken before destructive operations - `~/.config/meridian/servers.json` — saved SSH servers - `~/.config/meridian/setup.json` — `meridian setup` progress (no secrets) - `~/.config/meridian/ssh/meridian_ed25519` — managed SSH key, when generated - `~/.config/meridian/cache/` — update-check cache The directory follows `$XDG_CONFIG_HOME` when it is set, and `MERIDIAN_HOME` overrides it. While `~/.config/meridian/` doesn't exist, Meridian also uses `~/.meridian-v4/` or `~/.meridian/` when either holds a fleet (it has a `servers.json`). Meridian 3.x state in `~/.meridian/` is never touched. --- # CLI Reference > Complete reference for all Meridian CLI commands and flags. ## Commands ### meridian deploy Deploy one server and get a shareable connection link. The server becomes both the control plane (Remnawave panel) and an exit. Deploy builds the same reviewed topology as `meridian setup`, applies it with checkpoints, verifies real traffic through the delivered subscription, and prints a connection page and subscription URL. Re-running the same command resumes an interrupted deploy or redeploys, keeping paths, keys, and users. ``` meridian deploy [SERVER] [flags] ``` | Flag | Default | Description | |------|---------|-------------| | `--sni HOST` | (auto) | Camouflage target. Without it, a first deploy tries sites in the server's own subnet, then `www.google.com`, `dl.google.com`, `www.cloudflare.com`, and uses the first that carries traffic; a redeploy keeps the saved target. An explicit `--sni` is never swapped | | `--domain DOMAIN` | (none) | Your domain: XHTTP moves onto it and WSS is added, both through Cloudflare's CDN (XHTTP works without one, on the IP). The panel and connection pages stay on the IP certificate | | `--hysteria2/--no-hysteria2` | on | Offer Hysteria2 on UDP/443, disguised with salamander, as the last fallback. Uses the domain's certificate, or the IP certificate without one | | `--fingerprint NAME` | chrome | TLS fingerprint clients present on Reality, XHTTP, and WSS (`chrome`, `firefox`, `safari`, `ios`, `android`, `edge`, `random`, `randomized`). Change it if your region starts flagging the current one; a redeploy keeps the saved value | | `--client-name NAME` | default | Name of the first client | | `--display-name NAME` | (none) | Label for connection pages | | `--icon EMOJI_OR_URL` | (none) | Page icon — emoji or image URL | | `--color PALETTE` | ocean | Page color theme (ocean/sunset/forest/lavender/rose/slate) | | `--user USER` | saved, else root | SSH user (non-root needs passwordless sudo) | | `--ssh-port PORT` | saved, else 22 | SSH port | | `--harden / --no-harden` | on | SSH key-only login, fail2ban, and UFW firewall. `--no-harden` is for servers shared with other services | | `--warp / --no-warp` | off | Send outgoing traffic through Cloudflare WARP | | `--geo-block` / `--no-geo-block` | on | Refuse Russian domains and IPs (`geosite:category-ru` + `geoip:ru`) at the exit. Xray JSON and Mihomo subscriptions already send them directly from the device | | `--client-ips / --no-client-ips` | off | Pass each client's real IP to Xray and the panel, for Remnawave per-user IP limits and connection logs. Off, every TCP connection appears to come from the server itself | | `--country CC` | address registry | Two-letter country shown in connection names and as the panel's node flag. Without it, a first deploy asks the server address's Regional Internet Registry (via IANA's RDAP bootstrap, no geo-IP service); a redeploy keeps the saved country | | `--yes` | | Skip confirmation prompts | | `--json` | | Emit the final deploy result as a `meridian.output/v1` envelope | | `--events=jsonl` | | Stream typed progress events as JSONL on stderr | | `--request FILE` | | Read a `deploy-request` JSON payload from a file; use `-` for stdin | | `--dry-run` | | Compile and preview the deploy without SSH or local changes | Without an IP, deploy asks for one. `local` deploys on the machine you are running on. Deploy only redeploys a server that is already an exit in your fleet; add other servers with `meridian node add`. Changes are additive: removing something already deployed (such as dropping `--domain`) is refused. **Machine/UI flow**: `meridian api workflow deploy --json` returns a renderable wizard contract. A UI collects those fields, validates them against `deploy-request`, then runs `meridian deploy --request deploy.json --json --events=jsonl`. Machine deploys are non-interactive: the request must contain `yes: true` after the user confirms. `--dry-run --json` returns `deploy-plan` under `data`, so a UI can preview the mode, ports, and generated paths before opening SSH. ### meridian setup A resumable guided wizard for one server or many: exits, Realm relays, routing gateways with failover pools, and users. It is written for people who have never set up a VPN: every question uses the real term (exit, relay, SNI) and explains it in one plain sentence, with a safe default you can accept by pressing Enter. ``` meridian setup [--intent FILE|-] [--restart] [--yes] ``` | Flag | Default | Description | |------|---------|-------------| | `--intent FILE` | (none) | Start at review from a complete topology JSON document; use `-` for stdin | | `--restart` | | Discard saved setup progress before starting | | `--yes`, `-y` | | Approve the review and apply without asking | The first question is the language (English, Русский, فارسی, 中文), preselected from your terminal and remembered in `~/.config/meridian/preferences.json`; `--lang` or `MERIDIAN_LANG` skips it. Then the wizard walks through servers (an IP, `user@IP:port`, or `local` for this machine), what each server does, connection methods, routing (only asked when you have a routing gateway), users, a plain-language summary, and apply. Choices are lists: move with the arrow keys (or press an option's number), toggle checklist items with Space, press `?` for details, Enter to confirm, Esc to go back, and Ctrl+C to quit. Wherever a list offers "Something else", you can type your own value. In text fields you can also type `back`, `details`, `save`, or `quit` (or the same words in your language). For the camouflage site, the wizard scans the exit's own network first (about a minute) and lists those sites ahead of the well-known ones. Set `MERIDIAN_PLAIN_PROMPTS=1` for numbered prompts (they are used automatically when the terminal can't draw lists). Running `meridian` with no command in a terminal, before any VPN exists, offers to start the wizard. Progress is saved in `~/.config/meridian/setup.json` after each stage, so running `meridian setup` again resumes; passwords and private keys are never stored there. `--intent` does not replace an existing draft unless `--restart` is also given. When apply succeeds, setup verifies every node and subscription and prints each user's connection page. ### meridian plan Show what `meridian apply` would change, comparing the topology in `~/.config/meridian/cluster.yml` with the live servers and panel. Nothing is changed. ``` meridian plan [--json] ``` | Flag | Default | Description | |------|---------|-------------| | `--json` | | Emit the plan as a `meridian.output/v1` envelope for CI and UI clients | The output shows the plan hash, resource counts by kind, and any resources that need repair or could not be observed. JSON `data` contains `plan_hash`, `converged`, `resources[]`, `drifted_resources[]`, `observation_errors[]`, and `state_changes[]`. **Exit codes**: - `0` — converged, nothing to do - `1` — nothing to plan (no fleet yet, or unusable local state) - `2` — changes pending (run `meridian apply`) - `3` — required evidence (panel, SSH, local state) was unavailable Don't edit the topology in `cluster.yml` by hand. Change it with `meridian setup`, `deploy`, `client add`, `node add`, or `relay add`. ### meridian apply Apply the reviewed topology in `cluster.yml` to your servers. Apply shows the plan hash and resource count, asks for confirmation, then applies the exact plan it showed, with checkpoints. An interrupted apply resumes where it stopped. ``` meridian apply [--yes] [--json] ``` | Flag | Default | Description | |------|---------|-------------| | `--yes`, `-y` | | Skip confirmation | | `--json` | | Emit the final apply result as a `meridian.output/v1` envelope | JSON `data` contains `plan_hash`, `generation`, and `actions[]` with `status: "converged" | "applied" | "failed" | "unknown" | "skipped"`. JSON mode never prompts: without `--yes` it returns `MERIDIAN_CONFIRMATION_REQUIRED` with the plan hash and exits `2`. A failed resource exits `3`; fix the cause and run apply again. If local state cannot be saved after remote changes, apply exits `3` and says so; repair `~/.config/meridian` permissions, then run `plan` and `apply` again. Removals are applied too: whatever left the topology (users, protocol paths, exits, relays, routing gateways) is deleted after everything still in the plan has converged, and `plan` lists it first. The control server leaves only with `meridian teardown`. ### meridian client Manage the people who connect. ``` meridian client add NAME [NAME...] [--json] meridian client show NAME [--repair-page] [--json] meridian client list [--json] meridian client enable NAME [--json] meridian client disable NAME [--json] meridian client reissue NAME [--yes] [--json] meridian client remove NAME [--yes] [--json] ``` | Flag | Default | Description | |------|---------|-------------| | `--json` | | Emit the result as a `meridian.output/v1` envelope (all client commands) | | `--repair-page` | | Republish the client's connection page (`client show`) | | `--yes`, `-y` | | Confirm `client reissue` or `client remove`; required with `--json` | **`client add`** — adds one or more people to the topology, applies it, and prints each person's connection page and subscription URL. The page is built from their real subscription, so relays and Hysteria2 appear when present. Names already in the topology or already present in the panel are refused. Names can be in any script (`client add Биба 王小明`): pages and output show them as typed, while the panel, which accepts only Latin letters, digits, `-`, and `_`, gets a transliterated username (`biba`, `wangxiaoming`). Other client commands accept either form. If a client's subscription link can't be built, the client is still added and the command exits `3`; a missing connection page alone is a warning (repair it with `client show --repair-page`). **`client show`** — prints the connection page, subscription URL, status, traffic, and last-seen time. `--repair-page` republishes a missing page; if that fails, the links are still shown with a warning and exit `3`. **`client list`** — lists clients with status, traffic, creation time, and last seen. With `--json`, returns `data.summary` counts and `data.clients[]`. **`client enable` / `client disable`** — resume or suspend a client immediately. A disable lasts until you run `client enable`; applies (including redeploys and `client add`, `node add`, `relay add`) leave a client's status alone. **`client reissue`** — replaces a client's links after a leak. Remnawave issues a new subscription ID and new keys, so the old subscription URL, connection page, and links stop working. Meridian deletes the old page first, then publishes the new one and prints the new links. Status, limits, expiry, and the HWID device limit stay as set in the panel; delete devices you don't recognize there. Asks for confirmation unless `--yes`, and refuses names that aren't your clients. If the new page can't be published, the links are still shown with a warning and exit `3` (repair it with `client show --repair-page`). **`client remove`** — removes the client from the topology, deletes the panel user and their connection page. Asks for confirmation unless `--yes`. The last client cannot be removed. Don't delete users in the panel directly; Meridian recreates any client still in the topology on the next apply. ### meridian server Manage saved SSH servers (`~/.config/meridian/servers.json`). ``` meridian server add [USER@]IP[:PORT]|local [--name NAME] [--user USER] [--ssh-port PORT] meridian server list meridian server remove NAME_OR_IP [--yes] ``` | Flag | Default | Description | |------|---------|-------------| | `--name NAME` | (none) | Display name for the server | | `--user/-u USER` | root | SSH user | | `--ssh-port PORT` | 22 | SSH port | | `--yes`, `-y` | | Skip the removal confirmation (`server remove`) | `server add local` saves the machine you are on; later commands run on it directly instead of over SSH. Names and addresses must be unique; `server add` refuses collisions instead of retargeting an existing server. `server remove` deletes only the saved profile and refuses while the server still has a role in your topology. ### meridian node Manage exit servers. The first server is deployed with `meridian deploy` (or `meridian setup`); add more exits with `meridian node add`. ``` meridian node add [USER@]IP[:PORT] [flags] meridian node list [--json] meridian node remove NAME_OR_IP [--yes] [--force] meridian node replace EXIT [USER@]NEW_IP[:PORT] [--name NAME] [--yes] meridian node sni EXIT NEW_SNI [--yes] ``` | Flag | Default | Description | |------|---------|-------------| | `--name NAME` | (auto) | Friendly name for the node | | `--user USER` | saved, else root | SSH user on the node | | `--ssh-port PORT` | saved, else 22 | SSH port on the node (`node add`, `node replace`) | | `--sni HOST` | www.google.com | Reality camouflage target for this node | | `--domain DOMAIN` | (none) | Exit's domain: XHTTP moves onto it and WSS is added, both through Cloudflare's CDN; Hysteria2 uses its certificate | | `--country CC` | address registry | Country shown in connection names; set it when the registry is wrong (anycast or leased ranges) | | `--harden / --no-harden` | on | OS, SSH, and firewall hardening. Fleet servers always keep it; `--no-harden` is refused | | `--yes` | | Skip confirmation prompts | | `--force` | | `node remove`: also remove the relay chains that route to this exit | | `--json` | | Emit `node list` as a `meridian.output/v1` envelope | **`node add`** — connects over SSH, adds the server as an exit in your topology, and applies the reviewed plan (hardening, Docker, Xray, panel registration). Existing clients receive the new exit on their next subscription refresh. **`node list`** — lists nodes with live panel status, Xray version, and traffic. If panel status is unavailable, configured nodes are shown as `unknown` and the command exits `3`. **`node remove`** — removes an exit or routing gateway from your topology. It lists every resource that will be deleted (panel node and profile, node container, firewall rules, published Hosts), asks for confirmation, and applies exactly that review; the server stays in your saved servers. It refuses for the control server (use `meridian teardown`), while relays still route to the exit (remove them first or pass `--force`), while a routing gateway enters the fleet through it, and when the result would break the topology (for example an empty egress pool or the default egress). Routing rules scoped to a removed gateway are dropped with it. **`node replace`** — moves one exit to another server. `EXIT` is the exit ID or its server's saved name or IP; the new server uses the same target syntax as `node add` (a saved name or `local` works too) and is saved like `server add`. The exit keeps its ID, Reality keys, protocol paths, SNI, WARP and geo settings, and every relay that points at it, so relay users keep working and direct users get the new address on their next subscription refresh. The review lists what will be deployed on the new server and every resource cleaned up on the old one (node container, image, and directory; its nginx files, firewall rules, and certificates); Docker and the saved server stay. It refuses for the control server (moving the panel would change every subscription URL) and for a new server that already has a role. **`node sni`** — gives an exit's Reality path a new camouflage SNI. `EXIT` is the exit ID or its server's saved name or IP. It refuses `apple.com` and `icloud.com` names and `www.microsoft.com`, checks over SSH that the exit reaches the new site with TLS 1.3 and HTTP/2, shows the review (relays that follow the exit's SNI, relays that keep their own, routing gateways built on the path, and what gets cleaned up), confirms, and applies it. The old name is replaced, not kept alongside: apps switch at their next subscription refresh (every 6 hours) or a manual refresh, and until then they cannot connect over Reality. XHTTP, WSS, and Hysteria2 don't change. Use it when `meridian test --probe` reports the camouflage SNI blocked. ### meridian relay Manage relays — Realm TCP forwarders that give users a domestic entry point to an exit abroad. Relays carry Reality traffic. ``` meridian relay add [USER@]RELAY_IP[:PORT] --exit EXIT [flags] meridian relay list [--exit EXIT] [--json] meridian relay remove RELAY [--exit EXIT] [--yes] meridian relay sni RELAY NEW_SNI [--exit EXIT] [--yes] ``` | Flag | Default | Description | |------|---------|-------------| | `--exit/-e EXIT` | (required for add) | Exit server IP or name | | `--name NAME` | (auto) | Friendly name for the relay (e.g. "ru-moscow") | | `--port/-p PORT` | 443 | Listen port on the relay server | | `--sni HOST` | exit's target | Reality camouflage target for this relay; omit to use the exit's | | `--user/-u USER` | saved, else root | SSH user on the relay | | `--ssh-port PORT` | saved, else 22 | SSH port on the relay server (`relay add`) | | `--yes/-y` | | Skip confirmation prompts | | `--json` | | Emit `relay list` (`data.relays[]`) as a `meridian.output/v1` envelope | **`relay add`** — adds a single-hop relay to your topology and applies it. Clients receive it on their next subscription refresh. For chains with more than one hop, use `meridian setup`. **`relay remove`** — `RELAY` is a relay server's IP or saved name, or a relay chain ID. It drops every chain through that server (narrow it with `--exit`), lists what will be deleted (Realm units, firewall rules, the exit-side listener, published Hosts), confirms, and applies the reviewed removal. The servers stay in your saved servers. **`relay sni`** — gives the relay chains through a server (or one chain by ID; narrow it with `--exit`) a new camouflage SNI. It refuses the same names as `node sni`, checks that the exit reaches the site (a Reality listener on the exit serves the relay's own name), reviews, confirms, and applies. Passing the exit's own SNI makes the relay follow the exit again. Apps switch at their next subscription refresh. To check a relay's hops, run `meridian check RELAY`. ### meridian route Send chosen destinations from an exit through Cloudflare WARP, directly, or nowhere, ahead of the exit's default egress. ``` meridian route add EXIT VALUE... --via warp|direct|block [--name ID] [--json] meridian route list [EXIT] [--json] meridian route remove EXIT ROUTE [--yes] [--json] ``` | Flag | Default | Description | |------|---------|-------------| | `--via ACTION` | (required for add) | `warp`, `direct`, or `block` | | `--name ID` | the action, numbered | Route ID shown by `route list` and used by `route remove` | | `--yes/-y` | | Skip the removal confirmation (required with `--json`) | | `--json` | | Emit a `meridian.output/v1` envelope (`data.override` for add/remove, `data.exits[]` for list) | `EXIT` is an exit ID, saved server name, or IP. Each `VALUE` is a domain (subdomains included), `geosite:NAME`, or `geoip:NAME`; one route holds one kind. **`route add`** — adds the route to the exit in your topology and applies it. Routes match in the order they were added. A `warp` route on an exit without WARP sets WARP up for that route only; other traffic stays direct. **`route list`** — shows each exit's default egress and its routes in match order. **`route remove`** — removes one route and applies the change; its destinations follow the exit's default egress again. WARP stays installed on the server but carries nothing until a route or `--warp` uses it again. ### meridian fleet Fleet-wide health and inventory from the live panel, and Remnawave upgrades. ``` meridian fleet status [--json] meridian fleet inventory [--json] meridian fleet upgrade [--tested] [--yes] ``` | Flag | Default | Description | |------|---------|-------------| | `--json` | | Emit the result as a `meridian.output/v1` envelope (`status`, `inventory`) | | `--tested` | | `fleet upgrade`: ask neither Docker Hub nor GitHub; Remnawave returns to the versions this Meridian release was tested with, and a Realm you already upgraded stays | | `--yes`, `-y` | | `fleet upgrade`: skip the confirmation | **`fleet status`** — panel health, node heartbeats, relay listeners, and client counts. Missing declared clients degrade health; disabled, limited, or expired clients are counted but are your choice, not a fault. A reachable relay listener only proves TCP, so unverified relay routes keep health `unknown`; use `meridian test` to check the full path. Exit `0` means healthy, `4` degraded, and `3` unknown. **`fleet inventory`** — your configured servers, roles, nodes, and relays, with live panel status when reachable. It never prints the panel API token or secret paths. If live status is unavailable, the configured inventory is still shown, with warnings, and the command exits `3`. Use `plan --json` to decide whether apply has work to do. **`fleet upgrade`** — moves Remnawave to its newest release without waiting for a Meridian update. It asks Docker Hub for the newest backend, node, and subscription page releases within the major this Meridian release supports (pre-releases and other majors are skipped), pins each by its multi-arch digest, and saves the choice in `cluster.yml` so later applies keep it. A newer pin in a later Meridian release takes over again. `--tested` sticks to the versions this release was tested with. PostgreSQL and Valkey always stay on Meridian's pins. With relays in the fleet it does the same for Realm: GitHub's newest release in the tested major, recorded with the sha256 GitHub publishes for each archive, which every relay checks before installing. It then reads the backend, subscription page, PostgreSQL, Valkey, and node images each server runs (read-only `docker inspect` over SSH) and shows each one as `current`, `upgrade`, `install`, or kept. The version policy is the one setup uses: a newer version tag or a floating tag (`latest`, `3`) you chose is kept and reported, never downgraded. A PostgreSQL major is never moved in place — the old major keeps running and the command explains the dump-and-restore it needs. After you confirm, it applies the reviewed plan and runs `meridian check` on every server. A server whose images cannot be read stops the command with exit `3` before anything changes; if the apply converges but a server still runs an old image, it exits `3` too. ### meridian check Check a server's health, or whether a new server can run Meridian. What runs depends on the server's role in your fleet: - **Not in the fleet yet** — pre-flight checks: camouflage target reachability and DNS, port 443 availability and outside reachability, domain DNS, OS, disk, and clock. Nothing is installed; a short-lived listener tests the firewall when port 443 is free. DNS uses the server's own resolver. - **Control, exit, or routing gateway** — panel connection, SSH (or local access), containers (including the subscription page's health), every compiled public and internal port, TLS certificates, and disk space. Run from another machine, the control server also gets a data-flow check: a ~50 KB download that warns when it freezes after about 16–24 KB (throttling, measured from where you are). - **Relay** — every Realm hop on the server: the service is active and can reach its next hop, and the chain's public entry accepts TCP from your machine. With no server, every server in the fleet is checked. ``` meridian check [SERVER] [--domain NAME] [--sni HOST] [--user USER] [--ssh-port PORT] [--ai] [--json] ``` `--domain` and `--sni` apply to servers not deployed yet: they check that the domain resolves to the server and that a specific camouflage target is reachable. `--user` and `--ssh-port` override the saved SSH user and port. `--ai` copies an AI-ready diagnostic prompt to the clipboard. `--json` emits one `meridian.output/v1` envelope with a report per server and scope. Missing evidence is never treated as success. Exit `0` means every check passed, `4` means problems were found, `3` means required evidence was unavailable, and `2` means invalid input. Each failure prints its fix. Use `meridian test` to push real traffic through the whole route. ### meridian scan Find camouflage targets on the server's own network using a pinned RealiTLScanner release. Meridian verifies its SHA-256 checksum before running it and removes the remote workspace afterwards. ``` meridian scan [SERVER] [--user USER] [--ssh-port PORT] ``` RealiTLScanner scans IPv4 ranges; an IPv6-only server exits `3`. Picking a result prints the command to use it — it doesn't change a deployed topology. Skipping exits `0`, an invalid choice exits `2`, no suitable targets exits `4`, and scanner or download failures exit `3`. ### meridian test Test delivered proxy traffic from your device. No SSH is needed. A full test picks an active client, fetches the exact Xray JSON that Remnawave delivers to them, and pushes real traffic through the automatic fallback and through every path of the target server. The first run may spend up to 120 seconds downloading and verifying the pinned Xray runtime. Traffic checks rotate independent IP observers and compare them with a direct request, so an observer outage is inconclusive rather than a false failure. `MERIDIAN_CONNECT_TEST_URL` overrides the observer for controlled environments. Each path reports its time to first byte. When a path fails, Meridian opens a plain TCP connection to its endpoint to say whether it **connects, but no traffic passes** (the server answers but the proxy session carries nothing: wrong credentials or a session being cut) or is **unreachable** (nothing answers on that port). With `--json`, `data.transports` lists every path's outcome (`ok`, `no_traffic`, `unreachable`, `failed`, `egress_mismatch`, `inconclusive`, `invalid`) and `first_byte_ms`. A panel outage, HTTP `429`, or HTTP `5xx` makes the subscription unavailable, so the run exits `3`. Any other subscription rejection such as `400` or `422` is a completed failure and exits `4`. ``` meridian test [SERVER|DOMAIN] [--domain NAME] [--sni NAME] [--client NAME] [--basic] [--probe] [--timeout SECONDS] [--json] ``` Without a target, `test` tests the saved server it runs on, or your only saved server. With several, it tests every public entry of your fleet in turn (each exit, then each relay's entry) and exits with the worst result; `--json` then returns one result per server in `data.results`. `--client` tests a specific active client. `--basic` runs only network and TLS checks, without downloading Xray, and cannot verify UDP-only paths; it cannot be combined with `--client` (exit `2`). `--timeout` (default `5`, range `1`–`30`) limits each network operation, separate from the first-run Xray download. Exit `0` means every check passed, `4` means the test found problems, and `3` means required evidence was unavailable. `--json` emits a `meridian.output/v1` envelope with every check and finding. #### Censor's view: `meridian test --probe` `--probe` looks at a server the way an active prober would, to check whether the deployment gives itself away, instead of sending traffic. It works on any IP or domain and applies stricter checks when the target belongs to your saved topology. Checks follow the real deployment: public and internal port exposure, HTTP/TLS behavior, SNI consistency and camouflage, common proxy and panel paths, WebSocket upgrades, reverse DNS, HTTP/2, legacy TLS versions, and configured domains. `--sni` overrides the SNI for generic TLS probes. UDP-only listeners are reported as needing a full `meridian test` instead of being guessed. `--probe` also answers "what is blocked?". It opens TLS to the server with its camouflage SNI; if that fails, it retries without the SNI and opens the real camouflage site directly, then reports whether the **server IP** or the **camouflage SNI** looks blocked. If the real site can't be reached either, the result is inconclusive rather than a finding. On the control server it also downloads the connection page's ~50 KB script and watches for a transfer that freezes after about 16–24 KB, which is how throttled foreign IPs behave. When a WSS path goes through a CDN, the same download runs through the CDN. These results only mean something when you run them from inside the censored network. `--probe` cannot be combined with `--basic`, `--client`, or `--domain`. Exit codes are the same as `test`; skipped checks are never reported as passes. `meridian probe [SERVER|DOMAIN] [--sni NAME] [--timeout SECONDS] [--json]` is the same as `meridian test IP --probe`. ### meridian doctor Collect system diagnostics for bug reports. ``` meridian doctor [SERVER] [--sni HOST] [--user USER] [--ssh-port PORT] [--ai] ``` `--sni` picks the camouflage target to diagnose, `--user` and `--ssh-port` override the saved SSH user and port, and `--ai` copies a redacted, AI-ready report to the clipboard instead of printing the issue template. Doctor follows the server's roles: a relay-only server isn't diagnosed as an exit, and routing gateways are labeled separately. Missing tools or a lost SSH connection make the report inconclusive (exit `3`); sections already collected are still shown. ### meridian logs Show a server's recent logs, redacted, without logging in to it. ``` meridian logs [SERVER] [--lines N] [--grep TEXT] [--component NAME] [--user USER] [--ssh-port PORT] ``` | Flag | Default | Description | |------|---------|-------------| | `--lines`, `-n N` | `100` | Lines to show from each log (`1`–`10000`) | | `--grep`, `-g TEXT` | | Only lines containing this text; case-insensitive, matched literally rather than as a regex | | `--component`, `-c NAME` | all | Only `node`, `panel`, `nginx`, or `relay`; repeat to pick several | `logs` reads what the server's roles run: exits and routing gateways show the Remnawave node container, the control server shows the panel containers (backend, database, Valkey, subscription page), both show the nginx error log, and relays show their Realm units' journal. `--user` and `--ssh-port` override the saved SSH user and port. Every line passes through the same redaction as `meridian doctor`: tokens, passwords, keys, UUIDs, subscription IDs, and the panel's and connection page's secret paths are replaced with `[REDACTED]`, so the output is safe to paste into a bug report. Lines print as plain text with terminal control characters removed. A server without a fleet role, or a `--component` it doesn't run, exits `2` before connecting. A log that can't be read (a missing container, a lost SSH connection) is shown as unavailable and the command exits `3`. ### meridian panel Show the Remnawave admin panel URL and admin username, and what Meridian manages there versus what is yours to change. ``` meridian panel [--show-password] ``` | Flag | Default | Description | |------|---------|-------------| | `--show-password` | | Also print the admin password | Edits to client status, traffic limits, expiry, and descriptions, and users you create in the panel, survive every apply. Edits to nodes, profiles, Hosts, squads, and templates Meridian created are reverted by the next apply; `meridian plan` shows them first. ### meridian teardown Remove Meridian from a server. ``` meridian teardown [SERVER] [--user USER] [--ssh-port PORT] [--yes] ``` On a single-server deployment, teardown removes everything Meridian created: the panel, node, nginx configuration, certificates, relays, and firewall rules on the server, then the local `cluster.yml` and saved server profile. Docker, system packages, SSH hardening, and fail2ban stay, and nginx keeps running if it still serves your own sites. It also removes 3x-ui from Meridian 3.x. In a multi-server fleet, teardown refuses a server that still has a role in the topology. `--yes` skips the confirmation; the role checks still run first. Declining exits `1`. An unknown server name, or zero or several possible targets, exits `2` instead of prompting. ### meridian update Update the CLI to the latest release of its major version on PyPI. A newer major version is only announced; `--major` moves to it after you have read the release notes. A beta build follows newer betas of its major version and then the stable release. Meridian tries uv, pipx, and pip, and only reports success when the active `meridian` command shows the new version. Exit `0` means already current or updated; `3` means the update or its verification failed. Updating the CLI does not change your servers. ``` meridian update [--major] ``` ### meridian studio Open Meridian Studio, an experimental browser UI, with its local Engine API. The server binds only to `127.0.0.1`. ``` meridian studio [--port PORT] [--assets-dir DIRECTORY] [--no-open] ``` | Flag | Default | Description | |------|---------|-------------| | `--port PORT` | 7391 | Localhost port from 0 to 65535; `0` picks any free port | | `--assets-dir DIRECTORY` | bundled assets | Use a built Studio asset directory instead of the packaged copy | | `--no-open` | | Print the local URL without opening a browser | Studio prints and opens an address like `http://127.0.0.1:7391/studio/#k=…`. The part after `#k=` is a key that changes on every launch; only a browser tab opened from that address can use the Engine, so other programs and other users on the same computer cannot. Keep it private. After restarting Studio, open the new address it prints. Without `--port`, Studio uses port 7391, or the next free port up to 7400, so the draft your browser saves for Studio is still there next time. An explicit `--port` that is taken fails clearly, as do missing assets. An explicit asset directory must contain `studio/index.html`. ### meridian api Inspect the machine-readable contracts behind JSON output, for scripts and UI clients. ``` meridian api schemas [--json] [--include-schemas] meridian api commands [--json] [--include-schemas] meridian api schema NAME [--envelope|--json] meridian api workflow NAME [--json] ``` | Flag | Default | Description | |------|---------|-------------| | `--json` | | Emit the catalog as a `meridian.output/v1` envelope | | `--include-schemas` | | Include full JSON Schemas (implies JSON output) for `api schemas` or `api commands` | | `--envelope`, `--json` | | Wrap `api schema NAME` in a `meridian.output/v1` envelope instead of printing the raw schema | **`api schemas`** — lists schema names such as `output-envelope`, `deploy-request`, `deploy-result`, `deploy-plan`, `plan-envelope`, `apply-envelope`, `client-list-envelope`, `fleet-status-envelope`, and `event`. **`api commands`** — lists command contracts: argv, envelope and data schemas, possible statuses and outcomes, exit-code meanings, every option the command accepts (`machine_flags`), and `interrupt_behavior`. Arguments, options, and descriptions are read from the CLI itself. Ctrl-C exits `130` without a JSON envelope (`interrupt_behavior: "exit_130_without_envelope"`). **`api schema NAME`** — prints one JSON Schema, e.g. `meridian api schema output-envelope`. **`api workflow NAME`** — prints a UI-renderable workflow, e.g. `meridian api workflow deploy --json`. ### meridian help Explain Meridian to an AI agent, or show one command's help. ``` meridian help [agents | COMMAND...] ``` **`meridian help agents`** prints a Markdown guide for AI agents: what Meridian does, how the fleet and its reviewed topology fit together, where state lives, the rules for driving the CLI safely (non-interactive flags, JSON envelopes, plan before apply, confirmation before removal, links as secrets), exit codes, and common tasks. It always prints plain Markdown, so an agent can read it from a pipe. **`meridian help COMMAND`** shows the same help as `meridian COMMAND --help`, e.g. `meridian help node add`. Without arguments it prints the top-level help. An unknown topic exits `2`. ## Global options Global options go before the command, for example `meridian --quiet doctor`. Command options such as `test --json` go after it. | Flag | Description | |------|-------------| | `--version`, `-v` | Show the CLI version and exit | | `--verbose` | Enable debug logging | | `--quiet`, `-q` | Suppress progress output | | `--lang CODE` | Language for the setup wizard: `en`, `ru`, `fa`, or `zh` (default: saved choice, else asked) | | `--json` | Request a JSON envelope from a command that supports it; other commands fail explicitly | | `--install-completion` | Install shell completion for the current shell | | `--show-completion` | Print the completion script for the current shell | JSON output uses the `meridian.output/v1` envelope (`schema`, `command`, `operation_id`, `status`, `summary`, `data`, `warnings`, `errors`). It is supported by every command that lists `--json`: deploy, plan, apply, check, test, probe, all client and route commands, fleet status/inventory, node/relay list, and the api commands. The global `--json` and a command's own `--json` are interchangeable. Secrets are redacted before JSON leaves the process. Run `meridian api commands --json` to discover the contracts. ## Common server options These recur across commands; each command accepts only the options listed in its own section. | Flag | Used by | |------|---------| | `SERVER` argument | deploy, check, scan, test, doctor, logs, teardown, node add, relay add, server add | | `--user`, `-u USER` | deploy, check, node add, relay add, server add, scan, doctor, logs, teardown | | `--ssh-port PORT` | deploy, check, node add, relay add, server add, scan, doctor, logs, teardown | | `--sni HOST` | deploy, check, node add, relay add, test, doctor | | `--domain DOMAIN` | deploy, check, node add, test | Client commands use the panel API directly and don't take a server. `test` runs from your device and doesn't use SSH. ## Server resolution Name the server first, the way `ssh` does: an IP, a saved name, `user@host:port`, or `local` to run on the current machine without SSH. `--user` and `--ssh-port` fill in what the target leaves out and must not contradict it. An empty user or port means the saved value, else `root` and `22`. With no server, Meridian uses local mode (when running on a deployed server) or the only saved server. Zero or several candidates exit `2` rather than prompting. --- # AI Agents > Let Claude Code, Codex, Cursor, or another AI agent run Meridian for you, safely. Meridian is built to be driven by AI agents as well as people. An agent can deploy servers, add people, read health checks, and explain failures, using the same commands you would. ## Point your agent at Meridian Ask the agent to run this before it does anything else: ```bash meridian help agents ``` It prints a short Markdown guide for agents: what Meridian does, how the fleet fits together, where its state lives, the rules for using the CLI safely, exit codes, and common tasks. The same guide is at [getmeridian.org/agents.md](https://getmeridian.org/agents.md). To make this permanent, add a line to your project's agent instructions (`AGENTS.md`, `CLAUDE.md`, or your tool's equivalent): ```markdown Our proxy servers are managed with Meridian. Run `meridian help agents` before using it. ``` ## What the CLI gives agents - **No prompts.** Commands that change something accept `--yes`. The `setup` wizard needs a terminal, so agents use `deploy`, `node add`, `relay add`, and `client add`, or `setup --intent FILE --yes`. - **JSON envelopes.** Commands with `--json` print one `meridian.output/v1` envelope on stdout, errors included. Progress and warnings go to stderr. - **Contracts.** `meridian api commands` lists every command with a machine-readable contract; `meridian api schema NAME` prints its JSON Schema. - **Meaningful exit codes.** `0` is success, `2` a user error, `3` missing evidence (SSH, panel, or local state), and `4` a completed check that found problems. See the [CLI reference](https://getmeridian.org/en/cli-reference.md) for each command. - **Review before change.** `meridian plan` shows what would change, and `meridian apply --yes` applies exactly that. ## What the docs give agents - **Every page as Markdown.** Add `.md` to a page's address, for example [/en/relay.md](https://getmeridian.org/en/relay.md). Each page also links its Markdown version in its ``. - **Copy page and Open in buttons.** Under each page title, copy the page as Markdown or open it in ChatGPT or Claude. - **llms.txt.** [/llms.txt](https://getmeridian.org/llms.txt) indexes the docs for language models, and [/llms-full.txt](https://getmeridian.org/llms-full.txt) holds all of them in one file. ## Keep secrets out of the chat Connection pages and subscription URLs give whoever holds them access to your servers. The agent guide tells agents to hand each link only to the person it belongs to and never to reveal the panel password unless you ask. Don't paste links into a chat you share with others. `meridian doctor` produces a redacted report that is safe to share. --- # Security > Security design, vulnerability reporting, and scope. ## Reporting vulnerabilities If you discover a security vulnerability in Meridian: 1. **Do NOT open a public issue** 2. Report it privately through [GitHub private vulnerability reporting](https://github.com/getmeridian/meridian/security/advisories/new) 3. Include steps to reproduce and potential impact We aim to respond within 48 hours and will credit reporters in the fix. ## Security design - **Credentials**: stored with `0600` permissions in `~/.config/meridian/cluster.yml`, secrets never passed through shell commands without `shlex.quote()`, redacted from `meridian doctor` output. Directory permissions are `0700` (owner-only). Any application running as your user can read these files — this is inherent to all CLI tools that store credentials locally (SSH keys, cloud CLI tokens, etc.). If your local machine is compromised at the user level, credential encryption would not help: malware can keylog the passphrase or intercept SSH sessions. Protect your local machine. - **Panel access**: the Remnawave admin UI is reverse-proxied by nginx at a secret HTTPS path — no SSH tunnel required. Connection pages and subscription links use a separate path and never reveal the panel's. The URL and admin credentials are in the `panel` section of `~/.config/meridian/cluster.yml` - **SSH**: password authentication disabled and fail2ban enabled by default (`--no-harden` skips both, plus the firewall, for servers shared with other services) - **Firewall**: UFW denies inbound traffic except the server's SSH ports and the ports its role needs — TCP/443 for proxy traffic, TCP/80 where certificates are issued, UDP/443 only when Hysteria2 is enabled, and the listen port on relays - **Pinned software**: Remnawave images are pinned to tested versions; Realm, Xray (for `meridian test`), and RealiTLScanner downloads are version-pinned and checksum-verified - **TLS**: [acme.sh](https://github.com/acmesh-official/acme.sh) handles certificates via Let's Encrypt, served by nginx ## Scope Meridian configures proxy servers — it does **not** implement cryptographic protocols. The underlying security depends on: - [Xray-core](https://github.com/XTLS/Xray-core) — VLESS+Reality protocol - [Remnawave](https://remna.st/) — panel + node management stack - [nginx](https://nginx.org/) — SNI routing and TLS termination --- # Contributing > Development setup, PR guidelines, and testing approach. ## Reporting issues - **Bug?** Use the [bug report template](https://github.com/getmeridian/meridian/issues/new?template=bug_report.yml) — run `meridian doctor` first - **Connection issue?** Use the [connection issue template](https://github.com/getmeridian/meridian/issues/new?template=connection_issue.yml) — run `meridian test` and `meridian check` first - **Feature idea?** Use the [feature request template](https://github.com/getmeridian/meridian/issues/new?template=feature_request.yml) - **Security vulnerability?** See [Security](https://getmeridian.org/en/security.md) — do NOT open a public issue ## Development setup ```bash git clone https://github.com/getmeridian/meridian.git && cd meridian # Install CLI in editable mode with dev dependencies make install # Install the pre-push hook (fast checks before every push) make hooks # Run full CI locally make ci # Individual checks: make test # pytest make lint # ruff check make format-check # ruff format --check make typecheck # mypy make templates # Jinja2 template validation ``` ## Project structure The CLI is a Python package (`src/meridian/`) distributed via PyPI as `meridian-vpn`. Key modules: - `cli.py` — Typer app, subcommand registration - `commands/` — one module per subcommand - `cluster.py` + `cluster_persistence.py` — typed `cluster.yml` state and persistence - `servers.py` — `ServerRegistry` for known servers - `setup/` — `meridian setup` wizard state and the shared runtime behind deploy, setup, and apply - `compiler/` — turns the topology into a reviewed, hash-identified list of resources (no I/O) - `reconciler/` — observes and applies those resources with checkpoints - `provision/` — server preparation steps (packages, hardening, Docker, Realm) ## Pull requests 1. Fork the repo and create a branch from `main` 2. Make focused, minimal changes 3. Ensure CI passes: `make ci` 4. Test on a real server if possible 5. Open a PR with a clear description ## Key conventions - **Shell values use `shlex.quote()`** — never interpolate unsanitized values - **PWA assets must stay in sync** (`templates/pwa/` and the application catalog) - **nginx config** is written per resource as `meridian-v4-*.conf` files under `/etc/nginx/stream.d/` and `/etc/nginx/sites-available/` - **Provisioner steps** return `StepResult` (ok/changed/skipped/failed) ## Testing CI validates: Python tests, ruff lint, mypy types, template rendering, and shell script syntax. Full deployment testing requires a real VPS.