Hire & LiteLLM Deployment
apps/hire deploys to Railway the same way ops and pool do — GitHub Actions
pushes variables with railway variables set, then runs railway up. The one
new piece is litellm, a container service rather than a workspace app, which
every model call in hire routes through.
Two reusable workflows do the work, both called from deploy-staging.yml and
deploy-production.yml:
| Workflow | Railway service | Builder |
|---|---|---|
.github/workflows/litellm-deploy.yml | litellm | Dockerfile (infra/litellm/Dockerfile) |
.github/workflows/hire-deploy.yml | hire | Railpack (pnpm workspace) |
hire is deployed after litellm in both orchestrators, since it is useless
without a reachable proxy. Neither depends on api.
Deploying a branch to staging before merge
Section titled “Deploying a branch to staging before merge”deploy-staging.yml takes a services input:
all(default) — every service, current behaviour.hire-and-litellm— only these two, leaving the rest of staging untouched.
Run it from Actions → deploy-staging → Run workflow, selecting your branch.
workflow_dispatch resolves the workflow file and the reusable workflows it
calls from the ref you pick, so a branch that changes either deploy workflow is
exercised as written before it merges.
One-time provisioning
Section titled “One-time provisioning”None of this is created by CI — it has to exist in the Railway project first.
Everything is per-environment (staging and prod).
- Create two services, named exactly
hireandlitellm. CI addresses them by name (--service hire), and the Railway project itself is resolved fromRAILWAY_TOKEN. litellmneeds its own Postgres database. It stores virtual keys, teams and spend logs under its own schema, so give it a database separate from the platform one — either a dedicated Railway Postgres service, or alitellmdatabase inside the existing one, mirroring howdocker-compose.ymlreuses the shared local Postgres. Put the resulting URL inLITELLM_DATABASE_URL.litellmneeds Redis for its response cache (LITELLM_REDIS_URL).hireneeds its own Postgres database (HIRE_POSTGRES_URL). Its schema is Drizzle-managed and unrelated to the Prisma schema inpackages/database; migrations run automatically at container start (see below). Redis is optional (HIRE_REDIS_URL) — without it, rate limiting and resumable streams are skipped, nothing breaks.- Create the
usersteam in the LiteLLM dashboard.apps/hire/lib/litellm.tslooks up a team whose alias is literallyusersand provisions a per-user virtual key against it. If that team is missing, key generation fails silently and every chat request is rejected withbad_request:chat. Grant it access to at least thedefaultmodel alias. - Enable Magic Auth in the WorkOS dashboard for the project behind
WORKOS_CLIENT_ID. Auth happens inline in the chat with no hosted pages, so there is no redirect URI to configure — butcreateMagicAutherrors until Magic Auth itself is switched on.
Secrets and variables
Section titled “Secrets and variables”Set per GitHub Environment (staging / production). Reuse the existing
RAILWAY_TOKEN, WORKOS_* and provider-key secrets; the rest are new.
Secrets
Section titled “Secrets”| Name | Used by | Notes |
|---|---|---|
HIRE_POSTGRES_URL | hire | Required. Read at container start (migrations) and at runtime. |
HIRE_REDIS_URL | hire | Optional. Empty values are skipped rather than sent. |
LITELLM_DATABASE_URL | litellm | Required. |
LITELLM_REDIS_URL | litellm | Required for the response cache. |
LITELLM_MASTER_KEY | both | The same value must reach both services — hire authenticates to the proxy with it. |
LITELLM_SALT_KEY | litellm | Encrypts stored provider keys. Changing it makes every stored key undecryptable, so set it once per environment and leave it. |
SIGNALHIRE_API_KEY | hire | Optional; candidate search degrades without it. |
XAI_API_KEY | litellm | New — backs the grok-* models and the default alias. |
WORKOS_API_KEY, WORKOS_CLIENT_ID, WORKOS_COOKIE_PASSWORD | hire | Existing, shared with ops/pool. The cookie password must be ≥32 characters — lib/auth/workos.ts throws below that. hire scopes its cookie by name (hire_auth) so it cannot collide. |
OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_GENAI_API_KEY | litellm | Existing. Only the providers you actually route to are needed. |
Variables
Section titled “Variables”| Name | Notes |
|---|---|
LITELLM_API_BASE_URL / LITELLM_API_BASE_URL_PROD | The litellm service URL including the /v1 suffix — e.g. https://litellm-staging.up.railway.app/v1. lib/ai/providers.ts uses it verbatim as the OpenAI base URL, and lib/litellm.ts takes its origin for the management API. |
RAILWAY_PORT_HIRE / RAILWAY_PORT_HIRE_PROD | Optional, defaults to 3000. |
HIRE_SITE_URL / HIRE_SITE_URL_PROD | The public origin hire is served from, no trailing slash — e.g. https://hire-staging.up.railway.app. Becomes NEXT_PUBLIC_SITE_URL, which metadataBase resolves canonical and OpenGraph URLs against, and which robots.ts/sitemap.ts emit. Wrong here means wrong canonicals, so set it per environment. |
Credentials that exist only in production should be generated fresh and stored
in 1Password as the system of record, then copied into the production GitHub
Environment. Staging can reuse the values already in local .env / .env.local.
Things worth knowing
Section titled “Things worth knowing”hire’s start script is bypassed. package.json hardcodes --port 3002,
which would ignore the port Railway injects, so hire-deploy.yml sets
RAILPACK_START_CMD to run next start -H 0.0.0.0 -p ${PORT} directly. This is
the same override ops and pool use.
Migrations run at container start, not during the build. Railway’s private
network (*.railway.internal) only resolves at runtime, so a build-time
migration cannot reach the database — it fails with
getaddrinfo ENOTFOUND postgres.railway.internal. RAILPACK_START_CMD
therefore runs tsx lib/db/migrate.ts immediately before next start.
Drizzle’s migrator records what it has applied, so re-running on every restart
is a no-op once the schema is current. Check the deploy log (not the build
log) for Migrations completed after shipping a schema change.
Because of this, apps/hire’s build script is plain next build. Run
pnpm --filter hire db:migrate by hand against a local database.
LiteLLM’s config is baked into the image. docker-compose.yml bind-mounts
litellm.config.yaml; Railway has no bind mounts, so infra/litellm/Dockerfile
copies it in instead. The build context is the repo root, and Railway is pointed
at the Dockerfile via the RAILWAY_DOCKERFILE_PATH variable. A change to
litellm.config.yaml therefore requires a litellm redeploy, not just a restart.
The litellm image tag is latest, matching docker-compose.yml so staging
behaves like local dev. Pin it to a released tag before relying on it in
production.
Production is gated on the pre-release backup. Both new jobs needs: the
same backup job as the rest of the release, so a failed backup blocks them too.