Skip to content

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

3 Commits

Folders and files

Repository files navigation

omniroute-railway

Deploy on Railway

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.

What this image changes, and why

1. The volume has to land on /app/data, and it has to be writable

Upstream's own Dockerfile is explicit:

# Data directory inside Docker — must match the volume mount in docker-compose.yml
ENV DATA_DIR=/app/data

Everything 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.

2. INITIAL_PASSWORD — a passwordless instance can be un-authenticated by anyone

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).

3. The heap is sized from the cgroup, not baked at 1 GB

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.

4. The tag is pinned

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.

What this image deliberately does not change

  • No Redis. REDIS_URL is read only by src/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 as QUOTA_STORE_REDIS_URL, which the quota engine reads separately and which also defaults to SQLite.
  • No CLOUD_URL. src/lib/cloudSync.ts POSTs 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.
  • PORT is left to Railway. The app honours the injected value; the template's domain targets it.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages