transtation.git

transtation

~ Your own VLESS + REALITY proxy with optional Cloudflare WARP egress. Server in a container or native; native client with a fail-closed killswitch.
git clone https://github.com/debuman/transtation.git
commit f028ebe2bb3525f6dab6facffb8922b2f8f0af5a
parent e70837eef8bda4a218c2f6c97288ab5d76855aad
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Thu, 6 Aug 2026 19:50:05 +0900

The server installs natively too, not only in a container

Same program, same generated config, two ways to run it. `install.sh server
--native` downloads Xray and wgcf, verifies both against upstream checksums with
the same empty-expectation guard the Dockerfile uses, keeps state in
/var/lib/transtation owned by a dedicated user, and runs under a systemd unit.

Making the script work outside a container turned up three things it had been
getting away with:

- `apply()` sent SIGTERM to PID 1 to restart Xray. Inside a container that is
Xray and the restart policy brings it back. On a host, PID 1 is init. It is
keyed on TT_MANAGED now, which the image sets and a native install does not,
and uses systemctl outside a container.
- The data directory was hardcoded to /data, so `transtation show` on a native
install looked at an empty directory and reported no identity.
- Every CLI verb writes through mktemp and rename. Run as root against a state
directory owned by the service user, that leaves root-owned files the service
can no longer read, so the installed command drops to the transtation user
before doing anything.

The listen port now depends on who performs the privileged bind: the container
listens high and lets dockerd bind 443, which is why it needs no capability at
all; natively there is nothing in front, so it binds the real port and the unit
carries CAP_NET_BIND_SERVICE and nothing else.

Verified end to end with no container runtime involved: install, identity and
dest selection, verify completing a REALITY handshake, user add and rm keeping
every file owned by the service user, a client connecting through it, an
outside observer seeing a genuine Tving certificate, and a clean uninstall.

Diffstat:
MREADME.md | 100++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Minstall.sh | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mserver/Dockerfile | 1+
Mserver/transtation | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mtest.sh | 5+++++
5 files changed, 325 insertion(+), 67 deletion(-)

diff --git a/README.md b/README.md
@@ -4,8 +4,8 @@ Your own VLESS + REALITY proxy, with optional Cloudflare WARP egress, in one
 container on any Linux box that has Docker.
 
 ```
-curl -fsSLO https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.12/install.sh
-sha256sum install.sh      # compare against the hash in the v1.0.12 release notes
+curl -fsSLO https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.13/install.sh
+sha256sum install.sh      # compare against the hash in the v1.0.13 release notes
 less install.sh           # please actually read it
 sudo sh install.sh
 ```
@@ -40,42 +40,57 @@ transtation exists because of a narrower set of preferences:
 
 ## Server
 
-### Install
+Two ways to run it. They are the same software with the same config; pick on
+how you feel about a container runtime on your VPS.
 
-Two-step form above is the recommended one. If you are going to pipe it anyway:
+### In a container (default)
 
 ```
-curl -fsSL https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.12/install.sh | sudo sh
+curl -fsSLO https://raw.githubusercontent.com/sqzer-x/transtation/v1.0.13/install.sh
+sha256sum install.sh      # compare against the hash in the v1.0.13 release notes
+less install.sh           # please actually read it
+sudo sh install.sh
 ```
 
-The installer needs root, refuses anything other than x86_64/aarch64, installs
-Docker if it is missing, writes `/opt/transtation/{docker-compose.yml,.env}`,
-and waits for the container to report healthy. Running it again is an upgrade:
-it pulls and restarts, and never re-provisions.
+Installs Docker if it is missing, writes `/opt/transtation/{docker-compose.yml,.env}`,
+resolves the image tag to a digest and pins the compose file to it, then waits
+for the container to be healthy and proves the handshake works. Re-running it is
+how you upgrade.
 
-If you already run Docker and prefer to do it yourself:
+### Natively
 
-```yaml
-# /opt/transtation/docker-compose.yml
-services:
-  proxy:
-    image: ghcr.io/sqzer-x/transtation:v1.0.12
-    container_name: transtation
-    restart: unless-stopped
-    ports: ["443:8443"]
-    volumes: ["transtation-data:/data"]
-    env_file: .env
-    cap_drop: [ALL]
-    security_opt: ["no-new-privileges:true"]
-    read_only: true
-    tmpfs: ["/tmp:rw,noexec,nosuid,size=16m"]
-    logging:
-      driver: json-file
-      options: { max-size: "10m", max-file: "3" }
-volumes:
-  transtation-data:
-    name: transtation-data      # pinned; otherwise Compose prefixes it with the directory name
 ```
+sudo sh install.sh server --native
+```
+
+No container runtime. It downloads Xray and wgcf, verifies both against
+upstream checksums, installs `/usr/local/bin/transtation`, keeps state in
+`/var/lib/transtation` owned by a dedicated `transtation` user, and runs under
+`transtation.service`.
+
+### Which one
+
+Measured on the same host, same binary:
+
+| | container | native |
+|---|---|---|
+| capabilities held | **none** — `CapEff 0000000000000000` | `CAP_NET_BIND_SERVICE`, and only that |
+| root filesystem | read-only | `ProtectSystem=strict` |
+| runs as | uid 10001 | `transtation` user |
+| costs | Docker daemon, ~250 MB, `ip_forward=1`, published ports bypass `ufw` | needs systemd |
+| upgrades | pull a new digest | re-run the installer |
+
+The container needs no capability at all because dockerd performs the
+privileged bind on 443 and the container listens high; natively there is
+nothing in front, so it keeps exactly one capability and nothing else is even
+reachable. Both pin and verify the same Xray release.
+
+If you have no strong feeling, use the container: one artifact, dependencies
+verified and frozen inside it, and rollback is a digest. If you would rather not
+run a container runtime on a proxy server, the native path is not a lesser
+option — it is the same program with a systemd unit instead of a compose file.
+
+Either way `transtation` is the command:
 
 ### Day to day
 
@@ -363,25 +378,37 @@ docker compose exec proxy xray tls ping example.com
 
 ---
 
-## Capabilities: none, and none needed
+## Capabilities
+
+The container holds **none at all**, verified on a running server:
+`CapEff`, `CapPrm` and `CapBnd` all `0000000000000000`, `NoNewPrivs 1`, root
+filesystem read-only. The native install holds `CAP_NET_BIND_SERVICE` and
+nothing else, because something has to bind 443 and there is no dockerd in
+front to do it.
 
 | | Needed? | |
 |---|---|---|
 | `cap_add: NET_ADMIN` | no | the generated config pins `"noKernelTun": true`, so WARP always runs in Xray's in-process userspace stack regardless of what the container holds. Adding the capability buys nothing and widens the container's privileges. |
 | `--device /dev/net/tun` | no | the userspace network stack needs no TUN device |
-| `CAP_NET_BIND_SERVICE` | no | it listens on 8443 inside; `-p 443:8443` does the privileged bind in dockerd, which also keeps this working under rootless Docker |
+| `CAP_NET_BIND_SERVICE` | container: no | it listens on 8443 inside; `-p 443:8443` does the privileged bind in dockerd, which also keeps this working under rootless Docker |
 | `--network host` | no, and must not be used | it would expose the loopback SOCKS inbound the healthcheck uses, turning your server into an open relay |
 
+For reference, a hand-rolled native Xray unit typically holds `CAP_NET_ADMIN`
+*and* `CAP_NET_BIND_SERVICE` with a writable root filesystem. The `NET_ADMIN`
+half is only needed for kernel-TUN WARP, which this does not use.
+
 That `noKernelTun` pin is load-bearing rather than decorative. Left unset, Xray
 probes for `CAP_NET_ADMIN` and takes the kernel-TUN path when it finds it — and
 `createKernelTun` writes to `/proc/sys/.../rp_filter` *before* opening the
 device, which fails on Docker's read-only `/proc/sys` with no fallback. Pinning
-it makes the container's behaviour independent of its capabilities.
+it makes behaviour independent of capabilities.
 
-To confirm:
+To confirm:To confirm:
 
 ```
-docker compose logs proxy | grep -o 'Using .* TUN'    # -> Using gVisor TUN
+docker compose logs proxy | grep -o 'Using .* TUN'      # container
+journalctl -u transtation | grep -o 'Using .* TUN'     # native
+# both -> Using gVisor TUN
 ```
 
 ---
@@ -450,7 +477,8 @@ the unpacking container has to run as root to read it at all. `restore` also
 refuses to run while the server is up, and refuses a file that is not a
 transtation backup.
 
-Upgrades: re-run `install.sh`, or `docker compose pull && docker compose up -d`.
+Upgrades: re-run `install.sh` (with `server --native` if that is how you
+installed), or `docker compose pull && docker compose up -d`.
 Your identity lives in the volume and survives; the config is regenerated from
 the new image every boot, so schema changes come along for free.
 
diff --git a/install.sh b/install.sh
@@ -15,18 +15,20 @@
 # last line. If the download is truncated, nothing runs.
 set -eu
 
-VERSION=${TT_VERSION:-v1.0.12}
+VERSION=${TT_VERSION:-v1.0.13}
 IMAGE_SERVER=${TT_IMAGE_SERVER:-ghcr.io/sqzer-x/transtation}
 IMAGE_CLIENT=${TT_IMAGE_CLIENT:-ghcr.io/sqzer-x/transtation-client}
 # Filled in by the release workflow. A tag is mutable -- the same stolen-token
 # compromise we already concede for Xray's .dgst would let someone retag
-# v1.0.12, and the hash-verified installer you read would vouch for nothing.
+# v1.0.13, and the hash-verified installer you read would vouch for nothing.
 SERVER_DIGEST=${TT_SERVER_DIGEST:-}
 CLIENT_DIGEST=${TT_CLIENT_DIGEST:-}
 
 # sing-box ships no checksum file, so these were computed once from the official
 # release artifacts and are reviewed as part of this repo. The -musl builds are
 # statically linked, so one binary runs on any distribution.
+XRAY_VERSION=${TT_XRAY_VERSION:-v26.3.27}
+WGCF_VERSION=${TT_WGCF_VERSION:-2.2.32}
 SINGBOX_VERSION=${TT_SINGBOX_VERSION:-1.13.16}
 SINGBOX_SHA_amd64=9ff0345fde4157a6bdab45a615668d41ccc93f6d0f361108042a48b8a49a9baa
 SINGBOX_SHA_arm64=3ea951c68f2eea10fd3ee8f8cc7794c12ccc7405afa99279a79e0b41cb183adf
@@ -121,7 +123,172 @@ wait_healthy() {
 	return 1
 }
 
-# ------------------------------------------------------------------ server
+# --- native server -------------------------------------------------------
+
+fetch_verified_xray() {
+	case "$(uname -m)" in
+		x86_64 | amd64) _xz=Xray-linux-64.zip; _wa=amd64 ;;
+		aarch64 | arm64) _xz=Xray-linux-arm64-v8a.zip; _wa=arm64 ;;
+	esac
+	_t=$(mktemp -d)
+	_b="https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}"
+	curl -fsSL -o "$_t/$_xz" "$_b/$_xz" || { rm -rf "$_t"; die "could not download Xray ${XRAY_VERSION}"; }
+	curl -fsSL -o "$_t/$_xz.dgst" "$_b/$_xz.dgst" || { rm -rf "$_t"; die "could not download the Xray checksum"; }
+	_exp=$(awk '/^SHA2-256/ {print $2}' "$_t/$_xz.dgst")
+	# Same guard as the Dockerfile: an empty expectation makes sha256sum verify
+	# nothing at all.
+	printf '%s' "$_exp" | grep -Eq '^[0-9a-f]{64}$' ||
+		{ rm -rf "$_t"; die "no usable SHA2-256 line in $_xz.dgst"; }
+	( cd "$_t" && echo "$_exp  $_xz" | sha256sum -c - >/dev/null 2>&1 ) ||
+		{ rm -rf "$_t"; die "Xray checksum mismatch -- refusing to install it"; }
+	command -v unzip >/dev/null 2>&1 || install_pkg unzip
+	unzip -q -j "$_t/$_xz" xray -d "$_t"
+	install -Dm0755 "$_t/xray" /usr/local/bin/xray
+
+	_w="https://github.com/ViRb3/wgcf/releases/download/v${WGCF_VERSION}"
+	curl -fsSL -o "$_t/wgcf" "$_w/wgcf_${WGCF_VERSION}_linux_${_wa}" ||
+		{ rm -rf "$_t"; die "could not download wgcf"; }
+	curl -fsSL "$_w/checksums.txt" | grep " wgcf_${WGCF_VERSION}_linux_${_wa}\$" |
+		awk -v f="$_t/wgcf" '{print $1"  "f}' | sha256sum -c - >/dev/null 2>&1 ||
+		{ rm -rf "$_t"; die "wgcf checksum mismatch -- refusing to install it"; }
+	install -Dm0755 "$_t/wgcf" /usr/local/bin/wgcf
+	rm -rf "$_t"
+	step binaries "xray $(/usr/local/bin/xray version | head -1 | cut -d' ' -f2) and wgcf ${WGCF_VERSION}, both checksum-verified"
+}
+
+install_pkg() {
+	case "$(os_id)" in
+		alpine) apk add --no-cache "$1" >/dev/null 2>&1 ;;
+		arch | archarm | manjaro | endeavouros) pacman -S --needed --noconfirm "$1" >/dev/null 2>&1 ;;
+		*) DEBIAN_FRONTEND=noninteractive apt-get -y -qq install "$1" >/dev/null 2>&1 ||
+			dnf -y -q install "$1" >/dev/null 2>&1 || true ;;
+	esac
+}
+
+install_server_native() {
+	need_root
+	check_arch
+	say ""
+	say "  transtation $VERSION -- native install, no container runtime"
+	say "  https://github.com/sqzer-x/transtation"
+	say ""
+	step host "$(os_id) / $(uname -m) / root"
+	[ -d /run/systemd/system ] || die "the native install is managed by systemd, which is not running here.
+    Use the container install instead:  sh install.sh"
+	command -v curl >/dev/null 2>&1 || die "curl is required"
+
+	fetch_verified_xray
+	install -Dm0755 "$(server_script)" /usr/local/lib/transtation/server
+
+	id -u transtation >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin transtation 2>/dev/null ||
+		adduser -S -D -H -s /sbin/nologin transtation 2>/dev/null || true
+	install -d -o transtation -g transtation -m 0700 /var/lib/transtation
+	mkdir -p /etc/transtation
+	[ -e /etc/transtation/server.env ] || { : >/etc/transtation/server.env; chmod 600 /etc/transtation/server.env; }
+	[ -n "${PORT:-}" ] && ! grep -q '^PORT=' /etc/transtation/server.env &&
+		printf 'PORT=%s\n' "$PORT" >>/etc/transtation/server.env
+	# Every command has to run as the service user. Run as root, the script would
+	# create root-owned files in the state directory and the service -- which is
+	# not root -- would stop being able to read its own identity.
+	install -Dm0755 /dev/stdin /usr/local/bin/transtation <<-'WRAP'
+		#!/bin/sh
+		set -eu
+		if [ "$(id -un)" = transtation ]; then
+		  exec env TT_DATA=/var/lib/transtation /usr/local/lib/transtation/server "$@"
+		fi
+		[ "$(id -u)" = 0 ] || { echo "transtation: run this as root or as the transtation user" >&2; exit 1; }
+		if command -v runuser >/dev/null 2>&1; then
+		  exec runuser -u transtation -- env TT_DATA=/var/lib/transtation /usr/local/lib/transtation/server "$@"
+		fi
+		exec su -s /bin/sh transtation -c 'exec env TT_DATA=/var/lib/transtation /usr/local/lib/transtation/server "$@"' -- sh "$@"
+	WRAP
+	step config "wrote /etc/transtation/server.env, data in /var/lib/transtation"
+
+	cat >/etc/systemd/system/transtation.service <<-'EOF'
+		[Unit]
+		Description=transtation server
+		After=network-online.target
+		Wants=network-online.target
+
+		[Service]
+		Type=simple
+		User=transtation
+		Group=transtation
+		Environment=TT_DATA=/var/lib/transtation
+		EnvironmentFile=-/etc/transtation/server.env
+		ExecStart=/usr/local/lib/transtation/server run
+		Restart=on-failure
+		RestartSec=3s
+		# The container needs no capability because dockerd performs the
+		# privileged bind. Here nothing is in front of us, so we keep exactly
+		# one -- and nothing else is even reachable.
+		AmbientCapabilities=CAP_NET_BIND_SERVICE
+		CapabilityBoundingSet=CAP_NET_BIND_SERVICE
+		NoNewPrivileges=true
+		ProtectSystem=strict
+		ProtectHome=true
+		PrivateTmp=true
+		PrivateDevices=true
+		ProtectKernelTunables=true
+		ProtectControlGroups=true
+		RestrictSUIDSGID=true
+		ReadWritePaths=/var/lib/transtation
+
+		[Install]
+		WantedBy=multi-user.target
+	EOF
+	systemctl daemon-reload
+	printf '  %-14s' start
+	systemctl enable --now transtation.service >/dev/null 2>&1 || true
+	_i=0
+	while [ "$_i" -lt 40 ]; do
+		/usr/local/bin/transtation health >/dev/null 2>&1 && break
+		_i=$((_i + 1)); printf '.'; sleep 3
+	done
+	systemctl is-active --quiet transtation || {
+		say "FAILED"
+		say "  journalctl -u transtation -n 40"
+		exit 1
+	}
+	say "running"
+
+	printf '  %-14s' verify
+	/usr/local/bin/transtation verify || {
+		say ""
+		say "  The server is running but clients cannot complete the REALITY handshake."
+		say "      echo 'SNI=dl.google.com' >> /etc/transtation/server.env"
+		say "      systemctl restart transtation && transtation verify"
+		exit 1
+	}
+	say ""
+	/usr/local/bin/transtation status || true
+	cat <<-EOF
+		  DO THIS NOW -- it is the only irreplaceable thing on this box:
+		      transtation backup
+
+		  Your link:   transtation show
+		  Add a user:  transtation user add alice
+		  Logs:        journalctl -u transtation -f
+		  Uninstall:   sh install.sh --uninstall
+
+		  >> If a client cannot connect, open the port in your provider's firewall
+		     or security group. The healthcheck only proves OUTBOUND traffic works.
+
+	EOF
+}
+
+server_script() {
+	_d=$(dirname -- "$0" 2>/dev/null || echo .)
+	if [ -r "$_d/server/transtation" ]; then
+		printf '%s' "$_d/server/transtation"
+		return 0
+	fi
+	_f=$(mktemp)
+	curl -fsSL "$RAW/server/transtation" -o "$_f" || die "could not fetch the server script from $RAW"
+	printf '%s' "$_f"
+}
+
+# ------------------------------------------------------ server (container)
 
 install_server() {
 	need_root
@@ -515,6 +682,19 @@ uninstall() {
 			*) say "volume kept: 'docker volume ls | grep transtation-data'" ;;
 		esac
 	fi
+	if [ -e /etc/systemd/system/transtation.service ]; then
+		systemctl disable --now transtation.service >/dev/null 2>&1 || true
+		rm -f /etc/systemd/system/transtation.service
+		systemctl daemon-reload 2>/dev/null || true
+		rm -rf /usr/local/bin/transtation /usr/local/lib/transtation /usr/local/bin/xray /usr/local/bin/wgcf
+		say "native server removed. Its data is still in /var/lib/transtation."
+		ask 'also delete /var/lib/transtation? That destroys your Reality key and every issued link. [y/N] '
+		say ""
+		case "$REPLY" in
+			y | Y) rm -rf /var/lib/transtation; say "data deleted." ;;
+			*) say "data kept in /var/lib/transtation" ;;
+		esac
+	fi
 	if [ -e /etc/systemd/system/transtation-client.service ]; then
 		systemctl disable --now transtation-client.service >/dev/null 2>&1 || true
 		rm -f /etc/systemd/system/transtation-client.service
@@ -535,7 +715,14 @@ main() {
 	_cmd=${1:-server}
 	if [ $# -gt 0 ]; then shift; fi
 	case "$_cmd" in
-		server) install_server ;;
+		server)
+			case "${1:-}" in
+				--native) install_server_native ;;
+				'') install_server ;;
+				*) die "unknown option: $1 (expected --native)" ;;
+			esac
+			;;
+		--native) install_server_native ;;
 		client) install_client "$@" ;;
 		--uninstall | uninstall) uninstall ;;
 		-h | --help)
diff --git a/server/Dockerfile b/server/Dockerfile
@@ -55,6 +55,7 @@ COPY NOTICE /NOTICE
 # CIDR list replaces. Any "geoip:" or "geosite:" routing rule will fail at
 # startup with file-not-found.
 
+ENV TT_MANAGED=docker
 USER xray
 VOLUME /data
 EXPOSE 8443
diff --git a/server/transtation b/server/transtation
@@ -16,9 +16,26 @@ HEALTH_PORT=10808
 WARP_PEER_PUBKEY='bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo='
 WARP_ENDPOINT_DEFAULT='162.159.192.1:2408'
 TAB=$(printf '\t')
+# How this instance is supervised. The image sets TT_MANAGED=docker; a native
+# install leaves it unset and is managed by systemd. It decides how the server
+# is restarted and which file the operator edits -- and, critically, that
+# `kill -TERM 1` is only ever right inside a container. On a host, PID 1 is
+# init.
+TT_MANAGED=${TT_MANAGED:-systemd}
+restart_hint() { [ "$TT_MANAGED" = docker ] && printf 'docker compose up -d' || printf 'systemctl restart transtation'; }
+env_hint() { [ "$TT_MANAGED" = docker ] && printf '/opt/transtation/.env' || printf '/etc/transtation/server.env'; }
 
 set_paths() {
-	DATA=${TT_DATA:-/data}
+	# The container mounts its volume at /data; a native install keeps state
+	# where a system service should. Getting this wrong sends `transtation show`
+	# looking at an empty directory.
+	if [ -n "${TT_DATA:-}" ]; then
+		DATA=$TT_DATA
+	elif [ "$TT_MANAGED" = docker ]; then
+		DATA=/data
+	else
+		DATA=/var/lib/transtation
+	fi
 	STATE="$DATA/state.env"
 	USERS="$DATA/users.tsv"
 	CONF="$DATA/config.json"
@@ -154,7 +171,7 @@ warp_register() {
     Cloudflare rate-limits registration per source IP for roughly 15 minutes.
     Continuing with DIRECT egress from this VPS's own IP -- the proxy works.
       retry:            transtation warp register
-      turn off for good: set WARP=0 in your .env and 'docker compose up -d'
+      turn off for good: set WARP=0 in $(env_hint) and '$(restart_hint)'
     Any WARP identity you already had is untouched."
 	return 1
 }
@@ -213,7 +230,7 @@ render_config() {
 		    {
 		      "tag": "reality-in",
 		      "listen": "0.0.0.0",
-		      "port": $INTERNAL_PORT,
+		      "port": ${LISTEN_PORT:-$INTERNAL_PORT},
 		      "protocol": "vless",
 		      "settings": {
 		        "clients": [
@@ -276,7 +293,9 @@ render_config() {
 write_config() {
 	_t=$(mktemp "$DATA/.config.XXXXXX")
 	render_config >"$_t"
-	jq -e . "$_t" >/dev/null 2>&1 || { rm -f "$_t"; die "generated config is not valid JSON -- this is a transtation bug"; }
+	if command -v jq >/dev/null 2>&1; then
+		jq -e . "$_t" >/dev/null 2>&1 || { rm -f "$_t"; die "generated config is not valid JSON -- this is a transtation bug"; }
+	fi
 	# -format json is required, not optional: xray infers the format from the
 	# file extension, and this is a mktemp name with no .json suffix. Without it
 	# every validation fails with "Failed to get format of ..." and the server
@@ -296,20 +315,21 @@ write_runtime() {
 	# Derived, rewritten every boot. `show`/`status`/`health` run as separate
 	# `docker exec` processes and would otherwise not see an auto-detected HOST.
 	_t=$(mktemp "$DATA/.runtime.XXXXXX")
-	printf 'HOST=%s\nPORT=%s\nSNI=%s\nDEST=%s\nWARP=%s\nWARP_READY=%s\nSNI_REASON=%s\n' \
-		"$HOST" "$PORT" "$SNI" "$DEST" "${WARP:-1}" "$(warp_ready && echo 1 || echo 0)" \
-		"${SNI_REASON:-unknown}" >"$_t"
+	printf 'HOST=%s\nPORT=%s\nLISTEN_PORT=%s\nSNI=%s\nDEST=%s\nWARP=%s\nWARP_READY=%s\nSNI_REASON=%s\n' \
+		"$HOST" "$PORT" "${LISTEN_PORT:-$INTERNAL_PORT}" "$SNI" "$DEST" "${WARP:-1}" \
+		"$(warp_ready && echo 1 || echo 0)" "${SNI_REASON:-unknown}" >"$_t"
 	chmod 600 "$_t"
 	mv -f "$_t" "$RUNTIME"
 }
 
 load_runtime() {
-	[ -f "$RUNTIME" ] || die "server has not started yet -- run 'docker compose up -d' first"
-	HOST=""; PORT=""; SNI=""; DEST=""; WARP=""; WARP_READY=""; SNI_REASON=""
+	[ -f "$RUNTIME" ] || die "server has not started yet -- run '$(restart_hint)' first"
+	HOST=""; PORT=""; LISTEN_PORT=""; SNI=""; DEST=""; WARP=""; WARP_READY=""; SNI_REASON=""
 	while IFS='=' read -r _k _v; do
 		case "$_k" in
 			HOST) HOST=$_v ;;
 			PORT) PORT=$_v ;;
+			LISTEN_PORT) LISTEN_PORT=$_v ;;
 			SNI) SNI=$_v ;;
 			DEST) DEST=$_v ;;
 			WARP) WARP=$_v ;;
@@ -349,17 +369,24 @@ apply() {
 	DEST=${_dest_env:-$SNI:443}
 	write_config
 	write_runtime
-	if [ -r /proc/1/comm ] && [ "$(cat /proc/1/comm)" = xray ]; then
-		# Xray has no reload signal and no config watcher, so the file is the
-		# source of truth and the only way to apply it is to restart. Stopping
-		# PID 1 stops the container; Docker's restart policy brings it back.
-		echo "applying: stopping xray so your restart policy brings it back up."
+	# Xray has no reload signal and no config watcher, so the file is the source
+	# of truth and the only way to apply it is to restart.
+	if [ "$TT_MANAGED" = docker ]; then
+		if [ -r /proc/1/comm ] && [ "$(cat /proc/1/comm)" = xray ]; then
+			echo "applying: stopping xray so your restart policy brings it back up."
+			echo "  existing connections drop and clients reconnect on their own."
+			printf '  If you started this container WITHOUT a restart policy, start it again:\n'
+			printf '    docker start %s\n' "$(hostname)"
+			kill -TERM 1
+		else
+			echo "written; takes effect the next time the container starts"
+		fi
+	elif command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet transtation 2>/dev/null; then
+		echo "applying: restarting transtation.service"
 		echo "  existing connections drop and clients reconnect on their own."
-		printf '  If you started this container WITHOUT a restart policy, start it again:\n'
-		printf '    docker start %s\n' "$(hostname)"
-		kill -TERM 1
+		systemctl restart transtation
 	else
-		echo "written; takes effect the next time the container starts"
+		echo "written; takes effect the next time the server starts"
 	fi
 }
 
@@ -657,7 +684,7 @@ cmd_verify() {
 		                 "settings": { "auth": "noauth", "udp": false } }],
 		  "outbounds": [{
 		    "protocol": "vless",
-		    "settings": { "vnext": [{ "address": "127.0.0.1", "port": $INTERNAL_PORT,
+		    "settings": { "vnext": [{ "address": "127.0.0.1", "port": ${LISTEN_PORT:-$INTERNAL_PORT},
 		      "users": [{ "id": "$_u", "encryption": "none", "flow": "xtls-rprx-vision" }] }] },
 		    "streamSettings": { "network": "tcp", "security": "reality",
 		      "realitySettings": { "fingerprint": "chrome", "serverName": "$SNI",
@@ -694,7 +721,7 @@ cmd_verify() {
     complete the handshake:
         xray tls ping $SNI
     Then pick a smaller one (dl.google.com, www.cloudflare.com, addons.mozilla.org),
-    set SNI= in your .env, and 'docker compose up -d'."
+    set SNI= in $(env_hint), and '$(restart_hint)'."
 	fi
 	printf 'REALITY handshake OK. Egress through the tunnel: %s\n' \
 		"$(echo "$_out" | grep -E '^(ip|colo|warp)=' | tr '\n' ' ')"
@@ -716,7 +743,7 @@ cmd_health() {
 				  - the WireGuard stack itself is fine: this image pins "noKernelTun": true, so
 				    it always uses Xray's in-process userspace stack no matter what
 				    capabilities the container holds.
-				  fix: set WARP=0 in your .env and run 'docker compose up -d'. The proxy works
+				  fix: set WARP=0 in $(env_hint) and run '$(restart_hint)'. The proxy works
 				       without WARP; it just egresses from this VPS's own IP.
 			EOF
 			exit 1
@@ -743,10 +770,16 @@ cmd_status() {
 		printf '  egress        UNREACHABLE -- no traffic is getting out through the proxy\n'
 	fi
 
-	if [ -r /proc/1/comm ] && [ "$(cat /proc/1/comm)" = xray ]; then
-		printf '  xray          running\n'
+	if [ "$TT_MANAGED" = docker ]; then
+		if [ -r /proc/1/comm ] && [ "$(cat /proc/1/comm)" = xray ]; then
+			printf '  xray          running\n'
+		else
+			printf '  xray          NOT running as PID 1 of this container\n'
+		fi
+	elif command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet transtation 2>/dev/null; then
+		printf '  xray          running under transtation.service\n'
 	else
-		printf '  xray          NOT running as PID 1\n'
+		printf '  xray          NOT running\n'
 	fi
 	printf '  users         %s\n' "$(awk -F'\t' 'NF>=3{printf "%s%s", (n++?", ":""), $1}' "$USERS" 2>/dev/null)"
 	printf '  inbound       not verifiable from this host. If clients time out with no server\n'
@@ -772,7 +805,7 @@ cmd_reset() {
 	[ "${I_UNDERSTAND_THIS_DESTROYS_MY_KEYS:-}" = yes ] || die \
 		"refusing. This destroys your Reality keypair and every issued link.
     If you are certain:
-      docker compose run --rm -e I_UNDERSTAND_THIS_DESTROYS_MY_KEYS=yes proxy reset"
+      I_UNDERSTAND_THIS_DESTROYS_MY_KEYS=yes transtation reset"
 	rm -f "$STATE" "$USERS" "$CONF" "$RUNTIME" "$ACCOUNT" "$PROFILE" "$SNIFILE"
 	echo "identity destroyed. The next start provisions a brand new server."
 }
@@ -952,6 +985,10 @@ validate_config_env() {
 	PORT=${PORT:-443}
 	printf '%s' "$PORT" | grep -Eq '^[0-9]{1,5}$' && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] ||
 		die "PORT must be 1-65535, got: $(q "$PORT")"
+	# In a container we listen high and let dockerd perform the privileged bind,
+	# which is why the image needs no capability at all. Natively there is
+	# nothing in front of us, so we listen on the real port.
+	[ "$TT_MANAGED" = docker ] && LISTEN_PORT=$INTERNAL_PORT || LISTEN_PORT=$PORT
 	printf '%s' "${MIN_CLIENT_VER:-0.0.0}" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){0,2}$' ||
 		die "MIN_CLIENT_VER must look like 0.0.0, got: $(q "${MIN_CLIENT_VER:-}")"
 	# Deliberately IPv4:port only, never a hostname: resolving the WARP endpoint
@@ -963,7 +1000,7 @@ validate_config_env() {
 # --------------------------------------------------------------------- run
 
 banner() {
-	printf '\n  transtation -- listening on 0.0.0.0:%s inside the container\n' "$INTERNAL_PORT"
+	printf '\n  transtation -- listening on 0.0.0.0:%s\n' "${LISTEN_PORT:-$INTERNAL_PORT}"
 	printf '  server        %s:%s\n' "$HOST" "$PORT"
 	printf '  dest / SNI    %s  (%s)\n' "$SNI" "${SNI_REASON:-unknown}"
 	if [ "${WARP:-1}" = 1 ] && ! warp_ready; then
@@ -1038,7 +1075,7 @@ cmd_run() {
 	[ -s "$USERS" ] || user_add "${USER_NAME:-main}"
 
 	# --- collect the WARP registration started above ---------------------
-	[ -z "$_warp_pid" ] || wait "$_warp_pid" || true
+	[ -z "$_warp_pid" ] || wait "$_warp_pid" 2>/dev/null || true
 
 	write_config
 	write_runtime
diff --git a/test.sh b/test.sh
@@ -130,6 +130,11 @@ posix_sh_ok() {
 }
 check "install.sh survives dash (Debian, Ubuntu) with no arguments" posix_sh_ok debian:stable-slim dash
 check "install.sh survives busybox ash (Alpine) with no arguments" posix_sh_ok alpine:3.23 sh
+posix_sh_native_ok() {
+	_o=$(docker run --rm -v "$PWD":/w:ro -u 65534 "$1" sh -c "cd /w && $2 install.sh server --native" 2>&1) || true
+	case "$_o" in *"needs root"*) return 0 ;; *) return 1 ;; esac
+}
+check "the native server path dispatches under dash" posix_sh_native_ok debian:stable-slim dash
 
 echo
 echo "== client renders configs the real sing-box accepts =="