Hype StackHypeStack

Search

Search the packs, templates, docs, and pages

Deploy

deploy ships everything the template can produce. It is three pipelines under one command:

CommandWhat it ships
deploy webFrontend, admin, backend, plus Postgres, Valkey, and object storage
deploy mobileThe Expo app, built (and optionally submitted) through EAS
deploy extensionThe 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

bash
npx @hype-stack/cli deploy web

Run it from your project root, next to your stack.json. On a project that has never been deployed it asks one question:

text
◆  Where should your stack live?
│  ● Fly.io          everything on fly
│  ○ Railway         everything on railway
│  ○ Mix providers   choose for each app and service

Answer 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:

bash
npx @hype-stack/cli deploy web --dry-run --yes --provider fly

That prints every command the real run would issue, in order.

Providers

ProviderAppsServicesCLI
Fly.iobackend, frontend, adminPostgres (Managed Postgres), Valkey, Tigrisfly
Railwaybackend, frontend, adminPostgres, Redis, RustFSrailway

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:

bash
npx @hype-stack/cli deploy web \
  --backend fly --postgres fly --valkey fly \
  --frontend railway --admin railway --bucket railway

When 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, the RUSTFS_* storage credentials, BUCKET_NAME, FRONTEND_URL, ADMIN_URL, SERVER_URL, NODE_ENV, and APP_ENV.
  • Frontend and admin get VITE_API_BASE_URL and 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:

bash
npx @hype-stack/cli deploy web --yes
json
{
  "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:

json
{
  "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.

json
{
  "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.

json
{
  "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_URL and /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

FlagWhat 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-runPrint every command that would run without creating anything
--skip-provisionAssume Postgres, Valkey, and the bucket already exist
--skip-migrationsDo not run database migrations after the backend deploys
--with-mobileRun deploy mobile afterwards without asking
--with-extensionRun deploy extension afterwards without asking
-y, --yesSkip 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.

bash
npx @hype-stack/cli deploy mobile

It 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

  1. Checks your EAS session. The CLI bootstraps eas-cli if it is not installed, then runs eas whoami. Not logged in? It stops and tells you to run eas login, or to set EXPO_TOKEN for CI (create one at expo.dev/settings/access-tokens).
  2. Lines up eas.json. Missing, it is created with your chosen build profile. Present, only that profile's EXPO_PUBLIC_API_URL is patched in; the rest of your file is left alone.
  3. Points the app at your API. The backend URL saved by deploy web becomes EXPO_PUBLIC_API_URL in the build profile. If the backend has not been deployed yet, the CLI warns instead of baking in nothing: run deploy web first, then come back.
  4. Links the EAS project. If app.json has no project id yet, eas init runs non-interactively to create or pick one.
  5. Queues the builds. One eas build --no-wait per platform, with --auto-submit when you asked for store submission. A failed Android build never blocks the iOS one; each failure carries the EAS output into the summary.
  6. Optionally publishes an OTA update. With --update, eas update pushes 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

FlagWhat it does
--platform <platforms>Platforms to build, comma-separated: android, ios, or both
--profile <profile>EAS build profile from eas.json (default: production)
--submitAuto-submit each build to its app store (eas build --auto-submit)
--updateAlso push an OTA update (eas update) after the builds are queued
--dry-runPrint every command that would run without executing any
-y, --yesSkip 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.

bash
npx @hype-stack/cli deploy extension

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

StoreEnv keysWhere to create them
Chrome Web StoreCHROME_CLIENT_ID, CHROME_CLIENT_SECRET, CHROME_REFRESH_TOKEN, CHROME_EXTENSION_IDChrome Web Store API guide
Edge Add-onsEDGE_CLIENT_ID, EDGE_API_KEY, EDGE_PRODUCT_IDEdge Add-ons API guide
Firefox Add-onsAMO_JWT_ISSUER, AMO_JWT_SECRETAMO 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

FlagWhat it does
--stores <stores>Stores to publish to, comma-separated: chrome, edge, firefox
--dry-runPrint every command that would run without executing any
-y, --yesSkip 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.

Sponsor open source

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!

Sponsor on GitHub