Deploy on Railway: OmniRoute template
Railway-tuned OmniRoute image: the gateway's providers, endpoints and API keys survive a redeploy, the dashboard is password-gated from the first boot, and the V8 heap is sized from the container instead of a baked-in 1 GB.
Image: ghcr.io/bon5co/omniroute-railway:latest — FROM diegosouzapw/omniroute:3.8.49.
OmniRoute is a self-hosted AI gateway: one OpenAI-compatible /v1 endpoint in front of many
LLM providers, with routing rules, fallback and per-key quotas.
Upstream's own Dockerfile is explicit:
# Data directory inside Docker — must match the volume mount in docker-compose.yml
ENV DATA_DIR=/app/dataEverything the gateway knows lives there — storage.sqlite (provider connections,
endpoints, API keys, routing rules) and server.env, which holds the auto-generated
JWT_SECRET and STORAGE_ENCRYPTION_KEY that the stored provider credentials are
encrypted with:
# Auto-generated by OmniRoute bootstrap — do not delete
JWT_SECRET=2f8fe8f1…
STORAGE_ENCRYPTION_KEY=54cc6f78…
STORAGE_ENCRYPTION_KEY_VERSION=v1
Mounting a volume anywhere else persists nothing, and the container looks perfectly healthy
while it does so. Measured on 3.8.49 with the volume at /data, then recreating the
container the way a Railway redeploy does:
| container 1 | container 2 (same volume) | |
|---|---|---|
/app/data/storage.sqlite |
1687552 bytes | regenerated |
JWT_SECRET |
2f8fe8f1… |
758e9fe8… |
STORAGE_ENCRYPTION_KEY |
54cc6f78… |
4c4dd6e8… |
| the mounted volume | empty | empty |
Every provider, endpoint and API key is gone, and the key that could have decrypted the old credentials is gone with them.
Mounting on the correct path is not sufficient either, which is the trap worth knowing
about. Railway mounts volumes owned by uid 0 and this image runs as node (uid 1000), so
the app cannot write into its own data directory — and it does not crash. It reports
healthy, logs [DB] SQLite database ready, and fails every write underneath:
WARNING: /app/data is not writable by the current user (UID 1000).
[bootstrap] ⚠️ Could not persist secrets to /app/data/server.env: EACCES: permission denied
[Migration] Failed to create pre-migration backup: EACCES: permission denied
[sqljsAdapter] save failed: Error: EACCES: permission denied, open '/app/data/storage.sqlite'
railway-entrypoint.sh repairs ownership as root and then drops to uid 1000 with setpriv
before exec'ing the stock server. Verified: with the volume mounted root-owned at
/app/data, the entrypoint logs owner=1000:1000, no EACCES appears, the SQLite database
and server.env are written into the volume, and a fresh container reads back the same
JWT_SECRET and STORAGE_ENCRYPTION_KEY.
With no password configured, OmniRoute leaves two paths outside the auth check
(src/shared/utils/apiAuth.ts), so a first-run visitor can complete onboarding:
function isRequireLoginBootstrapWritePath(pathname: string | null, method: string): boolean {
return pathname === "/api/settings/require-login" && method.toUpperCase() === "POST";
}and isAuthRequired() checks settings.requireLogin === false first, before anything
else. On a public URL that composes into a full bypass. Measured against 3.8.49 with no
INITIAL_PASSWORD, every request anonymous and from off-box:
GET /api/keys -> 401 # looks secure
POST /api/settings/require-login -> 200 {"success":true}
GET /api/keys -> 200 # auth is now off, for everyone, permanently
At that point whoever found the URL can read and mint API keys and add provider connections
against the deployer's credits. With INITIAL_PASSWORD set, the same three calls are
401 / 401 / 401.
This image does not set a password itself — a baked one would be identical on every deploy.
The Railway template generates it per deploy as ${{secret(24)}}, which Railway shows in the
service's variables panel. POST /api/auth/login with that value returns 200, a wrong one
401 (verified).
Upstream pins the ceiling in the image:
ENV OMNIROUTE_MEMORY_MB=1024
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_MEMORY_MB}"with its own comment noting 1024 "can be tight for large fusion-combo panels (many models
fanned out in parallel, each response buffered in full)". On Railway the container size is
chosen at deploy time, so the entrypoint reads /sys/fs/cgroup/memory.max and takes 70% of
it. Measured v8.getHeapStatistics().heap_size_limit:
| container | stock image | this image |
|---|---|---|
| 8 GB | 1120 MB | 5830 MB |
| 2 GB | 1120 MB | 1481 MB |
Both OMNIROUTE_MEMORY_MB and NODE_OPTIONS are cleared in the Dockerfile, not just
NODE_OPTIONS: the entrypoint treats a set OMNIROUTE_MEMORY_MB as a deployer override, so
the baked 1024 suppressed the calculation entirely until both were cleared (the entrypoint
reported heap=1024MB inside a 2 GB container). A deployer-supplied OMNIROUTE_MEMORY_MB
still wins.
3.8.49, not latest. OmniRoute ships forward-only SQLite migrations
(OMNIROUTE_MIGRATIONS_DIR, 122 of them at this version), so an unpinned image upgrades the
deployer's database on a redeploy they did not ask for.
- No Redis.
REDIS_URLis read only bysrc/shared/utils/rateLimiter.ts, which falls back to in-memory rate limiting when it is unset (#2357— "Redis is optional"). A Railway template deploy is single-replica, so a shared rate-limit backend buys nothing a process-local one does not already give, and the second service is pure bill. Note this is not the same asQUOTA_STORE_REDIS_URL, which the quota engine reads separately and which also defaults to SQLite. - No
CLOUD_URL.src/lib/cloudSync.tsPOSTs the config bundle —providers,apiKeys,settings— to${CLOUD_URL}/sync/${machineId}. Leaving it unset means opt-in cloud sync stays unconfigured rather than pointed somewhere the deployer did not choose. - No start command. A Railway start command replaces
ENTRYPOINT, which would skip every fix above. PORTis left to Railway. The app honours the injected value; the template's domain targets it.