Architecture

Service topology

Standalone mode (no domain)

Internet


┌──────────────────────────────────────┐
│ Server                               │
│                                      │
│  Port 443: HAProxy (SNI router)      │
│  ┌──────────────────────────────┐    │
│  │ SNI = reality_sni            │    │
│  │  → Port 10443: Xray (Reality)│    │
│  │                              │    │
│  │ SNI = server IP              │    │
│  │  → Port 8443: Caddy (TLS)   │    │
│  │     ├─ /info-path → page    │    │
│  │     ├─ /panel-path → 3x-ui  │    │
│  │     └─ /xhttp-path → Xray   │    │
│  └──────────────────────────────┘    │
│                                      │
│  Port 80: Caddy (ACME challenges)    │
│                                      │
│  Docker: 3x-ui                       │
│  ├─ Reality inbound (port 10443)     │
│  └─ XHTTP inbound (localhost port)   │
│                                      │
│  Caddy: IP cert (ACME shortlived)    │
│  HAProxy: TCP SNI, no TLS terminate  │
└──────────────────────────────────────┘

HAProxy does not terminate TLS. It reads the SNI hostname from the TLS Client Hello and forwards the raw TCP stream to the appropriate backend.

Caddy requests a Let’s Encrypt IP certificate via the ACME shortlived profile (6-day validity, auto-renewed). Falls back to self-signed if IP cert issuance is not supported.

XHTTP runs on a localhost-only port and is reverse-proxied by Caddy — no extra external port exposed.

Domain mode

Internet


┌──────────────────────────────────────┐
│ Server                               │
│                                      │
│  Port 443: HAProxy (SNI router)      │
│  ┌──────────────────────────────┐    │
│  │ SNI = reality_sni            │    │
│  │  → Port 10443: Xray (Reality)│    │
│  │                              │    │
│  │ SNI = domain                 │    │
│  │  → Port 8443: Caddy (TLS)   │    │
│  │     ├─ /info-path → page    │    │
│  │     ├─ /panel-path → 3x-ui  │    │
│  │     ├─ /xhttp-path → Xray   │    │
│  │     └─ /ws-path → Xray WSS  │    │
│  └──────────────────────────────┘    │
│                                      │
│  Docker: 3x-ui                       │
│  ├─ Reality inbound (port 10443)     │
│  ├─ XHTTP inbound (localhost port)   │
│  └─ WSS inbound (localhost port)     │
│                                      │
│  Caddy: domain cert (Let's Encrypt)  │
│  HAProxy: TCP SNI, no TLS terminate  │
└──────────────────────────────────────┘

Domain mode adds VLESS+WSS as a CDN fallback path. Traffic flows through Cloudflare’s CDN via WebSocket, making the connection work even if the server’s IP is blocked.

How Reality protocol works

  1. Server generates an x25519 keypair. Public key is shared with clients, private key stays on server.
  2. Client connects on port 443 with a TLS Client Hello containing the camouflage domain (e.g., www.microsoft.com) as SNI.
  3. To any observer, this looks like a normal HTTPS connection to microsoft.com.
  4. If a prober sends their own Client Hello, the server proxies the connection to the real microsoft.com — the prober sees a valid certificate.
  5. If the client includes valid authentication (derived from the x25519 key), the server establishes the VLESS tunnel.
  6. uTLS makes the Client Hello byte-for-byte identical to Chrome’s, defeating TLS fingerprinting.

Port assignments

PortServiceMode
443HAProxy (SNI router)All
80Caddy (ACME challenges)All
10443Xray Reality (internal)All
8443Caddy TLS (internal)All
localhostXray XHTTPWhen XHTTP enabled
localhostXray WSSDomain mode
20533x-ui panel (internal)All

XHTTP and WSS ports are localhost-only — Caddy reverse-proxies to them on port 443.

Provisioning pipeline

#StepPurpose
1InstallPackagesOS packages
2EnableAutoUpgradesUnattended upgrades
3SetTimezoneUTC
4HardenSSHKey-only auth
5ConfigureBBRTCP congestion control
6ConfigureFirewallUFW: 22 + 80 + 443
7InstallDockerDocker CE
8Deploy3xui3x-ui container
9ConfigurePanelPanel credentials
10LoginToPanelAPI auth
11CreateRealityInboundVLESS+Reality
12CreateXHTTPInboundVLESS+XHTTP
13CreateWSSInboundVLESS+WSS (domain)
14VerifyXrayHealth check
15InstallHAProxySNI routing
16InstallCaddyTLS + reverse proxy
17DeployConnectionPageQR codes + page

Credential lifecycle

  1. Generate: random credentials (panel password, x25519 keys, client UUID)
  2. Save locally: ~/.meridian/credentials/<IP>/proxy.yml — saved BEFORE applying to server
  3. Apply: panel password changed, inbounds created
  4. Sync: credentials copied to /etc/meridian/proxy.yml on server
  5. Re-runs: loaded from cache, not regenerated (idempotent)
  6. Cross-machine: meridian server add IP fetches from server via SSH
  7. Uninstall: deleted from both server and local machine
On this page