This guide is the operator-focused command map for ovpn.
It complements the README quick start with practical flows for normal maintenance, user support, and connection diagnostics.
All examples assume the release binary is named ./ovpn.
For a system-installed binary, replace ./ovpn with ovpn.
- Local desired state lives under
~/.ovpnby default. server addchanges only local state.server initanddeployrender runtime files and apply them to the remote host over SSH/SCP.- User mutations (
add,rm,enable,disable, expiry, quota) apply to all enabled VPN servers by default. - Read/link/diagnostic commands are server-scoped because they inspect one deployed runtime.
- Remote mutating endpoints on
ovpn-agentrequire the deployed bearer token; the CLI reads it over SSH from the remote.envwithsudo -n. - Remote hosts are expected to allow the configured SSH user passwordless
sudoforovpnoperations. If sudo is unavailable, token reads fail and protected runtime calls surface as auth-token mismatches.
Global flags:
./ovpn --data-dir ~/.ovpn <command>
./ovpn --dry-run <command>
./ovpn --log-level debug <command>
./ovpn --debug <command>Use --dry-run for deploy/cleanup previews when available.
Use --debug only for operator-side CLI troubleshooting; targeted per-user debug is a different feature and does not enable global debug logging.
| Task | Command |
|---|---|
| Check CLI version | ./ovpn version |
| Register host | ./ovpn server add ... |
| First deploy | ./ovpn server init <server> |
| Normal deploy | ./ovpn deploy <server> |
| Full health check | ./ovpn doctor <server> |
| Include recent logs in doctor | ./ovpn doctor <server> --include-logs --tail 100 |
| Compose status | ./ovpn server status <server> |
| Service logs | ./ovpn server logs <server> --service xray --tail 200 |
| Restart runtime | ./ovpn restart <server> |
| Backup runtime | ./ovpn server backup <server> |
| Restore runtime | ./ovpn server restore <server> --remote-path /opt/ovpn-backups/<archive>.tgz |
| Cleanup runtime | ./ovpn server cleanup <server> --confirm CLEANUP |
Register a normal VPN host:
./ovpn server add \
--name <server> \
--host <server-ip-or-hostname> \
--domain <client-domain> \
--ssh-user root \
--ssh-port 22 \
--ssh-identity ~/.ssh/id_rsa \
--xray-version 26.3.27Useful server flags:
--role vpn|proxy: runtime role, defaultvpn--proxy-preset ru|cn: routing preset for proxy role--ssh-known-hosts <path>: known hosts file--ssh-strict-host-key=false: disable strict host key checking for temporary labs only--reality-server-name,--reality-target: REALITY camouflage target settings--reality-private-key,--reality-public-key,--reality-short-id: reuse known REALITY material instead of generating it
First deploy:
./ovpn server init <server>
./ovpn doctor <server>
./ovpn server status <server>Normal deploy after config/user/runtime changes:
./ovpn deploy <server>
./ovpn doctor <server>Inspect logs:
./ovpn server logs <server> --tail 200
./ovpn server logs <server> --service xray --tail 200
./ovpn server logs <server> --service ovpn-agent --tail 200
./ovpn server logs <server> --service ovpn-agent --tail 100 --followSupported service names include xray, ovpn-agent, prometheus, alertmanager, grafana, node-exporter, cadvisor, and ovpn-telegram-bot.
Back up before risky work:
./ovpn server backup <server>
ls -lah ~/.ovpn/backupsCleanup is destructive and requires an explicit token:
./ovpn --dry-run server cleanup <server>
./ovpn server cleanup <server> --confirm CLEANUPCleanup defaults are conservative:
- keeps remote backups
- removes ovpn Docker volumes
- includes monitoring cleanup if present
- disables local server metadata
- does not remove local metadata unless
--remove-localis set
Create a user on all enabled VPN servers:
./ovpn user add --username aliceCreate with expiry and metadata:
./ovpn user add \
--username alice \
--email alice@global \
--expiry 2026-12-31 \
--notes "family phone" \
--tags family,iosWhen --email is omitted, the stable identity is username@global.
The email field is used by Xray stats and diagnostics, so keep it stable across servers.
Show and list users on one server:
./ovpn user list --server <server>
./ovpn user show --server <server> --username aliceGenerate the client credential:
./ovpn user link --server <server> --username alice
./ovpn user link --server <server> --username alice --qr=false
./ovpn user link --server <server> --username alice --qr-file ~/Desktop/alice.png
./ovpn user link --server <server> --username alice --profile vless-reality-xhttp
./ovpn user qr --server <server> --username alice --profile vless-reality-xhttp --out ~/Desktop/alice-xhttp.png
./ovpn user link --server <server> --username alice --profile vless-xhttp-plain
./ovpn user export --server <server> --username alice --all-profiles --out ~/DownloadsThe vless:// link and QR code are secrets.
Anyone who has them can use that profile until you disable or remove the user.
Transport profiles are server-side opt-in. See docs/transports.md for the profile list, client compatibility notes, and rollout workflow.
Enable, disable, or remove:
./ovpn user disable --username alice
./ovpn user enable --username alice
./ovpn user rm --username aliceExpiry:
./ovpn user expiry-set --username alice --date 2026-12-31
./ovpn user expiry-clear --username aliceExpiry is date-only and remains valid through the end of that UTC day.
Quota:
./ovpn user quota-set --username alice --monthly-gb 300
./ovpn user quota-set --username alice --monthly-bytes 322122547200
./ovpn user quota-set --username alice --enabled=false
./ovpn user quota-reset --username alice--monthly-gb uses 1024^3 bytes per GB.
quota-reset clears a quota block and re-adds the user to runtime access.
Top traffic consumers:
./ovpn user top --server <server>
./ovpn user top --server <server> --limit 20When you add a new enabled VPN server, users are materialized from canonical local state during deploy. You should not need a separate sync command for normal multi-server operation.
List the profiles known to a server:
./ovpn server profile list <server>Enable an extra deployable profile and redeploy:
./ovpn server profile enable <server> vless-reality-xhttp
./ovpn server profile enable <server> vless-xhttp-plain
./ovpn deploy <server>
./ovpn doctor <server>Change the primary profile used by user link when --profile is omitted:
./ovpn server profile switch <server> vless-xhttp-plain
./ovpn deploy <server>Plain XHTTP is a fallback profile without Xray REALITY/TLS transport security. It can still be useful on degraded paths, but switch it to primary only deliberately. Already issued links are not changed by switch.
Disable a non-primary profile after users have moved away from it:
./ovpn server profile disable <server> vless-reality-tcp-vision
./ovpn deploy <server>
./ovpn doctor <server>The CLI refuses to disable the current primary profile. Switch the primary first, deploy, verify users have a working replacement link, and only then disable the old profile:
./ovpn server profile switch <server> vless-xhttp-plain
./ovpn deploy <server>
./ovpn doctor <server>
./ovpn server profile disable <server> vless-reality-tcp-vision
./ovpn deploy <server>
./ovpn doctor <server>Profile commands change local desired state. Old links keep working until the next successful deploy removes that profile's inbound from Xray.
user link, user qr, and user export --profile validate that the requested profile exists, is deployable, and is enabled on that server before printing or writing credentials.
If you request a disabled profile, the CLI prints the enable/deploy commands to run instead of emitting a broken link.
Use profile-specific links for A/B testing before switching the primary profile.
Show current remote totals from ovpn-agent:
./ovpn stats --server <server>Collect remote totals into local SQLite:
./ovpn stats sync --server <server>Show daily local traffic:
./ovpn stats user --server <server> --date 2026-06-06If a local SQLite command briefly fails with database is locked, rerun it after the other CLI command finishes.
Connection diagnostics answer support questions without enabling global debug logs:
- did the server see this user recently?
- are connections accepted or rejected?
- is traffic coming from one approximate network or many?
- are there IPv6 destination attempts?
- which destination ports are most common?
- during a short support window, what destinations did this one user attempt?
Diagnostics are enabled by default with OVPN_CONNECTION_DIAGNOSTICS=basic.
That is an intentional support tradeoff: the operator gets per-user connection metadata for troubleshooting, but no payloads, URLs, full source IPs, real UUIDs, or raw lines are stored in SQLite.
Disable before deploy if needed:
export OVPN_CONNECTION_DIAGNOSTICS=off
./ovpn deploy <server>Default storage model:
- raw Xray access log is scratch data under
/opt/ovpn/logs - raw access log is capped at
30 MiB - raw access log is not backed up
- SQLite stores aggregates by default
- targeted debug stores only events for active debug sessions
What is stored by default:
- user email and username mapping from local/runtime policy
- last seen timestamp
- accepted/rejected connection counts
- destination family counts (
domain,ipv4,ipv6,unknown) - top destination ports
- approximate source network buckets (
IPv4 /24,IPv6 /56) hashed in storage - IPv6 destination attempt counts
What is not stored by default:
- payloads
- URLs
- full source IPs
- real UUIDs in diagnostics tables
- raw access-log lines in SQLite
Approximate source networks are not exact devices. One phone moving between Wi-Fi and LTE may appear as multiple source networks, and several devices behind one NAT may appear as one source network. For exact accountability, issue separate users/profiles per person or device.
Use diagnose first:
./ovpn user diagnose --server <server> --username alice --since 24hJSON output is useful for scripts or attaching evidence to an issue:
./ovpn user diagnose --server <server> --username alice --since 24h --jsonRead the output as:
enabled/effective/expired/quota_blocked: local/runtime access statequota: rolling 30d usage versus limittraffic: 1h/6h/24h/30d usage from existing statslast_seen: whether the server has recently seen this useraccepted/rejected: whether Xray accepted connections for this userapprox_source_networks: rough signal for shared profiles or network churntop_ports: common destination ports, usually443for HTTPS-like trafficipv6_destinations: a hint when client apps prefer IPv6 destinations
If last_seen is empty and counts are zero, the server probably does not see the profile at all.
Check the selected profile, link/QR, domain, client network, and server reachability.
If accepted counts increase but one app does not work, the VPN profile is reaching the server. Move to targeted debug for that user and ask them to reproduce the app issue.
Start a short debug session:
./ovpn user debug start --server <server> --username alice --duration 15mList active sessions:
./ovpn user debug list --server <server>
./ovpn user debug list --server <server> --jsonAsk the user to reproduce the issue during the window, then inspect events:
./ovpn user debug show --server <server> --username alice --since 15m
./ovpn user debug show --server <server> --username alice --since 15m --jsonStop early when finished:
./ovpn user debug stop --server <server> --username aliceDuration defaults to 15m and is capped at 24h.
Use the shortest useful window.
Stopping a debug session removes that user's captured debug events immediately.
The aggregate counters remain available for normal diagnostics.
Targeted debug captures:
- timestamp
- result (
acceptedorrejected) - user email
- masked source network
- destination host/IP when present in the Xray access log
- destination port
- destination family
It does not enable global Xray debug logging and does not capture payloads.
Rendered Xray routing uses domainStrategy=AsIs.
This keeps domain destinations visible to routing and diagnostics instead of resolving every domain during route matching.
For normal vpn servers this mainly affects diagnostics readability.
For proxy servers it means:
- explicit
geositeand domain-suffix direct rules still match domain destinations geoip:*direct rules match IP-literal destinations- domain destinations not matched by the preset's domain rules fall through to the foreign pool
The freedom outbound prefers IPv4 for domain resolution. IPv6-literal destinations are not blocked by default because mobile clients may try IPv6 first and otherwise wait for timeout before IPv4 fallback.
When a user says "the VPN is connected but nothing opens":
./ovpn doctor <server>
./ovpn server logs <server> --service xray --tail 100
./ovpn user diagnose --server <server> --username <user> --since 24hIf the user is not seen:
- confirm the selected client profile
- generate a fresh QR/link if the profile may be wrong
- ask whether the issue happens on Wi-Fi, mobile network, or both
- check whether other users from the same region/operator are affected
If the user is seen and accepted:
./ovpn user debug start --server <server> --username <user> --duration 15m
./ovpn user debug list --server <server>
# ask the user to open the failing app for 2-3 minutes
./ovpn user debug show --server <server> --username <user> --since 15m
./ovpn user debug stop --server <server> --username <user>If many approximate source networks appear for one user, treat that as a shared-profile signal, not proof of exact device count. The operational fix is to create separate users/profiles and retire the shared profile after migration.
Start/stop the optional monitoring stack:
./ovpn server monitor up <server>
./ovpn server monitor status <server>
./ovpn server monitor down <server>Telegram setup:
OVPN_TELEGRAM_BOT_TOKEN=<token> \
./ovpn server monitor telegram-setup <server> \
--owner-user-id <owner-user-id>Useful Telegram setup flags:
--token <token>orOVPN_TELEGRAM_BOT_TOKEN--notify-chat-ids <ids>--owner-user-id <id>--monitor-up=false--test-notify=false
The Telegram bot is read-only by default.
Owner-only recovery actions require OVPN_TELEGRAM_ADMIN_TOKEN.
See monitoring.md for dashboards, alerts, and Telegram behavior.
Register a proxy:
./ovpn server add \
--name <proxy> \
--role proxy \
--proxy-preset ru \
--host <proxy-ip> \
--domain <proxy-domain> \
--ssh-user root \
--ssh-port 22Attach and inspect backends:
./ovpn server backend attach --proxy <proxy> --backend <vpn-server> --priority 100
./ovpn server backend list --proxy <proxy>
./ovpn server backend detach --proxy <proxy> --backend <vpn-server>After changing proxy backend attachments, deploy the affected backend and proxy:
./ovpn deploy <vpn-server>
./ovpn deploy <proxy>
./ovpn doctor <proxy>See ha.md for the full proxy model.
Render Xray JSON for inspection:
./ovpn config render --server <server>Validate rendered config locally and, when Docker is available, with the pinned Xray image:
./ovpn config validate --server <server>Use config validation before a risky deploy or when changing Xray-related defaults.
user reconcile exists as a hidden repair command for unusual local-state drift.
It is not part of normal server onboarding and should not be used as a routine sync step.
Normal behavior:
- user mutations apply to all enabled VPN servers
- deploy materializes canonical users onto a newly added server
- link/read/diagnostic commands remain server-scoped
Use reconcile only if ~/.ovpn has drifted after manual SQLite edits, an old migration, or a failed repair and you have already inspected the local state.
It copies local user rows from one server record to another server record, is dry-run by default, and does not change the live remote runtime until you deploy the affected server.
./ovpn user reconcile --from-server <known-good-server> --to-server <target-server>
./ovpn user reconcile --from-server <known-good-server> --to-server <target-server> --apply
./ovpn deploy <target-server>Prefer normal commands whenever possible:
./ovpn user add --username <user>
./ovpn user disable --username <user>
./ovpn deploy <server>For a newly bootstrapped host:
./ovpn server status <server>
./ovpn doctor <server>
./ovpn server logs <server> --service ovpn-agent --tail 100
./ovpn server logs <server> --service xray --tail 100For a user who cannot connect:
./ovpn user show --server <server> --username <user>
./ovpn user diagnose --server <server> --username <user> --since 24h
./ovpn user link --server <server> --username <user> --qr-file ~/Desktop/<user>.pngFor high traffic:
./ovpn user top --server <server> --limit 20
./ovpn user diagnose --server <server> --username <user> --since 24h
./ovpn user quota-set --username <user> --monthly-gb <gb>For a shared profile:
./ovpn user add --username <person-or-device>
./ovpn user link --server <server> --username <person-or-device> --qr-file ~/Desktop/<person-or-device>.png
./ovpn user disable --username <old-shared-profile>Do not leave targeted debug running longer than needed.
Use debug list during support and debug stop when finished.