Railway public networking expects the web process to listen on the
Railway-providedPORT variable on 0.0.0.0. Railway terminates public
TLS at the platform edge for HTTP services, so the usual Pounce deployment is
plain HTTP inside the container withtrusted_hostsconfigured only after you
confirm the ingress peer addresses for your service.
Platform behavior in this page was last checked against Railway's public docs on 2026-07-08. Verify mutable networking details against the linked references and your service's request logs before changing a trust boundary.
Request Path And Protocol Hops
The two HTTP legs are independent:
| Leg | Contract |
|---|---|
| Client → Railway edge | Railway public networking accepts HTTPS and documents HTTP/1.1 and HTTP/2 client traffic. The edge owns the public certificate and TLS session. |
| Railway edge → Pounce | The edge selects an origin protocol independently. In the production incidents #231 and #232, this leg used HTTP/2 even when the external client used HTTP/1.1. Treat that as observed evidence, not a permanent platform guarantee. |
| Pounce → ASGI app | scope["http_version"], scope["scheme"], scope["client"], and scope["server"]describe Pounce's origin connection after trusted-proxy normalization. They do not reveal the client's protocol to the edge. |
Install the HTTP/2 extra when the Railway origin leg may negotiate HTTP/2:
uv add "bengal-pounce[h2]"
Confirm the actual origin protocol from Pounce access/lifecycle logs or a
diagnostic app that recordsscope["http_version"]; do not infer it from
curl --http1.1or the browser's client-side protocol.
Start Command
Use a small entrypoint when your app needs programmatic config:
# railway_app.py
import os
import pounce
from myapp import app
if __name__ == "__main__":
pounce.run(
app,
host="0.0.0.0",
port=int(os.environ["PORT"]),
workers=2,
health_check_path="/readyz",
shutdown_timeout=10,
log_format="json",
access_log=False,
)
Then set the Railway start command to:
python railway_app.py
For a plain import-string app that does not need custom ServerConfigvalues:
pounce serve --app myapp:app --host 0.0.0.0 --port "$PORT" --workers 2 --health-check-path /readyz --shutdown-timeout 10 --log-format json --no-access-log
A complete deployment bundle lives at
examples/deploy/railway.
It includes a CPython 3.14t Dockerfile, a GIL-off boot assertion,
railway.toml, the app/config example, and a smoke runner that proves initial
deployment plus continuous fast and slow traffic through a graceful redeploy.
The adjacentDockerfile.canaryinstalls the current repository checkout for
the public main-branch canary; the release Dockerfile remains pinned to a
published package version so those two proof claims cannot be confused.
The earlier single-file example
remains as a compatibility entrypoint.
Health Checks
Configure Railway's healthcheck path to/readyz, matching
health_check_path="/readyz". Railway uses the service PORTfor healthcheck
traffic, and healthchecks gate deployment activation rather than continuous
monitoring.
If your application rejects unexpected hostnames, allow
healthcheck.railway.appfor the health endpoint.
TLS And HTTP/3
Do not setssl_certfile, ssl_keyfile, or http3_enabled=Truefor Railway
public HTTP unless you have a separate raw UDP/TCP networking design. Railway's
public HTTP path provides platform TLS and forwards HTTP traffic to the process
port.
Inside the ASGI scope,scope["scheme"] will be httpunless you configure
trusted proxy headers and Railway forwardsX-Forwarded-Proto. Keep
trusted_hostsempty until the ingress trust boundary for the deployment is
known; untrustedX-Forwarded-*headers are stripped by default.
Choose TLS ownership by network path:
| Path | Pounce configuration |
|---|---|
| Railway public HTTP/HTTPS | Edge terminates TLS. Leavessl_certfile, ssl_keyfile, and http3_enabled unset; Pounce speaks HTTP on $PORT. |
| Railway private networking | Connect tohttp://<service>.railway.internal:<port>. Railway documents the private mesh as WireGuard-encrypted; no public edge or forwarded-header identity is involved. |
| Railway TCP Proxy or another raw TCP path | Pounce may terminate TLS and negotiate ALPN itself, but this is a separate design from Railway public HTTP. TCP proxying does not provide Pounce's UDP listener for HTTP/3. |
Trusted Proxy Worked Example
Proxy trust controls application-visible identity; it does not change the origin protocol. Start fail-closed:
from pounce import ServerConfig
config = ServerConfig(
host="0.0.0.0",
port=8000,
trusted_hosts=frozenset(), # default: strip X-Forwarded-* headers
)
After request logs or platform support confirm the direct ingress peer address, trust only that peer and set the number of trusted hops:
config = ServerConfig(
host="0.0.0.0",
port=8000,
trusted_hosts=frozenset({"10.0.0.12"}), # example; replace with observed peer
forwarded_for_trusted_hops=1,
)
The equivalent pounce.tomlfragment is:
trusted_hosts = ["10.0.0.12"] # example; replace with observed peer
forwarded_for_trusted_hops = 1
For the default one-hop case, the CLI also accepts a comma-separated peer allowlist:
pounce serve --app myapp:app --host 0.0.0.0 --port "$PORT" \
--trusted-hosts 10.0.0.12
Do not copy the example address. Do not use *merely because an edge address
is inconvenient to discover: wildcard trust makes a directly reachable Pounce
listener accept client-supplied forwarded identity.
Warning
Frameworks that embed Pounce must forward bothtrusted_hostsand
forwarded_for_trusted_hops into ServerConfig. Reading raw forwarded headers
inside application auth or rate limiting is not an equivalent trust boundary.
The broader embedding-surface audit is tracked in
#247.
What Each Header Changes
Pounce applies these values only when the direct peer matchestrusted_hosts:
| Header | Pounce behavior |
|---|---|
X-Forwarded-For |
Replacesscope["client"] with the entry selected from the right by forwarded_for_trusted_hops. If the header is absent, the direct peer remains the client. |
X-Forwarded-Proto |
Replacesscope["scheme"] when the value is http, https, ws, or wss. |
X-Forwarded-Host |
Replacesscope["server"] and the ASGI Hostheader, including a forwarded port. This is the authority host-routed tenant apps see. |
X-Request-ID |
Supplies the request ID only from a trusted direct peer; otherwise Pounce generates one. |
Railway's current public-networking specification documentsX-Real-IP,
X-Forwarded-Proto, and X-Forwarded-Host. Pounce does not consume
X-Real-IPas client identity. It remains an ordinary application-visible
header, so do not feed it directly into auth or rate limiting. Pounce can
normalizescope["client"]only when the verified edge supplies
X-Forwarded-For; otherwise keep edge-derived client identity at the edge or
add deployment-specific middleware with its own verified trust boundary.
Graceful Deploys
Set Pounce shutdown and Railway drain windows together. The checked-in
railway.tomlexpresses these values directly:
[deploy]
overlapSeconds = 5
drainingSeconds = 15
pounce.run(
app,
host="0.0.0.0",
port=int(os.environ["PORT"]),
shutdown_timeout=10,
health_check_path="/readyz",
)
The Railway drain window must be longer than Pounce's shutdown_timeoutso
in-flight requests can finish before the platform sends a hard kill. Railway's
default drain is zero seconds, so omitting this setting forfeits graceful
shutdown even when the server handlesSIGTERMcorrectly.
Smoke Proof
Every-merge main canary
The repository maintainer runs a best-effort public canary at
https://pounce-railway-smoke-production.up.railway.app. Railway watches the
GitHubmain branch and builds examples/deploy/railway/Dockerfile.canaryfrom
the repository root. The app reports the non-secret Railway git commit and
deployment channel alongside its Pounce/Python versions and GIL state.
On every merge,.github/workflows/railway-main-canary.ymlwaits until the
public app serves that exact commit, then checks/readyz, a normal request, a
slow request, and a finite SSE stream. The workflow uses no Railway credential;
deployment remains Railway-owned and the independent check observes only the
public origin. This is canary evidence for unreleasedmain, not a release or
uptime guarantee.
Full deploy and redeploy proof
Run the bundled proof against an explicit Railway project, environment, service, and public origin:
python examples/deploy/railway/smoke.py \
--project PROJECT_ID \
--environment production \
--service SERVICE_ID \
--origin https://SERVICE.up.railway.app
The runner does not infer a linked target. It uploads the recipe, waits for the
new deployment to reach terminalSUCCESS, checks /readyz, confirms the
container reportsgil_enabled=false, then uploads a second deployment while
continuously probing fast and slow requests. It exits nonzero on any dropped
request or failed deployment.
For deployments that enable and protect/_pounce/info, set
POUNCE_BUILD_IDto the git SHA or immutable release fingerprint injected by
your delivery pipeline. The endpoint returns that value verbatim alongside the
Pounce version, Python build, free-threaded capability, runtime GIL state, and
resolved worker model. Do not place secrets inPOUNCE_BUILD_ID, and do not
expose the introspection path publicly without reverse-proxy controls.
Long-lived SSE
Pounce treats an open SSE response as active work:header_timeout,
request_timeout, and keep_alive_timeoutdo not reap it. Send a lightweight
SSE comment heartbeat such as: keepalive\n\nevery 15–30 seconds for
intermediaries that close silent connections. This does not override
Railway's documented 15-minute maximum request duration; EventSource clients
must reconnect and use event IDs/backfill when continuity matters. See
Railway's SSE and WebSocket guide.
Multi-Tenant Host Routing
For Chirp or LB Sonic style host routing, prefer deriving tenant identity from
the ASGIHost header. Pounce preserves the request Hostdirectly, and when
a trusted proxy suppliesX-Forwarded-Host, Pounce rewrites both the ASGI
Host header and scope["server"]to that forwarded authority.
Confirm the actual Railway ingress headers for your deployed service before
enablingtrusted_hosts. If those details are unknown, leave trusted_hosts
empty and route tenants from the normalHostheader.
Frameworks that constructServerConfigon behalf of the app should also use
the Framework Embedding checklist so body limits,
lifecycle bounds, and proxy trust reach Pounce instead of stopping at the
framework config.