Hype StackHypeStack

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

bash
npx @hype-stack/cli deploy

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 --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_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 \
  --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 --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
  • 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

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

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