Deploy and manage 3X-UI on a root-managed Ubuntu or Debian VPS using Docker Compose, nginx, ACME certificates, SSH panel tunneling, UFW hardening, and Xray V...
---
name: 3x-ui-vps
description: Deploy and manage 3X-UI on a root-managed Ubuntu or Debian VPS using Docker Compose, nginx, ACME certificates, SSH panel tunneling, UFW hardening, and Xray VLESS over XHTTP behind nginx. Use when the user explicitly wants to install 3X-UI from scratch, lock the panel and subscription server to 127.0.0.1, open an SSH tunnel to the panel, create or repair a VLESS inbound behind nginx on public 443, add extra clients to an existing inbound, or run safe OS and container updates.
disable-model-invocation: true
metadata:
openclaw:
requires:
bins:
- bash
- python3
- ssh
---
# 3X-UI VPS
Deploy 3X-UI on a VPS with the panel and subscription server bound to loopback, `ufw` allowing only SSH/HTTP/HTTPS, nginx on public `80/443`, and one `VLESS + XHTTP` transport routed through nginx.
This skill is manual-first because it mutates remote infrastructure. Invoke it only when the user explicitly asks to deploy, repair, harden, or update a VPS.
## Hard rule
- All server-side configuration must be executed only through the bundled scripts in this skill.
- Do not create or edit remote configs manually over SSH, do not run ad-hoc heredocs on the server, and do not "quick-fix" nginx, Docker, ACME, or 3X-UI by hand.
- Read-only inspection commands are allowed for diagnosis.
- If a script fails because the host is in an unexpected state, stop changing the server, patch the relevant script locally in this skill, and rerun the script.
- The manual fallback in this skill is for the 3X-UI panel UI only. It is not permission to mutate server files manually.
## Inputs
Collect these before doing any work:
- `ssh target` for the VPS, preferably `root@host`
- optional plain-text SSH password if the host is password-auth only
- public domain pointed at the VPS
- optional ACME email
- panel admin username and password
- optional local tunnel port
Assume Ubuntu or Debian with `apt`. Do not use this skill on other distributions without adapting the Docker repository setup first.
The operator workstation and the target VPS also need outbound internet access for Docker downloads, ACME issuance, and panel API calls.
## Preflight
Before changing the host, confirm these assumptions:
- the domain already resolves to the VPS from the server side, not only from the operator workstation
- the operator knows whether SSH access is key-based or password-based
- if the host already has a certificate for the same domain, reruns must reuse it instead of treating `acme.sh` "Domains not changed" as a hard failure
- if panel tunneling starts failing with immediate SSH disconnects after several quick attempts, stop parallel retries, wait briefly, and retry one connection at a time
## Workflow
### 1. Fresh deploy
Use [`scripts/bootstrap-host.sh`](scripts/bootstrap-host.sh).
Example:
```bash
./scripts/bootstrap-host.sh \
--host root@example-vps \
--ssh-password 'host-password' \
--domain vpn.example.com \
--panel-username admin \
--panel-password 'panel-secret'
```
Default shape:
- 3X-UI runs via Docker Compose under `/opt/3x-ui`
- Docker is installed on Ubuntu and Debian hosts through `get.docker.com`
- container keeps `network_mode: host`
- data lives under `3x-ui-data/db/`
- certificates live under `3x-ui-data/cert/`
- panel must bind to `127.0.0.1:<panel_port>`
- subscription server must bind to `127.0.0.1:2096`
- panel admin username and password must be applied during deploy
- the script must resolve the public domain on the server itself with `dig` before ACME issuance
- nginx terminates TLS on public `443`
- nginx returns `401` for unmatched traffic
- nginx proxies only the configured Xray secret path to the local backend port with `grpc_pass`
- `ufw` must allow only SSH, HTTP, and HTTPS from the internet
After deploy, verify:
```bash
ssh <target> 'ss -ltnp | egrep ":2053 |:2096 |:1234 "'
ssh <target> 'docker compose -f /opt/3x-ui/docker-compose.yml ps'
ssh <target> 'curl -I http://127.0.0.1:2053/'
ssh <target> 'ufw status numbered'
```
Read [`references/architecture.md`](references/architecture.md) if you need the full topology or nginx routing rationale.
### 2. Panel access
Use [`scripts/open-panel-tunnel.sh`](scripts/open-panel-tunnel.sh) and keep the panel SSH-only.
Default tunnel:
```bash
./scripts/open-panel-tunnel.sh --host root@example-vps --ssh-password 'host-password' --local-port 12053 --panel-port 2053
```
Then open `http://127.0.0.1:12053`.
If the tunnel fails with an immediate SSH disconnect, avoid parallel SSH sessions to the same host for a short period and retry the tunnel as a single connection after a brief pause.
Do not publish the panel in nginx. If the operator later wants a public panel, treat that as a separate hardening decision.
### 3. Quick inbound bootstrap
Use [`scripts/bootstrap-inbound.py`](scripts/bootstrap-inbound.py) against the tunneled panel URL.
Important detail:
- public client transport is `TLS` because nginx terminates TLS on `443`
- backend Xray inbound behind nginx stays plain `XHTTP` on loopback
- the inbound path must match the nginx secret path
Preferred flow:
```bash
python3 scripts/bootstrap-inbound.py \
--panel-url http://127.0.0.1:12053 \
--username admin \
--password 'secret' \
--public-domain vpn.example.com \
--backend-port 1234 \
--path /xhttp-keep-this-secret
```
The script prefers API automation but always prints a manual fallback checklist. Use [`references/manual-bootstrap.md`](references/manual-bootstrap.md) if API endpoints drift or the panel UI has changed.
That fallback is UI-only. If server-side behavior needs to change, update the bundled scripts first and rerun them.
### 4. Add another client to an existing inbound
Use [`scripts/add-inbound-client.py`](scripts/add-inbound-client.py) against the tunneled panel URL.
This workflow is for adding one more client to an already working inbound without changing nginx, ports, or the existing secret path.
Preferred flow:
```bash
python3 scripts/add-inbound-client.py \
--panel-url http://127.0.0.1:12053 \
--username admin \
--password 'secret' \
--inbound-id 1
```
Behavior:
- the script logs in to 3X-UI through the panel tunnel
- loads the existing inbound
- appends one more VLESS client to `settings.clients`
- keeps the existing public domain and XHTTP path from that inbound
- updates the same inbound instead of creating a second parallel inbound
- prints a ready-to-import `vless://` client URL
If `--inbound-id` is omitted, the script may auto-select the inbound only when the panel has exactly one inbound. Otherwise require the operator to pass the inbound ID explicitly.
### 5. Updates
Use [`scripts/update-stack.sh`](scripts/update-stack.sh).
The update workflow must stay conservative:
```bash
./scripts/update-stack.sh --host root@example-vps --ssh-password 'host-password'
```
This runs:
- `apt update`
- `apt upgrade`
- `docker compose pull`
- `docker compose up -d`
- reapply panel loopback bind
- reapply subscription loopback bind on `2096`
- reapply `ufw` rules for SSH, HTTP, and HTTPS only
Do not switch this skill to `apt full-upgrade` unless the user explicitly asks for it.
## Fast troubleshooting
Use these checks before assuming the deploy is broken:
- `ssh <target> 'ss -ltnp | egrep ":2053 |:2096 |:1234 |:443 |:80 "'`
- `ssh <target> 'docker compose -f /opt/3x-ui/docker-compose.yml ps'`
- `ssh <target> 'curl -I http://127.0.0.1:2053/'`
- `ssh <target> 'cat /opt/3x-ui/bootstrap.env'`
- local tunnel check: `lsof -nP -iTCP:12053 -sTCP:LISTEN`
Interpretation:
- `127.0.0.1:2053` and `127.0.0.1:2096` mean panel and sub server are correctly isolated
- `127.0.0.1:1234` means the Xray backend inbound exists
- public `0.0.0.0:80` and `0.0.0.0:443` should belong to nginx
- `curl -I http://127.0.0.1:2053/` returning `404` is acceptable and proves the panel is responding
- `https://<domain>/` returning `401` is the expected nginx default for unmatched traffic
## Decision rules
- Prefer the bundled scripts over retyping long shell sessions.
- Treat the bundled scripts as the only writable interface to the server state.
- If deployment or update fails, fix the script or add a new script in this skill. Do not repair the server manually.
- If the operator provides a plain-text SSH password, pass it through to the bundled script with `--ssh-password` instead of wrapping SSH manually.
- Keep Docker installation simple and consistent by using `curl -fsSL https://get.docker.com -o get-docker.sh` followed by `sh ./get-docker.sh`.
- Resolve the public domain from the server itself with `dig`, not from the operator workstation, before relying on DNS results.
- Keep the panel on `127.0.0.1`; verify with `ss -ltnp`.
- Keep the subscription server on `127.0.0.1:2096`; verify with `ss -ltnp`.
- Keep nginx responsible for public `80/443`.
- Keep the Xray backend on a separate loopback port such as `127.0.0.1:1234`.
- Reuse the exact same secret path in nginx and the inbound config.
- When the operator asks for another client, prefer adding it to the existing inbound instead of creating a second inbound with duplicate transport settings.
- Keep `nginx` default responses normal HTTP `401`, not `444`, so browsers receive a valid error page.
- Keep `ufw` active and restricted to SSH, HTTP, and HTTPS ingress only.
- On current 3X-UI images, prefer `/app/x-ui setting ...`; the `x-ui` wrapper may not apply panel settings correctly inside the container.
- If this 3X-UI version exposes an extra subscription listener, set `subListen=127.0.0.1` and verify that `2096` is not public.
- If 3X-UI API calls fail, stop guessing and use the manual fallback.
## Script inventory
- [`scripts/bootstrap-host.sh`](scripts/bootstrap-host.sh): install host packages, Docker, nginx, ACME, Compose stack, and nginx config
- [`scripts/ssh-with-password.sh`](scripts/ssh-with-password.sh): wrapper for `ssh` with optional plain-text password support
- [`scripts/open-panel-tunnel.sh`](scripts/open-panel-tunnel.sh): open an SSH local port forward to the loopback-bound panel
- [`scripts/bootstrap-inbound.py`](scripts/bootstrap-inbound.py): log in to 3X-UI and create one VLESS client plus inbound
- [`scripts/add-inbound-client.py`](scripts/add-inbound-client.py): log in to 3X-UI, load an existing inbound, append one more client, and print the new `vless://` URL
- [`scripts/update-stack.sh`](scripts/update-stack.sh): run safe package and container updates remotely
## References
- [`references/architecture.md`](references/architecture.md): deploy topology and nginx behavior
- [`references/manual-bootstrap.md`](references/manual-bootstrap.md): panel UI fallback steps and field mapping
don't have the plugin yet? install it then click "run inline in claude" again.
added explicit preflight checks, detailed inputs with external connection setup, decision points for SSH auth, ACME reuse, tunnel failures and API errors, output contract specifying exact listener state and config locations, and outcome signals covering all six workflows.
Deploy a hardened 3X-UI proxy server on a VPS with the control panel and subscription server isolated to loopback, nginx terminating TLS on public ports 80/443, Xray VLESS running over XHTTP behind nginx, and UFW blocking all inbound except SSH/HTTP/HTTPS. Use this skill when the user explicitly asks to deploy 3X-UI from scratch, repair an existing deployment, lock down the panel and subscription server, tunnel the panel over SSH for safe access, bootstrap a new VLESS inbound, add clients to an existing inbound, or run OS and container updates. Do not use this skill for ad-hoc config edits or manual server mutations outside the bundled scripts.
Collect these before running any workflow:
root@hostname or equivalent, with SSH key or password auth confirmedapt is available and the host has outbound internet access for Docker downloads, ACME issuance, and 3X-UI API calls.External connections:
curl -fsSL https://get.docker.com -o get-docker.sh and sh ./get-docker.sh on the target host.--acme-email for renewal notifications.root@target. Scripts wrap SSH with optional password auth via --ssh-password parameter.Operator workstation prerequisites:
bash, python3, ssh client, dig commandBefore mutating the host, confirm these assumptions:
Log in to the VPS and check that the public domain resolves from the server itself:
ssh <target> 'dig +short <your-domain>'
If the domain does not resolve from the server, stop and correct DNS before proceeding.
Confirm SSH auth method (key or password):
ssh -v <target> 'echo ok' 2>&1 | grep -i 'auth\|permission'
If the VPS already has a certificate for the same domain, confirm that the bootstrap script will reuse it instead of failing on "Domains not changed" ACME errors.
If this is a repeat deployment, stop any existing 3X-UI containers:
ssh <target> 'docker compose -f /opt/3x-ui/docker-compose.yml down 2>/dev/null || true'
Inputs to step 1: SSH target, domain, optional SSH password.
Outputs from step 1: confirmation that DNS resolves, SSH works, and no conflicting containers exist.
Run the primary deployment script to install Docker, nginx, ACME, Docker Compose stack, and firewall rules:
./scripts/bootstrap-host.sh \
--host root@example-vps \
--ssh-password 'host-password' \
--domain vpn.example.com \
--panel-username admin \
--panel-password 'panel-secret' \
--acme-email 'admin@example.com'
This script:
get.docker.comcertbot for ACME/opt/3x-ui/ with Docker Compose configurationdocker compose up -d to start 3X-UI, with panel bound to 127.0.0.1:2053 and subscription server on 127.0.0.1:2096127.0.0.1:1234/opt/3x-ui/bootstrap.env for future referenceInputs to step 2: host, SSH password, domain, panel username, panel password, optional ACME email.
Outputs from step 2: Docker running, 3X-UI panel isolated to loopback, nginx on public 443, UFW active.
Run remote verification commands to confirm the stack is healthy:
ssh <target> 'ss -ltnp | egrep ":2053 |:2096 |:1234 "'
ssh <target> 'docker compose -f /opt/3x-ui/docker-compose.yml ps'
ssh <target> 'curl -I http://127.0.0.1:2053/'
ssh <target> 'ufw status numbered'
Expected output:
127.0.0.1:2053 and 127.0.0.1:2096 listening (panel and subscription server, loopback only)127.0.0.1:1234 listening (Xray backend inbound)0.0.0.0:80 and 0.0.0.0:443 listening (nginx, public)curl -I returns 404 or similar (panel is responding)ufw status numbered shows allow rules for 22, 80, 443Inputs to step 3: SSH target.
Outputs from step 3: confirmation that listeners are correct and no unexpected ports are open.
Keep the 3X-UI panel SSH-only. Use the tunnel script to forward the panel to your local machine:
./scripts/open-panel-tunnel.sh \
--host root@example-vps \
--ssh-password 'host-password' \
--local-port 12053 \
--panel-port 2053
Then open http://127.0.0.1:12053 in your browser to access the panel UI.
The script:
localhost:12053 to the remote 127.0.0.1:2053If the tunnel fails with an immediate SSH disconnect, stop other parallel SSH sessions to the same host, wait 10 seconds, and retry as a single tunnel.
Inputs to step 4: host, SSH password, local port, panel port.
Outputs from step 4: SSH tunnel running, panel accessible on http://127.0.0.1:12053.
Create one VLESS inbound over XHTTP behind nginx using the bootstrap script:
python3 scripts/bootstrap-inbound.py \
--panel-url http://127.0.0.1:12053 \
--username admin \
--password 'panel-secret' \
--public-domain vpn.example.com \
--backend-port 1234 \
--path /xhttp-secret-path
This script:
127.0.0.1:1234vless:// client URL with the public domain and pathThe client-facing transport is TLS (because nginx terminates TLS on 443). The backend inbound behind nginx stays plain XHTTP on loopback.
If API calls fail, the script prints a manual fallback checklist. See references/manual-bootstrap.md for UI-only steps. Do not manually edit server config files; update the bundled scripts and rerun instead.
Inputs to step 5: panel URL, admin username and password, public domain, backend port, secret path.
Outputs from step 5: one VLESS inbound created, one client URL printed and ready to use.
To add another client to an existing inbound without changing nginx or the path:
python3 scripts/add-inbound-client.py \
--panel-url http://127.0.0.1:12053 \
--username admin \
--password 'panel-secret' \
--inbound-id 1
This script:
vless:// client URLDo not create a second inbound with duplicate settings; always append to the existing one.
Inputs to step 6: panel URL, admin username and password, inbound ID (optional if only one inbound exists).
Outputs from step 6: one new client appended to the inbound, new client URL printed.
Keep the host and container stack current with conservative updates:
./scripts/update-stack.sh \
--host root@example-vps \
--ssh-password 'host-password'
This script:
apt update && apt upgrade (not full-upgrade)docker compose up -d to restart containers127.0.0.1:2053127.0.0.1:2096Do not switch to full-upgrade unless the user explicitly requests it. Reruns are safe.
Inputs to step 7: host, SSH password.
Outputs from step 7: updated OS, pulled container images, stack restarted, loopback binds and firewall rules reapplied.
If SSH uses key auth, not password: Omit the --ssh-password parameter. The script falls back to your local SSH agent or key in ~/.ssh/id_rsa.
If SSH auth is password-only: Pass the plain-text password via --ssh-password 'password'. This is inherently insecure; prefer key auth where possible.
If the VPS already has a certificate for the same domain: The bootstrap script detects this and reuses the certificate instead of failing on ACME "Domains not changed" errors. No action needed.
If bootstrap fails because the host is in an unexpected state (e.g., Docker already installed, nginx already running, stale 3X-UI containers): Stop, examine the error output, patch the relevant script locally, and rerun. Do not manually edit /opt/3x-ui/docker-compose.yml, nginx config, or 3X-UI settings over SSH.
If the panel tunnel fails with immediate SSH disconnects: Stop parallel SSH sessions to the same host, wait 10 seconds, and retry a single tunnel connection. If the problem persists, check whether the VPS is rate-limiting or the panel is hung (run ssh <target> 'docker compose -f /opt/3x-ui/docker-compose.yml ps' to verify the panel container is running).
If panel API calls fail during inbound bootstrap or client add: Stop guessing at panel API behavior. Print the manual fallback checklist from the script output and follow the UI-only steps in references/manual-bootstrap.md. Update the bundled scripts to match any recent 3X-UI API changes and rerun.
If you need a public panel (not recommended): Treat this as a separate hardening decision outside this skill. The scripts intentionally bind the panel to loopback to protect it from direct internet access.
If apt upgrade or docker compose up -d fails during update: The update-stack.sh script stops cleanly and reports the error. Inspect the host, fix the root cause (disk space, broken packages, container image unavailable), and rerun the update script.
If the operator provides neither SSH key auth nor password: The skill cannot proceed. Request either a private key file or confirmation of the plain-text password before invoking scripts.
Success is defined by the following state on the target VPS:
Docker Compose stack running under /opt/3x-ui/:
127.0.0.1:2053127.0.0.1:2096127.0.0.1:1234 (or the configured backend port)nginx listening on public 0.0.0.0:80 and 0.0.0.0:443:
ACME certificate issued and installed:
/opt/3x-ui/3x-ui-data/cert/UFW firewall active and correctly configured:
Bootstrap parameters persisted in /opt/3x-ui/bootstrap.env:
At least one VLESS inbound created in 3X-UI:
SSH panel tunnel established (when running open-panel-tunnel.sh):
127.0.0.1:12053 (or configured local port) to remote 127.0.0.1:2053The operator knows the skill worked when:
Fresh deploy: The bootstrap script completes without errors, and verification commands confirm nginx on public 443, panel on loopback 2053, Xray backend on loopback 1234, and UFW allowing only SSH/HTTP/HTTPS.
Panel access: The SSH tunnel opens without disconnecting, and http://127.0.0.1:12053 displays the 3X-UI login page. Log in with the provided admin credentials.
Inbound bootstrap: The bootstrap-inbound.py script prints a working vless:// client URL that clients can immediately import into their proxy software. The URL contains the public domain, the secret path, and XHTTP transport.
Add client: The add-inbound-client.py script prints a new vless:// client URL for the same inbound, with the same domain and path. The existing client URLs remain valid.
Updates: The update-stack.sh script completes without errors, and rerunning verification commands shows the same listeners active and UFW rules intact.
End-to-end test: A client connecting to the vless:// URL should establish a VLESS over XHTTP tunnel through nginx on the public 443 port, authenticate, and obtain IP connectivity through the proxy.
scripts/bootstrap-host.sh: install Docker, nginx, ACME, Docker Compose stack, panel loopback binding, subscription loopback binding, nginx TLS config, and UFW rulesscripts/ssh-with-password.sh: internal wrapper for ssh with optional plain-text password auth supportscripts/open-panel-tunnel.sh: establish an SSH local port forward to the loopback-bound panelscripts/bootstrap-inbound.py: log in to 3X-UI panel, create one VLESS inbound, enroll one client, print client URLscripts/add-inbound-client.py: log in to 3X-UI panel, load an existing inbound, append one VLESS client, print client URLscripts/update-stack.sh: run apt update && apt upgrade, pull latest container images, restart Docker Compose stack, reapply loopback binds and firewall rulesreferences/architecture.md: deploy topology, nginx routing behavior, XHTTP transport rationalereferences/manual-bootstrap.md: panel UI fallback steps and field mapping for inbound and client creationOriginal author: olegtsvetkov