Deploy
deploy ships everything the template can produce. It is three pipelines under one command:
| Command | What it ships |
|---|---|
deploy web | Frontend, admin, backend, plus Postgres, Valkey, and object storage |
deploy mobile | The Expo app, built (and optionally submitted) through EAS |
deploy extension | The browser extension, published to the Chrome, Edge, and Firefox stores |
Bare npx @hype-stack/cli deploy asks which of the three to run, offering only the apps your project actually has, and
runs your picks in order. With --yes it runs the web pipeline alone, which is what the command did before the split.
After a deploy web finishes, the CLI also offers the other two when those apps exist, so one session can take the
whole stack out. Decline with a keypress, or pre-answer with --with-mobile / --with-extension.
When you are ready to take traffic, pair this page with the Going to production checklist (auth callbacks, Stripe webhooks, Resend, migrations).
deploy web
deploy web puts your web 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.
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 deploy webRun 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 web --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_API_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 web \
--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 web --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 - The billing webhook endpoint URL at Stripe, Lemon Squeezy, or Polar, and the Resend webhook if the newsletter pack is installed
- 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 |
--with-mobile | Run deploy mobile afterwards without asking |
--with-extension | Run deploy extension afterwards without asking |
-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.
deploy mobile
deploy mobile builds the Expo app through EAS, Expo's build and submission service. There is
nothing to provision here: EAS runs the builds on its own machines, signs them, and can hand them to the app stores. The
CLI's job is to line the config up and queue the right builds.
For local development and device testing, see Mobile. That page covers Metro and the Expo Go build from sign.expo.dev.
npx @hype-stack/cli deploy mobileIt asks which platforms to build (Android, iOS, or both), whether to auto-submit the builds to the stores, and whether
to also push an over-the-air update. Every answer is saved to the mobile block of stack.json, so the next run
defaults to the same choices.
What a run does
- Checks your EAS session. The CLI bootstraps
eas-cliif it is not installed, then runseas whoami. Not logged in? It stops and tells you to runeas login, or to setEXPO_TOKENfor CI (create one at expo.dev/settings/access-tokens). - Lines up
eas.json. Missing, it is created with your chosen build profile. Present, only that profile'sEXPO_PUBLIC_API_URLis patched in; the rest of your file is left alone. - Points the app at your API. The backend URL saved by
deploy webbecomesEXPO_PUBLIC_API_URLin the build profile. If the backend has not been deployed yet, the CLI warns instead of baking in nothing: rundeploy webfirst, then come back. - Links the EAS project. If
app.jsonhas no project id yet,eas initruns non-interactively to create or pick one. - Queues the builds. One
eas build --no-waitper platform, with--auto-submitwhen you asked for store submission. A failed Android build never blocks the iOS one; each failure carries the EAS output into the summary. - Optionally publishes an OTA update. With
--update,eas updatepushes your JS changes to the channel matching the build profile, so installed apps pick them up without a store release.
Builds are queued, not awaited: the command returns as soon as EAS has them, and the summary links each build's page on expo.dev where you can watch it finish.
Flags
| Flag | What it does |
|---|---|
--platform <platforms> | Platforms to build, comma-separated: android, ios, or both |
--profile <profile> | EAS build profile from eas.json (default: production) |
--submit | Auto-submit each build to its app store (eas build --auto-submit) |
--update | Also push an OTA update (eas update) after the builds are queued |
--dry-run | Print every command that would run without executing any |
-y, --yes | Skip prompts, reuse the saved answers |
--cwd <path> | Run against a different working directory |
Store submission has its own prerequisites on the Expo side (an Apple Developer account, a Google Play service account key). EAS walks you through those the first time; the EAS Submit docs cover the details.
deploy extension
deploy extension builds the browser extension and publishes it to the Chrome Web Store, Edge Add-ons, and Firefox
Add-ons (AMO). Pick any subset of the three; each store is its own step, and one store failing or missing credentials
never blocks the others.
npx @hype-stack/cli deploy extensionThe chrome build (pnpm build) is zipped and feeds both Chrome and Edge, since Edge accepts the same MV3 package. The
firefox build (pnpm build:firefox) is signed and submitted through web-ext. Zips land in
.hype-stack/deploy/extension/, which is gitignored.
Store credentials
Credentials are read from the environment at run time and are never written to stack.json (only plain identifiers like
the extension id are saved, so the next run can prefill them). A store whose keys are unset is skipped with
instructions, not failed, so the first run without any credentials doubles as the setup guide.
| Store | Env keys | Where to create them |
|---|---|---|
| Chrome Web Store | CHROME_CLIENT_ID, CHROME_CLIENT_SECRET, CHROME_REFRESH_TOKEN, CHROME_EXTENSION_ID | Chrome Web Store API guide |
| Edge Add-ons | EDGE_CLIENT_ID, EDGE_API_KEY, EDGE_PRODUCT_ID | Edge Add-ons API guide |
| Firefox Add-ons | AMO_JWT_ISSUER, AMO_JWT_SECRET | AMO API keys |
Each store expects the extension to already exist there once: the first upload of a brand new extension goes through the store's own dashboard (that is where the extension id / product id comes from). Every release after that is this command.
Flags
| Flag | What it does |
|---|---|
--stores <stores> | Stores to publish to, comma-separated: chrome, edge, firefox |
--dry-run | Print every command that would run without executing any |
-y, --yes | Skip prompts, reuse the saved store selection |
--cwd <path> | Run against a different working directory |
Publishing queues a review at every store; the command reports "submitted" and the stores take it from there. Review times are the stores' own: hours to days for Chrome and Firefox, up to a week for Edge.
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!
