Source: https://www.hype-stack.dev/docs/cli/deploy

# 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](/docs/getting-started/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

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

```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](/docs/backend/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

| 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](https://expo.dev/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](/docs/mobile). That page covers Metro and the Expo Go build from
[sign.expo.dev](https://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](https://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

| 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](https://docs.expo.dev/submit/introduction/)
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.

| 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](https://developer.chrome.com/docs/webstore/using-api)                                 |
| Edge Add-ons     | `EDGE_CLIENT_ID`, `EDGE_API_KEY`, `EDGE_PRODUCT_ID`                                       | [Edge Add-ons API guide](https://learn.microsoft.com/en-us/microsoft-edge/extensions/publish/api/using-addons-api) |
| Firefox Add-ons  | `AMO_JWT_ISSUER`, `AMO_JWT_SECRET`                                                        | [AMO API keys](https://addons.mozilla.org/en-US/developers/addon/api/key/)                                         |

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.
