Deploy
deploy puts your whole stack online: the frontend, the admin app, the backend API, and the Postgres, Valkey, and
object storage they run on. It creates what is missing, wires the connection strings and origins into each app, and
saves every name it picked so the next run lands on the same infrastructure.
When you are ready to take traffic, pair this page with the Going to production checklist (auth callbacks, Stripe webhooks, Resend, migrations).
Topology
Users
|
+--> frontend (Fly or Railway)
+--> admin (Fly or Railway) [after a starter pack]
+--> backend (Fly or Railway)
|
+--> Postgres
+--> Valkey
+--> S3-compatible bucket (Tigris / RustFS)Public origins are resolved first so the frontend can bake VITE_API_BASE_URL and the backend can set CORS /
FRONTEND_URL without a chicken-and-egg loop.
Usage
npx @hype-stack/cli deployRun it from your project root, next to your stack.json. On a project that has never been deployed it asks one
question:
◆ Where should your stack live?
│ ● Fly.io everything on fly
│ ○ Railway everything on railway
│ ○ Mix providers choose for each app and serviceAnswer it and the CLI shows you the plan and waits for a confirmation before it creates anything billable. Pick "Mix
providers" and it asks per app and per service instead. Anything you already pinned with a flag or in stack.json is
never asked about again, so the second deploy asks nothing at all.
stack.json is required. It is where your app paths come from and where the deploy records what it created, so a
directory without one is not a project this command can deploy.
To see the whole thing without touching an account:
npx @hype-stack/cli deploy --dry-run --yes --provider flyThat prints every command the real run would issue, in order.
Providers
| Provider | Apps | Services | CLI |
|---|---|---|---|
| Fly.io | backend, frontend, admin | Postgres (Managed Postgres), Valkey, Tigris | fly |
| Railway | backend, frontend, admin | Postgres, Redis, RustFS | railway |
Both of them do everything, and that is deliberate. A provider is only in this list if one command can put your whole stack on it: all three apps, plus Postgres, Valkey, and an S3-compatible bucket, with nothing left for you to click together afterwards. Picking one is a preference, never a trade-off.
That rules out the static hosts. Cloudflare Workers and Netlify are both good at the frontend half, but neither runs the backend (it holds WebSockets open, keeps a Prisma pool, and talks to Valkey), and neither has managed Postgres. Choosing one would mean assembling the rest by hand, which is the opposite of what this command is for.
You need to be logged into each provider CLI you use (fly auth login, railway login). deploy checks before it
creates anything and prints the exact login command if one is missing. For CI, FLY_API_TOKEN and RAILWAY_TOKEN work
instead.
If your account belongs to more than one Fly organization or Railway workspace, the CLI asks the provider which ones
exist rather than making you look it up. With one, it just uses it. With several, you pick from a list, right after the
login and before anything is created. --fly-org and --railway-workspace answer it up front for a run with nobody
watching. Either way the answer is saved, so joining a second org later cannot make your next deploy ambiguous.
Mixing providers
Nothing forces one provider for everything. You can split the stack however you like:
npx @hype-stack/cli deploy \
--backend fly --postgres fly --valkey fly \
--frontend railway --admin railway --bucket railwayWhen the backend and a database end up on different providers, the CLI resolves a publicly reachable connection string instead of a private-network reference, because a private one would never resolve from the other side.
What it wires up
The frontends inline the API origin at build time and the backend needs their origins for CORS, which is a cycle. The CLI breaks it by settling every public URL first, before anything is built. Then:
- Backend gets
DATABASE_URL,VALKEY_URL,VALKEY_PASSWORD, theRUSTFS_*storage credentials,BUCKET_NAME,FRONTEND_URL,ADMIN_URL,SERVER_URL,NODE_ENV, andAPP_ENV. - Frontend and admin get
VITE_API_BASE_URLand the sibling app URLs, baked into the bundle at build time.
Anything else your apps declare in .env.example (a Resend key, a WorkOS key) is carried over from your local .env,
except values pointing at localhost and values built from ${...} interpolation, which only make sense on your machine.
Whatever nothing could fill is listed at the end so you know what is still unset.
Secrets go to the provider over stdin, never as command arguments, and are masked in anything the CLI prints.
How each app is built
Your Dockerfiles are the deployment, and the CLI uses them. The template ships one for all three apps, so each is built
from its own apps/<app>/Dockerfile. Nothing is guessed and nothing is written over the top.
An app with no Dockerfile still deploys. The CLI builds it locally and wraps the output in a generated nginx image,
which is what you get for a plain Vite SPA. The backend is the exception: it holds WebSockets and a Prisma pool open, so
there is no static version of it and the deploy stops if apps/backend/Dockerfile is missing. That check runs while the
plan is being validated, before a single app or database exists.
Build-time values reach your Dockerfile the way each provider expects. Fly passes every VITE_* value as --build-arg;
Railway matches your ARG names against the service variables it already set. Declare the ones you need with ARG in
the stage that uses them, as the template does.
Where the settings live
Everything the deploy resolves is written to the deploy block of your stack.json, including after a failed run, so
what already exists is reused rather than duplicated. The second deploy is just:
npx @hype-stack/cli deploy --yes{
"version": 1,
"name": "acme",
"apps": { "backend": "apps/backend", "frontend": "apps/frontend" },
"installedPacks": ["starter-saas-workos"],
"deploy": {
"version": 1,
"projectName": "acme",
"environment": "production",
"targets": {
"backend": { "provider": "fly", "app": "acme-backend" },
"frontend": { "provider": "railway", "app": "acme-frontend" }
},
"services": {
"postgres": { "provider": "fly", "name": "acme-postgres" }
},
"fly": { "org": "acme-inc" }
}
}Commit it. It is configuration, not a secret: no credentials are stored in it. Saving merges into stack.json rather
than replacing it, so a deploy never disturbs the pack install log that compose writes.
Each provider has a block for the few things it cannot work out on its own. The org and workspace are filled in for you on the first deploy and pinned from then on:
{
"deploy": {
"fly": { "org": "acme-inc", "postgresPlan": "launch" },
"railway": { "workspace": "acme" }
}
}Bring your own Postgres, Valkey, or bucket
To point at infrastructure you already run, put the connection values in external and the CLI skips provisioning for
that service. Use this when you only want the CLI to ship the apps. You still need the env keys each app expects
(DATABASE_URL, VALKEY_URL, RUSTFS_*, BUCKET_NAME). See Storage.
{
"deploy": {
"services": {
"postgres": {
"provider": "fly",
"external": { "DATABASE_URL": "postgres://user:pass@your-host/db" }
}
}
}
}Custom domains
Set url on a target and the CLI uses it everywhere instead of the provider-generated origin, including in the other
apps' build-time env. Point the DNS at your provider first.
{
"deploy": {
"targets": {
"backend": { "provider": "fly", "app": "acme-backend", "url": "https://api.acme.dev" },
"frontend": { "provider": "fly", "app": "acme-frontend", "url": "https://app.acme.dev" }
}
}
}After custom domains land, update anything that still points at the provider hostname:
- WorkOS or Google OAuth redirect URIs
- Better Auth
BETTER_AUTH_URLand/api/auth/callback/*entries - Stripe webhook endpoint URL
- Resend domain / link hosts if you embed absolute URLs outside
frontendUrl()
Migrations
The CLI prefers a migration:latest script from your backend's package.json and falls back to prisma migrate deploy
against whichever schema layout you have.
On Fly it goes into the generated fly.toml as a release_command. Fly runs it in a temporary machine built from the
image it just built, before any traffic moves over, and aborts the deploy if it fails. That is the behavior you want: a
new version never serves requests against a schema it cannot use, and no SSH key is involved.
Railway calls it a pre-deploy command and reads it from a railway.json. Every railway up uploads its own snapshot of
your project, so the CLI writes that file for the app it is about to push and deletes it once the upload is done. Same
guarantee as Fly: the migration runs in the image being shipped, and a failure fails the release.
If you already have a railway.json or railway.toml, it is left alone, because overwriting your config to add one key
is not a trade the CLI gets to make. Migrations then fall back to railway ssh after the deploy. A failure there does
not mark the deploy failed, since the app is up, and the CLI prints the exact command along with the provider's reason
(usually a missing registered SSH key).
Skip the step either way with --skip-migrations.
Health checks
Both providers are told which path to poll before the new version takes traffic: /ping for the backend, / for the
frontends, with a 30 second timeout. Without one, a build that starts and immediately crashes still counts as a
successful deploy.
On Fly this is a [[http_service.checks]] block in the generated fly.toml. On Railway it rides along in the same
railway.json as the pre-deploy command, so a project with its own config file sets its own health checks too.
Flags
| Flag | What it does |
|---|---|
-p, --provider <provider> | Provider for everything not assigned separately |
--backend <provider> | Provider hosting the backend API |
--frontend <provider> | Provider hosting the frontend |
--admin <provider> | Provider hosting the admin app |
--postgres <provider> | Provider for the Postgres database |
--valkey <provider> | Provider for the Valkey cache |
--bucket <provider> | Provider for object storage |
--only <targets> | Deploy just these apps, comma-separated |
-e, --environment <name> | Deployment environment name (default: production) |
--fly-org <org> | Fly organization, skips the picker when you belong to several |
--railway-workspace <name> | Railway workspace, skips the picker the same way |
--dry-run | Print every command that would run without creating anything |
--skip-provision | Assume Postgres, Valkey, and the bucket already exist |
--skip-migrations | Do not run database migrations after the backend deploys |
-y, --yes | Skip confirmation prompts |
--cwd <path> | Run against a different working directory |
Generated files
When an app has no Dockerfile of its own and lands on a container provider, the CLI has to supply one. It writes what it
needs into .hype-stack/deploy/ (an nginx config, a Dockerfile per such app, a fly.toml per Fly app) and adds that
directory to .gitignore. Everything in there is reproducible, so there is nothing to commit.
Apps that ship their own Dockerfile, which is all three in the template, never get one generated.
The one file written outside that directory is railway.json at the project root, and only when you do not already have
one. It exists just long enough for railway up to upload it and is deleted afterwards, so it never lands in a commit.
Every purchase and sponsorship funds my 8+ years of work on open source given freely to the community. It keeps the lights on, funds new packs, and keeps the ecosystem alive. Even a small tier means a lot. Thank you!
