Hardening a Public Deployment

Applies to: ChengOS v0.2.0+ | Last verified: 2026-09-25 | Source: deploy/HARDENING.md, deploy/lib/doctor.sh, deploy/.env.example, deploy/docker/docker-compose.yml, deploy/infra/cloudflare/

This is the checklist for putting ChengOS on the public internet. It has two routes — A. Cloudflare Tunnel (recommended) and B. a reverse proxy — which share the same first step. The full operator guide, with every command, is deploy/HARDENING.md; the helper scripts are in deploy/infra/cloudflare/.

Know what is exposed

A ChengOS host listens on several ports, and they are not equal:

ServiceDefault portWho should reach itWhy
cheng-apiAPI_PORT (19225)This host onlyThe frontends proxy it. Reached directly, it bypasses every scanner rule, rate limit, and security header the frontends add — and the login endpoint is on it.
ui-serverUI_PORT (8080)Your proxy or cloudflaredThe editor and admin console
app-serverAPP_PORT (5055)Your proxy or cloudflaredThe channel gateway
laya (optional)LAYA_PORT (1215)cheng-api onlyLocal decision model; unauthenticated unless LAYA_API_KEY is set

Only 80/443, held by cloudflared or your reverse proxy, should face the internet. Check the effective ports in your deploy/.env rather than assuming the defaults.

A different port number is not a security measure. Internet-wide scanners cover every port in hours; what protects a port is not being reachable.

The settings involved

KeyControlsDefault for a new install
BIND_ADDRESSInterface the API listens on127.0.0.1
UI_BIND / APP_BINDInterface the native frontends listen on0.0.0.0
TRUSTED_PROXY_IPSProxies allowed to report a visitor's real IPempty
TRUST_CLOUDFLARERead CF-Connecting-IP instead of X-Forwarded-Forfalse
CORS_PERMISSIVEAllow any originfalse
CSP_CONNECT_SRC_EXTRAExtra connect-src originsempty

Two things are easy to miss:

Step 1: tighten the listen addresses

In deploy/.env:

# The API is only ever for this host.
BIND_ADDRESS=127.0.0.1

# Where the frontends listen depends on where the proxy is:
#   route A or B1 (proxy on this host)    -> 127.0.0.1
#   route B2 (proxy on another machine)   -> a private or VPN address of THIS host
UI_BIND=127.0.0.1
APP_BIND=127.0.0.1

CORS_PERMISSIVE=false
CORS_ALLOWED_ORIGINS=

Then restart and confirm:

./chengos.sh restart
ss -tlnp | grep -E '19225|8080|5055'    # substitute your API_PORT / UI_PORT / APP_PORT

Every line should show 127.0.0.1:<port>, not 0.0.0.0 or *.

Docker installs ignore these keys. docker-compose.yml publishes every host port to 127.0.0.1 only (127.0.0.1:${API_PORT}, 127.0.0.1:${UI_PORT}, and so on) and pins the API's BIND_ADDRESS to 0.0.0.0 inside its container, because the UI and app containers must reach it over the Compose network. Do not change that pin.

Route A: Cloudflare Tunnel

cloudflared connects out from your host to Cloudflare and requests come back along that connection, so the host needs no inbound ports at all.

  1. Install cloudflared, then run cloudflared tunnel login and cloudflared tunnel create chengos yourself — they open a browser and issue credentials.
  2. cloudflared tunnel route dns chengos chengos.example.com creates the proxied CNAME.
  3. ./deploy/infra/cloudflare/setup-tunnel.sh --ui chengos.example.com --app app.example.com writes config.yml and a systemd unit, reading ports from .env. The generated ingress publishes the UI server, never the API: the UI server proxies /api and /ws itself.
  4. Install the config and the tunnel's <UUID>.json under /etc/cloudflared, readable only by a dedicated cloudflared system user — that JSON file is a bearer credential for the tunnel.
  5. Start the service and wait for Registered tunnel connection in its log.
  6. ./chengos.sh cloudflare on and restart, so the frontends read CF-Connecting-IP.
  7. Optionally apply edge rules (TLS/HSTS, Bot Fight Mode, WAF, rate limiting) with apply-zone-settings.sh --dry-run, then without --dry-run.
  8. Last, once the site works through the tunnel, close inbound 80/443 in your firewall.

Free Universal SSL covers one subdomain level: app.example.com works, app.chengos.example.com does not, and the failure looks like a TLS error rather than a naming problem. Do not run origin-firewall.sh on this route — there is no inbound traffic to allow.

Route B: a reverse proxy

B1 — proxy on the same host. Traffic to 127.0.0.1 never leaves the kernel, so nothing needs encrypting and TRUSTED_PROXY_IPS can stay empty (loopback is trusted implicitly). The proxy must forward Host, X-Forwarded-For (appended, not replaced — ChengOS reads the last entry), X-Forwarded-Proto (it decides HSTS), and the WebSocket upgrade headers. Behind Cloudflare, set the DNS records to Proxied, then restrict the origin to Cloudflare's ranges with sync-cf-ips.sh and origin-firewall.sh --apply.

B2 — proxy on another machine. A firewall decides who can connect, but not who can read the proxy-to-origin hop: over the public internet it is plaintext. Use a provider private network in the same region, or WireGuard/Tailscale. Then:

Verify with doctor

./chengos.sh doctor

doctor is read-only: it changes no setting, restarts nothing, and writes no file. It probes the running system rather than reading configuration back, because every failure in this area is quiet. It reports four sections:

SectionChecks
Listening addressesThe API is loopback-only; the frontends match their role; an enabled Laya does not collide with a ChengOS port and is not reachable without LAYA_API_KEY
Trust and CORSCORS_PERMISSIVE is off; TRUSTED_PROXY_IPS is set when the frontends listen beyond loopback
Live frontend behaviourScanner paths return 404, SPA deep links return 200, security headers are present, the CSP nonce changes per request
Edgecloudflared running (tunnel mode), or a firewall enabled

The exit code is 0 when there are no failures (warnings do not change it), so doctor works as a deployment gate. Run it after every change here and after every upgrade.

The one thing doctor cannot check is public reachability — a host probing itself always succeeds. From another machine, confirm the origin refuses direct connections:

curl -sv --max-time 5 http://<origin-public-ip>:19225/health   # must time out or be refused
curl -sv --max-time 5 http://<origin-public-ip>:8080/          # route A: must fail too

Troubleshooting

SymptomCause
Browser TLS error on a two-level subdomainFree Universal SSL covers one level only
EADDRNOTAVAIL at startupUI_BIND names another machine's address
Tunnel up, but 502Tunnel port differs from UI_PORT, or UI_BIND is not 127.0.0.1
Every visitor rate-limited as oneTRUSTED_PROXY_IPS missing for a public or CGNAT proxy
Scanner paths return 200The host proxy serves static files itself instead of passing to the UI server
Blank page, CSP errors in the consoleAn external origin missing from CSP_CONNECT_SRC_EXTRA
Desktop client or remote CLI cannot connectThe API is loopback-only; go through the proxy on 443, where /api is forwarded
Docker API unreachable after editing BIND_ADDRESSThe container pin to 0.0.0.0 was removed from the Compose file

Next steps