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:
| Service | Default port | Who should reach it | Why |
|---|---|---|---|
cheng-api | API_PORT (19225) | This host only | The 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-server | UI_PORT (8080) | Your proxy or cloudflared | The editor and admin console |
app-server | APP_PORT (5055) | Your proxy or cloudflared | The channel gateway |
laya (optional) | LAYA_PORT (1215) | cheng-api only | Local 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
| Key | Controls | Default for a new install |
|---|---|---|
BIND_ADDRESS | Interface the API listens on | 127.0.0.1 |
UI_BIND / APP_BIND | Interface the native frontends listen on | 0.0.0.0 |
TRUSTED_PROXY_IPS | Proxies allowed to report a visitor's real IP | empty |
TRUST_CLOUDFLARE | Read CF-Connecting-IP instead of X-Forwarded-For | false |
CORS_PERMISSIVE | Allow any origin | false |
CSP_CONNECT_SRC_EXTRA | Extra connect-src origins | empty |
Two things are easy to miss:
UI_BINDandAPP_BINDdefault to every interface. Hardening is not automatic: you change them in step 1..envis instance-owned and never overwritten by an upgrade. These defaults apply to new installs only. An older installation keeps its old values, andUI_BIND,APP_BIND, andTRUSTED_PROXY_IPSmay not exist in its.envat all — add them by hand.
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.
- Install
cloudflared, then runcloudflared tunnel loginandcloudflared tunnel create chengosyourself — they open a browser and issue credentials. cloudflared tunnel route dns chengos chengos.example.comcreates the proxied CNAME../deploy/infra/cloudflare/setup-tunnel.sh --ui chengos.example.com --app app.example.comwritesconfig.ymland a systemd unit, reading ports from.env. The generated ingress publishes the UI server, never the API: the UI server proxies/apiand/wsitself.- Install the config and the tunnel's
<UUID>.jsonunder/etc/cloudflared, readable only by a dedicatedcloudflaredsystem user — that JSON file is a bearer credential for the tunnel. - Start the service and wait for
Registered tunnel connectionin its log. ./chengos.sh cloudflare onand restart, so the frontends readCF-Connecting-IP.- Optionally apply edge rules (TLS/HSTS, Bot Fight Mode, WAF, rate limiting) with
apply-zone-settings.sh --dry-run, then without--dry-run. - 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:
- Set
UI_BIND/APP_BINDto this host's private or tunnel address. Another machine's address cannot be bound at all (EADDRNOTAVAIL). - List the proxy in
TRUSTED_PROXY_IPSunless it is on an RFC 1918 address. Tailscale and other CGNAT addresses (100.64.0.0/10) are not RFC 1918. If this is wrong, every visitor is attributed to the proxy's address and the whole internet shares one rate-limit bucket — silently. The native servers print their effective trust set at startup (Trusted proxies: loopback + RFC1918 + …). - Firewall the frontend ports to the proxy's address only.
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:
| Section | Checks |
|---|---|
| Listening addresses | The 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 CORS | CORS_PERMISSIVE is off; TRUSTED_PROXY_IPS is set when the frontends listen beyond loopback |
| Live frontend behaviour | Scanner paths return 404, SPA deep links return 200, security headers are present, the CSP nonce changes per request |
| Edge | cloudflared 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
| Symptom | Cause |
|---|---|
| Browser TLS error on a two-level subdomain | Free Universal SSL covers one level only |
EADDRNOTAVAIL at startup | UI_BIND names another machine's address |
| Tunnel up, but 502 | Tunnel port differs from UI_PORT, or UI_BIND is not 127.0.0.1 |
| Every visitor rate-limited as one | TRUSTED_PROXY_IPS missing for a public or CGNAT proxy |
| Scanner paths return 200 | The host proxy serves static files itself instead of passing to the UI server |
| Blank page, CSP errors in the console | An external origin missing from CSP_CONNECT_SRC_EXTRA |
| Desktop client or remote CLI cannot connect | The API is loopback-only; go through the proxy on 443, where /api is forwarded |
Docker API unreachable after editing BIND_ADDRESS | The container pin to 0.0.0.0 was removed from the Compose file |
Next steps
- Native Deployment — the services you are fronting.
- Upgrades & Rollback — why
.envsurvives upgrades, and what that means for new defaults. - Environment Variable Reference — every key used here.