arti-axum-railway
Axum server as Tor Onion service on Railway
An Axum web server published as a Tor onion service, with the whole stack — the server and a Tor client — running inside a single container on Railway. The Tor side is Arti, the ground-up reimplementation of Tor in Rust from the Tor Project, run as a managed child process. The same service also answers on an ordinary public port, so a request reaches it either anonymously through the Tor network or directly over the open internet.
Railway, like most platforms-as-a-service, expects one process listening on one HTTP port. An onion service breaks both assumptions: it needs a Tor client running alongside the web server, and that client exposes no inbound port for the platform to route to. Most of what is interesting here is the plumbing that makes those two shapes fit together.
One process, supervising another
A container runs a single command, but this one needs two long-lived processes — the web server and arti — with no init system to babysit them. Rather than add tini or a separate process manager, the Axum server itself becomes the supervisor.
On startup it spawns arti proxy as a child process and watches it. If arti exits, the server restarts it after a short backoff, up to five times; beyond that it gives up and brings the whole service down rather than serve traffic with no Tor client behind it. The child is spawned with kill_on_drop, so a crash or shutdown of the parent never leaves an orphaned Tor process behind.
Shutdown travels the other direction. A SIGTERM from Railway, or Ctrl-C locally, is caught once and fanned out over a broadcast channel to every task — both HTTP listeners and the supervisor — so in-flight requests drain and the Tor child is killed cleanly, all inside the short draining window that Railway allows.
The trust boundary
The public endpoint and the onion endpoint are two separate Axum routers on two separate listeners, and the entire difference between them is one bind address.
The onion server binds to 127.0.0.1 only. It is unreachable from outside the container; the sole path to it is the onion proxy in Arti, which forwards port 80 of the service to that loopback address. Binding to loopback is what enforces the property that onion traffic genuinely arrives through Tor — there is no public socket left to bypass it with. The public server binds to 0.0.0.0 on the PORT injected by Railway and takes everything else.
Each handler knows which door the visitor used, and says so. The onion page confirms the connection came through Tor; the public page notes that the connection is direct, or indirect if an exit node carried it there.
Finding its own address
An onion address is derived from the service keypair, which Arti generates on first run — so the server does not know its own address until Arti has settled on one. There is no in-process way to ask for it either, because Arti runs as a separate binary rather than a library here.
So the server asks the only way it can: a background task shells out to arti hss --nickname demo onion-address and reads the result. A few seconds after boot it begins polling, checking each line of output against the shape of a v3 onion address until one appears, then storing it in shared state for the handlers to render. If nothing shows up within the timeout, the page simply reports that discovery is still in progress.
The Docker build
The image is a multi-stage build. Dependencies compile separately from application code using cargo-chef, so a change to the source does not trigger a full rebuild of the dependency tree. Arti itself is compiled from source with cargo install arti --features=full --locked, which is by far the slowest step. The final stage copies only the two resulting binaries onto a slim Debian base and runs them as a non-root user.
Tradeoffs
This is deliberately not the minimal example. Compiling Arti from source makes the image large and the first build slow; a published binary or a prebuilt image would be far lighter. More importantly, the onion service keeps no persistent storage — its keypair lives on the ephemeral filesystem of the container, so every redeploy generates a brand-new onion address. A real service would mount a volume and hold onto its keys; for a throwaway demo, a fresh address each deploy is acceptable.
The README tracks the rest of the rough edges: a cleaner Dockerfile, persistent addresses, health-check endpoints, and a genuinely barebones version for anyone who just wants the smallest thing that works.