# /iblai-vibe-ops-deploy

> Mirrored from [`iblai/vibe`](https://github.com/iblai/vibe) · [`skills/ship/iblai-vibe-ops-deploy/SKILL.md`](https://github.com/iblai/vibe/blob/main/skills/ship/iblai-vibe-ops-deploy/SKILL.md). This page is generated — edit it in the repository, not here.

> **First time here?** If `iblai.env` has no `ARCHITECTURE=`, run `/iblai-vibe-start` first (four questions; two minutes) — it decides single-org / multi-org / headless and who signs in, and every skill reads the answer.

Deploy your app's frontend through the ibl.ai platform's hosting API. The
platform holds the Vercel credential for your organization — you need only the
`DOMAIN` / `PLATFORM` / `TOKEN` already in `iblai.env`. No Vercel account,
no Vercel token, no `vercel` CLI.

The hosting API is **admin-only**: what authorises a deploy is the platform API
key your ibl.ai operator issued for the organization — the `TOKEN` in
`iblai.env`, sent as `Api-Token`. A user's own sign-in token is not that key
and every call below answers 403. If `iblai.env` has no `TOKEN`, ask the
operator for one rather than substituting a personal token.

> **Common setup (brand, conventions, env files, verification):** see [docs/skill-setup.md](https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/docs/skill-setup.md).
> **Headless twin:** `/iblai-api-hosting` is the same hosting API as a plain REST reference — every deploy, poll and custom-domain call with its response fields and full error surface.

**How it works:** zip the app, POST it to the platform's hosting endpoint,
poll until the build is READY. The address comes back as `site_url` on the
deploy response itself — the subdomain chosen for the project under the
platform's shared domain (Step 3.6, like choosing a username), or a custom
domain configured for it. Public by default
(no Vercel SSO/password protection to disable). POST again with the same
`project` slug to redeploy — content identical to what is already live is
skipped automatically (Step 3.5).

> **Read `site_url`, not `url`.** `url` is the underlying `*.vercel.app` host,
> which Vercel mints and revises, so it stays `null` until a live build confirms
> it. `site_url` is the address the platform chose — and is `null` when the
> project has neither kind of domain. Never derive either from the project name.

## Which target

Platform hosting (Steps 1–5 below) is the default and covers most asks. It is
**not** the right answer when the app has to run on infrastructure the team
controls:

- The user said *server*, *on-prem*, *self-host*, *our own VM*, *Docker*,
  *Kubernetes*, *air-gapped*, or named an internal hostname → **container**,
  see [`references/docker-deploy.md`](https://github.com/iblai/vibe/blob/main/skills/ship/iblai-vibe-ops-deploy/references/docker-deploy.md).
- The project already has a `Dockerfile` / `docker-compose.yml` /
  `entrypoint.sh` (skillsai and mentorai both do) → **container**. Deploy the
  way the project already deploys.
- No `package.json`, or the app is plain HTML/JS served from a directory →
  **static host** (see the section near the end), or a container, which adds
  rewrites and runtime config a bare static host cannot do.

If the target is a compliance or data-residency decision — a school district, a
health system, a government organization — do not pick it for them. Ask.

> **After the first deploy:** add the URL the API reports to the organization's
> **allowed redirect origins** (ask your ibl.ai operator), or sign-in on the
> deployed app will never return. Localhost needs it too.

## Step 1: Config

Read `iblai.env` (values may contain spaces — do not `source` it). The
platform username comes from the `IBLAI_USERNAME` environment variable when
the host provides it (the ibl.ai desktop app exports it), else from
`iblai.env`. (Never name the variable `USERNAME` — zsh binds that to the OS
login name and silently discards assignments.)

```bash
val() { grep -m1 "^$1=" iblai.env | cut -d= -f2-; }
DOMAIN=$(val DOMAIN); DOMAIN="${DOMAIN:-iblai.app}"
PLATFORM=$(val PLATFORM); TOKEN=$(val TOKEN)
IBLAI_USERNAME="${IBLAI_USERNAME:-$(val IBLAI_USERNAME)}"
[ -n "$IBLAI_USERNAME" ] || IBLAI_USERNAME=$(val USERNAME)   # legacy iblai.env key
AUTH="Authorization: Api-Token $TOKEN"
```

If `IBLAI_USERNAME` is still empty, ask the user once for their platform
username and persist it: append `IBLAI_USERNAME=…` to `iblai.env`. Do not
try to auto-detect it and do not pre-verify the token — a wrong username or
token fails loudly on the first deploy call below. Then:

```bash
BASE="https://api.$DOMAIN/dm/api/ai-mentor/orgs/$PLATFORM/users/$IBLAI_USERNAME"
```

## Step 2: Project slug + mode

```bash
PROJECT=$(jq -r .name package.json | tr '[:upper:]' '[:lower:]' \
  | sed -E 's/[^a-z0-9-]+/-/g; s/^-+//; s/-+$//' | cut -c1-64 | sed -E 's/-+$//')

grep -qs "output: *['\"]export" next.config.* && MODE=static || MODE=nextjs
```

`project` must match `^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$` (max 64).
Keep the slug stable — the same slug redeploys the same project and URL.

## Step 3: Zip

Limits: zip ≤ 50 MB, ≤ 2000 files, ≤ 200 MB uncompressed. The platform
strips `.git/`, `node_modules/`, `__MACOSX/`, `.DS_Store`, and `iblai.env`
from the upload, but keeping them out of the zip keeps you under the
limits.

**Static mode** (`output: 'export'` — Tauri shells, fully-prerendered
apps): build, add the SPA rewrite file, zip the *contents* of `out/` at the
zip root:

```bash
pnpm build
printf '%s' '{"cleanUrls":true,"rewrites":[{"source":"/(.*)","destination":"/index.html"}]}' > out/vercel.json
rm -f app.zip && (cd out && zip -qr ../app.zip .)
```

**Server mode** (no `output: 'export'` — server actions, API routes,
middleware all work): the platform installs from the lockfile and builds,
so zip the app *source* at the zip root. Run this as ONE block, in order —
`.env.production` is how the deployed server gets its runtime env (it rides
the zip deliberately; `.env*` is already gitignored by the starter), and
the guards abort the deploy loudly instead of shipping a broken app:

```bash
# 1. Regenerate runtime env from .env.local on every deploy (never stale)
grep -E '^(NEXT_PUBLIC_[A-Za-z0-9_]+|IBLAI_API_KEY|PAYWALL_[A-Za-z0-9_]+|CSP_MODE)=' .env.local | grep -vE '=$|=your-' > .env.production

# 2. Guard: the server needs its platform key — but only if this app reads one.
#    An app that keeps no server-side key (it calls the platform on the signed-in
#    user's own token) ships no IBLAI_API_KEY and must not be blocked here.
if grep -rq --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' --include='*.mjs' \
     --exclude-dir=node_modules --exclude-dir=.next --exclude-dir=out --exclude-dir=coverage \
     'IBLAI_API_KEY' . ; then
  grep -q '^IBLAI_API_KEY=' .env.production || { echo "ABORT: IBLAI_API_KEY missing/placeholder — fill it in .env.local"; exit 1; }
fi

# 3. Zip the source (src-tauri/target alone would blow the 50 MB cap).
#    Drop EVERY .env* and add .env.production back by name: naming the ones to
#    drop ships whatever nobody thought of — '.env*.local' does not match a
#    stray '.env.local.backup', which carries the platform key into the deploy.
rm -f app.zip
zip -qr app.zip . -x 'node_modules/*' '.git/*' '.next/*' 'out/*' 'coverage/*' \
  'playwright-report/*' 'test-results/*' 'src-tauri/target/*' 'src-tauri/gen/*' \
  '.env*' 'iblai.env' 'app.zip'
zip -q app.zip .env.production

# 4. Proof: the runtime env actually made it into the upload
unzip -l app.zip | grep -q '\.env\.production' || { echo "ABORT: .env.production not in zip"; exit 1; }
```

(Guard 2 decides for itself: it fires only when the app's own source reads
`IBLAI_API_KEY`. An app that holds no server-side key passes straight
through — never add one to satisfy it.)

## Step 3.5: Skip if unchanged

Hash what went *into* the zip — never `app.zip` itself, which embeds mtimes
and so differs on every run even when nothing changed. `unzip -Z1` (already a
dependency, Step 3's proof guard uses it) enumerates the members the exclude
list actually produced, so the hash can never drift from what is uploaded:

```bash
ROOT=.; [ "$MODE" = static ] && ROOT=out   # zip members are relative to this
ZIP="$PWD/app.zip"
# Both print "hash<2 spaces>path", so the combined digest matches cross-OS
SHA=sha256sum; command -v sha256sum >/dev/null 2>&1 || SHA="shasum -a 256"
# per-member digest -> LC_ALL=C sorted lines -> one digest. Directory
# entries (trailing /) have no content to read.
HASH=$( (cd "$ROOT" && unzip -Z1 "$ZIP" | grep -v '/$' | tr '\n' '\0' | xargs -0 $SHA) \
  | LC_ALL=C sort | $SHA | cut -d' ' -f1 )
case "$HASH" in *[!0-9a-f]*) HASH= ;; esac   # server rejects anything but lowercase hex
[ ${#HASH} -eq 64 ] || HASH=    # any hashing hiccup -> no hash -> deploy as usual

SKIP=
if [ -n "$HASH" ]; then         # empty hash must never match a missing field
  # push_state guard: while a push is pending/uploading, the stored hash and
  # READY state still describe the *previous* deploy — skipping then would
  # report "unchanged" while different content lands (the server answers a
  # re-push with 409). Only a quiet row may satisfy the skip.
  PREV_URL=$(curl -s "$BASE/providers/vercel/hosting/deployment/" -H "$AUTH" \
    | jq -r --arg p "$PROJECT" --arg h "$HASH" '
        .projects[]? | select(.name == $p)
        | select((.push_state // "") != "pending" and (.push_state // "") != "uploading")
        | select((.deployment_hash // "") == $h)
        | select((.latest_deployment.state // .last_ready_state // "") == "READY")
        | (.site_url // "") | select(. != "")' | head -1)
  if [ -n "$PREV_URL" ]; then
    SKIP=1; APP_URL="$PREV_URL"
    echo "unchanged — already live at $APP_URL"
  fi
fi
```

Every edge degrades to deploying, which is today's behaviour:

- **First deploy** — no row named `$PROJECT` in `projects`, nothing selects,
  deploy.
- **Older backend** without the field — the stored hash reads empty, never
  equal to a 64-char digest, deploy. The extra POST field below is harmlessly
  dropped too: unknown form fields on the deploy request are ignored, not
  rejected.
- **Vercel unreachable** — the list is served `stale: true` with
  `latest_deployment: null`, so the jq falls back to the row's cached
  `last_ready_state`; if neither says `READY`, deploy.
- **Older backend** without `site_url` — the field reads empty, nothing
  selects, deploy. (jq's `//` treats `null` exactly like a missing key, and the
  `select(. != "")` covers the empty string, so both shapes fall through.)

> **ponytail:** in **static** mode the Next.js build mints a fresh build ID
> every run, so identical source usually still hashes differently and the
> dedupe rarely fires. **Server** mode (the vibe-starter default) hashes app
> source and dedupes reliably. Upgrade path if static dedupe matters: hash the
> source tree *before* `pnpm build` and compare that instead.

## Step 3.6: The address (first deploy only)

The platform never invents an address. A project's subdomain under the
platform's shared domain is chosen, like a username, and a first deploy that
names none is refused with a `400` whose body has a `subdomain` key.

```bash
SUBDOMAIN=$(val SUBDOMAIN)
# The address the project already has — empty on a first deploy
ADDRESS=$(curl -s "$BASE/providers/vercel/hosting/deployment/" -H "$AUTH" \
  | jq -r --arg p "$PROJECT" '.projects[]? | select(.name == $p) | (.site_url // "")' | head -1)
SUBDOMAIN_ARG=
[ -z "$ADDRESS" ] && [ -n "$SUBDOMAIN" ] && SUBDOMAIN_ARG="-F subdomain=$SUBDOMAIN"
```

When `ADDRESS` is empty and `SUBDOMAIN` is empty, ask the user ONE short
question: the subdomain the site will live at — lowercase letters, digits and
hyphens, like a username; the address becomes `<subdomain>.<the platform's
shared domain>`. Suggest `$PROJECT`. Check the answer against
`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`, append `SUBDOMAIN=<answer>` to
`iblai.env` (`printf 'SUBDOMAIN=%s\n' "$SUBDOMAIN" >> iblai.env`), and set
`SUBDOMAIN_ARG` as above. Send it **only while the project has no address**:
a project that has one keeps it without being asked, and a recorded value
re-sent on every redeploy would override a change made later through the
domains route. A name that is already taken comes back as a `409` — see the
error table.

## Step 4: Deploy + poll

Both blocks are no-ops when Step 3.5 set `SKIP` — `APP_URL` is already the
live host, so Step 5 still runs.

```bash
if [ -z "$SKIP" ]; then
  RESP=$(curl -s -X POST "$BASE/providers/vercel/hosting/deployment/" -H "$AUTH" \
    -F "file=@app.zip" -F "project=$PROJECT" -F "framework=$MODE" \
    ${HASH:+-F "deployment_hash=$HASH"} $SUBDOMAIN_ARG)
  ID=$(echo "$RESP" | jq -r .id)
  # The address of this deployment — already final here, because it is a
  # name that was chosen, not one Vercel mints and later revises. NEVER build
  # it from the project name or the subdomain: read it.
  APP_URL=$(echo "$RESP" | jq -r '.site_url // empty')
fi
```

A `202` returns the project row (`{id, name, vercel_project_name, site_url,
shared_domain, url, vercel_alias, push_state, …}`), including who owns it:
`username`, `user_email` and `user_full_name`. `shared_domain` is that host on
its own when it is the chosen subdomain under the platform's shared domain, and
empty when the site is served at a custom domain. Anything else → see the error
table.

**Deploying to a custom domain.** Omit `domain` and the project keeps whatever
address it already has (a first deploy needs `subdomain`, Step 3.6). To pin a deployment
to a custom domain your organization has **already configured** (see
[references/custom-domains.md](https://github.com/iblai/vibe/blob/main/skills/ship/iblai-vibe-ops-deploy/references/custom-domains.md)), add
`-F "domain=app.example.com"`. A domain nobody has configured is a `400`.

A domain another organization holds is also a `400` — *unless* that
organization's site no longer serves it, in which case the deployment takes
the domain over and the other organization is detached. That is checked before
anything is created, so a refused domain leaves nothing behind.

Poll until the build finishes (static deploys take seconds, server builds a
few minutes; give up after ~10 min):

```bash
STATE=BUILDING; TRIES=0
[ -n "$SKIP" ] && STATE=READY   # nothing was pushed — the loop below never runs
until [ "$STATE" = "READY" ] || [ "$STATE" = "ERROR" ] || [ "$STATE" = "PUSH_FAILED" ] || [ $TRIES -ge 60 ]; do
  sleep 10; TRIES=$((TRIES+1))
  R=$(curl -s "$BASE/providers/vercel/hosting/deployment/$ID/" -H "$AUTH")
  case "$(echo "$R" | jq -r .push_state)" in
    failed) echo "push failed: $(echo "$R" | jq -r .push_error)"; STATE=PUSH_FAILED ;;
    # .deployment is the PREVIOUS deployment until the new push lands — read
    # it only after push_state reaches "pushed", or a stale READY masks failure
    pushed)
      STATE=$(echo "$R" | jq -r '.deployment.ready_state // "BUILDING"')
      # site_url was already known at Step 4; re-read it only in case a name
      # collision moved it (rare, and the server records the move).
      SITE=$(echo "$R" | jq -r '.site_url // empty')
      [ -n "$SITE" ] && APP_URL="$SITE" ;;
  esac
done
echo "$STATE ${APP_URL:-'(no address reported)'}"
```

`APP_URL` is set before the loop starts, so it is known while the build runs —
the site simply does not serve until `READY`. If it is empty, read
`.site_domain_error`: it says why. **Empty too? Then none was attempted** — use
`url` once READY, or choose one ([custom domains](https://github.com/iblai/vibe/blob/main/skills/ship/iblai-vibe-ops-deploy/references/custom-domains.md)).

On `PUSH_FAILED`, the printed `push_error` says why the upload never reached
Vercel (fix it, rebuild the zip, POST again). On `ERROR`, print the build
log tail and fix what it reports:

```bash
curl -s "$BASE/providers/vercel/hosting/deployment/$ID/" -H "$AUTH" | jq -r '.build_log_tail'
```

Redeploy = rebuild the zip, POST again with the same `project` — and when the
rebuilt content hashes to what is already live, Step 3.5 turns that into a
no-op instead of a push.

## Step 4.5: Touch test the live URL

Before you tell the user the deploy is done, run the `/iblai-vibe-ops-test`
touch test on `$APP_URL` in place of `http://localhost:3000`: open it, sign in
by hand (the first sign-in there also proves the origin is in the organization's
allowed redirect origins), use what the deploy shipped, read the console, check
the phone width, and put the report next to the URL. No browser tool: report
the URL and say the live touch test did not run.

## Step 5: Update Tauri (if present)

If `src-tauri/tauri.conf.json` exists and `APP_URL` is known, set
`build.devUrl` to it so mobile/desktop dev builds load the hosted frontend.
(Skip if it is empty — never write a guessed one.)

## Errors

| Status | Meaning | Fix |
|---|---|---|
| — | No upload happened: Step 3.5 matched the live `deployment_hash` and READY state | Expected — the URL printed is the current one. To force a push anyway, set `HASH=` (and `SKIP=`) before Step 4 |
| 400 | No Vercel credential stored for this organization, bad zip, or a malformed `deployment_hash` (server requires exactly 64 lowercase hex chars) | A platform admin adds a "Vercel" integration credential in the platform credentials UI (or the instance provides one); for zip errors check the size/file-count limits; for a hash error unset `HASH` and redeploy |
| 400 | The `domain` you named is not configured for this organization, or another organization holds it and still serves it | Configure it first (see [references/custom-domains.md](https://github.com/iblai/vibe/blob/main/skills/ship/iblai-vibe-ops-deploy/references/custom-domains.md)). A domain held by an organization whose site no longer serves it is taken over automatically, so this means the other one is still live on it |
| 400 | The `domain` you named is attached to another of your organization's projects | Detach it there first — a domain serves one project at a time |
| 400 | The `domain` you named is reserved: the shared subdomain namespace (chosen with `subdomain`, not `domain`), or a provider-owned host such as `*.vercel.app` | Deploy to a domain your organization owns and has configured, or send `subdomain` |
| 400 | The body has a `subdomain` key: the project has no address yet and none was chosen, the label is malformed, or `domain` and `subdomain` were both sent | The platform never invents an address — ask the user for a subdomain (Step 3.6), record it in `iblai.env`, redeploy |
| 402 | Deploy credit cost unmet | Top up platform credits |
| 403 | `TOKEN` is not the organization's platform API key, or it belongs to another `PLATFORM` — hosting is admin-only | Use the `TOKEN` your operator issued for this `PLATFORM`; a personal sign-in token cannot deploy |
| 409 | A push for this project is already in flight; the `project` slug collides; or the `subdomain` is taken — `error` names the host and says so | Wait for the running push to finish; pick another `project` slug; or drop the `SUBDOMAIN=` line from `iblai.env`, ask the user for a different subdomain and redeploy |
| 429 | Rate limited | Wait the `Retry-After` seconds, then retry |
| 502 | Vercel rejected the organization's stored credential | Admin re-saves a valid credential in the credentials UI |
| 503 | The deployment would have no address: this project has no custom domain of its own, and the platform's shared domain **is offered but unreachable** — or a `subdomain` was sent while no shared domain is on offer for this organization. When no shared domain is offered at all and none was sent, the deploy is accepted (202) with `site_url: null` instead | Nothing was uploaded. Either configure a custom domain for the project (see the custom-domains reference) and redeploy with `-F "domain=…"`, or ask your ibl.ai operator to fix the shared domain — the error names the setting |

## Custom Domain (optional)

Attaching a domain, handing the user the DNS records to set, re-verifying one,
reclaiming one and detaching it — every route and response field is in
[references/custom-domains.md](https://github.com/iblai/vibe/blob/main/skills/ship/iblai-vibe-ops-deploy/references/custom-domains.md).

## Deploy to a server the team controls

Full recipe — Dockerfile (Next.js and static shapes), nginx routes,
entrypoint, compose, TLS, CI smoke test — in
[`references/docker-deploy.md`](https://github.com/iblai/vibe/blob/main/skills/ship/iblai-vibe-ops-deploy/references/docker-deploy.md). The shape:

```bash
docker build -t <app> .
docker run -d -p 8080:8080 \
  -e NEXT_PUBLIC_IBL_PLATFORM=<tenant> \
  -e NEXT_PUBLIC_API_BASE_URL="${NEXT_PUBLIC_API_BASE_URL:-https://api.iblai.app}" \
  <app>
```

Two rules decide whether the deploy survives contact with a second
environment:

- **One image, every environment.** No organization, no backend, no customer baked
  in. The digest that passed staging is the digest that runs production, and
  repointing is a restart. See *Runtime configuration* below — do that part
  before containerizing, not after.
- **Reproduce the SSO callback route.** The ibl.ai Auth SPA returns to
  `<origin>/sso-login-complete`. A static server that does not map that path
  404s the callback and login dies with no useful error. Next.js routing
  handles it; nginx, S3 and GitHub Pages do not, unless told.

## Static host

For an app with no build step, or an existing bucket/host:

1. Render the config file the browser reads (below) for the *target*
   environment, not the developer's.
2. Upload the served directory.
3. Reproduce the `/sso-login-complete` → `/sso-login-complete.html` rewrite in
   the host's config.
4. Serve that config file `Cache-Control: no-store`. Cached, a repointed
   deployment keeps sending returning users to the old backend — and it looks
   like the repoint silently failed.

## Runtime configuration

Read [`references/runtime-config.md`](https://github.com/iblai/vibe/blob/main/skills/ship/iblai-vibe-ops-deploy/references/runtime-config.md) before any
deploy that is not throwaway.

An ibl.ai frontend is defined by which backend it talks to. Baked in at build
time — inlined `NEXT_PUBLIC_*`, a hand-edited committed config — those values
make an artifact that can only ever be one environment: staging and production
become different builds, and repointing means a rebuild. Rendering them at
process start from the environment is what every ibl.ai app that ships to a
server already does (`window.__ENV__` in skillsai/mentorai).

## Every target: the organization has to know the origin

Add the deployment's origin to the organization's **allowed redirect origins**.
Sign-in leaves for the login SPA and returns to `<origin>/sso-login-complete`;
an origin the organization does not know is a login that never comes back — and the
symptom looks like a broken app, not a missing setting.

## When to Deploy

- Before running dev builds (`pnpm exec tauri dev`, `… tauri ios dev`,
  `… tauri android dev`) so the WebView loads from a network URL
- After frontend changes when iterating on dev builds
- When sharing a preview URL
- When handing an app to someone who has to run it on their own
  infrastructure — that is the container path, and it is worth setting up
  before they ask

## Going Back to Local

Remove `devUrl` from `src-tauri/tauri.conf.json`; the WebView loads local
static files again.

## Reference

- Container/server: [`references/docker-deploy.md`](https://github.com/iblai/vibe/blob/main/skills/ship/iblai-vibe-ops-deploy/references/docker-deploy.md)
- Runtime config: [`references/runtime-config.md`](https://github.com/iblai/vibe/blob/main/skills/ship/iblai-vibe-ops-deploy/references/runtime-config.md)
- Custom domains: [`references/custom-domains.md`](https://github.com/iblai/vibe/blob/main/skills/ship/iblai-vibe-ops-deploy/references/custom-domains.md)
