Railway

Deploying Pounce on Railway public networking

8 min read 1553 words

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

flowchart LR Client["Client\nHTTPS over HTTP/1.1 or HTTP/2"] Edge["Railway edge\nTLS termination + routing headers"] Origin["Pounce origin\n0.0.0.0:$PORT"] App["ASGI app\nscope reflects origin hop"] Client --> Edge Edge -->|"independently negotiated HTTP hop"| Origin Origin --> App

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.

References