> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vibestrap.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Deploy on Kubernetes

> Single-file production manifest with a CI workflow that builds one image, pushes to Harbor, and auto-bumps the manifest.

The Kubernetes setup is intentionally minimal: one YAML file, one helper
script, and one CI workflow. The whole thing fits on one screen, reads top
to bottom, and is easy to extend later.

```
k8s/
├── README.md
└── prod/
    ├── k8s-prod.yaml         Namespace + Deployment + Service + Ingress
    ├── create-secrets.sh     builds the `vibestrap-secrets` Secret from .env.prod
    └── .env.prod             your production env (gitignored — see .env.example)
```

## What ships in the manifest

`k8s/vibestrap.yaml` defines four resources, in this order:

1. **Namespace** — `vibestrap`
2. **Deployment** — Next.js app, 1 replica, probes on `/api/ping`, sane resource requests/limits
3. **Service** — ClusterIP fronting the pods on port 80 → 3000
4. **Ingress** — nginx + cert-manager TLS, with `www → apex` redirect baked in

A single image is pulled from Harbor:
`harbor.funkro.com/vibestrap/vibestrap`. There is no in-cluster migration
Job — see [Database migrations](#database-migrations) below.

## What the CI does for you

`.github/workflows/docker-build-push.yml` runs on:

* Every push to `main`
* Every tag matching `v*`
* Manual dispatch from the GitHub Actions UI

On each run it:

<Steps>
  <Step title="Builds the image">
    The runner stage of the Dockerfile becomes
    `harbor.funkro.com/vibestrap/vibestrap:<version>`, also re-tagged as
    `:latest`.
  </Step>

  <Step title="Pushes to Harbor">
    Authenticated with `HARBOR_USERNAME` + `HARBOR_PASSWORD` from GitHub
    Actions secrets.
  </Step>

  <Step title="Computes the version tag">
    Tag pushes use the git tag (e.g. `v1.2.0`). Branch pushes use
    `main-<sha7>`. Both forms are immutable, unlike `:latest`.
  </Step>

  <Step title="Auto-bumps the manifest">
    The workflow rewrites the `image:` line in `k8s/vibestrap.yaml`
    to point at the new version and commits back to `main` with `[skip ci]`.
    The manifest in git always reflects what is actually in the registry.
  </Step>
</Steps>

<Note>
  No `kubeconfig` lives in CI. The deploy itself is still your hands-on-keyboard
  step — by design.
</Note>

### GitHub Actions secrets

Add these once in **Settings → Secrets and variables → Actions**:

| Secret            | What goes in it                           |
| ----------------- | ----------------------------------------- |
| `HARBOR_USERNAME` | Your Harbor account username              |
| `HARBOR_PASSWORD` | Harbor password, or a robot account token |

## First-time setup

Three things need to exist in the cluster before the first `kubectl apply`.

### 1. Harbor pull secret

So the cluster can pull from your private registry:

```bash theme={null}
kubectl create namespace vibestrap
kubectl create secret docker-registry harbor-secret \
  --namespace vibestrap \
  --docker-server=harbor.funkro.com \
  --docker-username='<YOUR_HARBOR_USERNAME>' \
  --docker-password='<YOUR_HARBOR_PASSWORD>'
```

<Warning>
  K8s pull secrets are namespace-scoped — `harbor-secret` in `default` or any
  other namespace **cannot** be used by a pod in `vibestrap`. Always pass
  `--namespace vibestrap`. The most common deploy failure is `ImagePullBackOff`
  because this secret was created in the wrong place.
</Warning>

If you've already created `harbor-secret` for another app in this cluster, copy
it into the `vibestrap` namespace instead of retyping credentials:

```bash theme={null}
kubectl get secret harbor-secret -n <other-ns> -o yaml \
  | sed '/namespace:/s/<other-ns>/vibestrap/; /resourceVersion:\|uid:\|creationTimestamp:/d' \
  | kubectl apply -f -
```

### 2. App secrets — only two values are required

The runtime image refuses to start without `DATABASE_URL` and
`BETTER_AUTH_SECRET`. **Every other env var is optional and no-ops gracefully
when blank** — you can deploy first, configure features later by re-running
the script and `kubectl rollout restart`.

```bash theme={null}
cp .env.example k8s/.env
# edit k8s/.env. ONLY two lines need real values:
#   DATABASE_URL=postgres://user:pass@host:5432/db
#   BETTER_AUTH_SECRET=<openssl rand -base64 32>
# Leave the rest blank until you actually want that feature.
./k8s/create-secrets.sh
```

The helper script strips comments, blank lines, and accidental quotes (kubectl
treats quotes as part of the value, which breaks Better Auth and Stripe SDKs),
then runs `kubectl create secret generic ... --from-env-file` for you. The
Deployment pulls every variable in via `envFrom: secretRef`.

<Warning>
  `k8s/.env` is gitignored. Don't commit it. Re-run
  `create-secrets.sh` whenever you add or change a value.
</Warning>

### 3. ingress-nginx + cert-manager

The Ingress assumes `ingressClassName: nginx` and a `letsencrypt-prod`
ClusterIssuer. If you don't have them yet:

<CodeGroup>
  ```bash ingress-nginx theme={null}
  helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
  helm install ingress-nginx ingress-nginx/ingress-nginx \
    --namespace ingress-nginx --create-namespace
  ```

  ```bash cert-manager theme={null}
  helm repo add jetstack https://charts.jetstack.io
  helm install cert-manager jetstack/cert-manager \
    --namespace cert-manager --create-namespace \
    --set installCRDs=true
  ```
</CodeGroup>

Then create a `letsencrypt-prod` ClusterIssuer following the
[cert-manager docs](https://cert-manager.io/docs/configuration/acme/).

## Database migrations

Migrations are **not** run by the cluster. The runtime image doesn't include
`drizzle-kit`, and there's no Job to babysit. Instead, before each release
that touches schema, run from your laptop:

```bash theme={null}
DATABASE_URL='postgres://user:pass@prod-host:5432/db?sslmode=require' \
  pnpm db:migrate
```

Then `kubectl apply` the new image. With 1 replica there's no race-condition
window — schema moves only when you tell it to.

### When prod DB is in a private VPC

If your laptop can't reach the prod DB directly, spin up a one-off pod inside
the cluster that has the right network access. The exact `kubectl run` command
lives in `k8s/README.md`; the shape is:

```bash theme={null}
kubectl run vibestrap-migrate --rm -it --restart=Never \
  --namespace vibestrap \
  --image=node:22-alpine \
  --env="DATABASE_URL=$DATABASE_URL" \
  -- sh -c "cd /tmp && git clone <repo> app && cd app && \
            corepack enable && pnpm install && pnpm db:migrate"
```

This is the heavy fallback — only reach for it when you genuinely can't reach
the DB from outside the cluster.

## Deploy

Once the CI workflow has bumped the manifest, every deploy is:

```bash theme={null}
git pull

# only if the release includes a schema change
DATABASE_URL='postgres://...' pnpm db:migrate

kubectl apply -f k8s/vibestrap.yaml

# watch the rollout
kubectl rollout status deployment/vibestrap -n vibestrap
```

If you only changed env vars (no new code), refresh the Secret and roll the
Deployment:

```bash theme={null}
./k8s/create-secrets.sh
kubectl rollout restart deployment/vibestrap -n vibestrap
```

## When you outgrow this

The manifest is intentionally minimal — add these only when you actually need
them. Each is small (10–30 lines) and orthogonal.

| Add                         | When                                                                         | Where                             |
| --------------------------- | ---------------------------------------------------------------------------- | --------------------------------- |
| `HorizontalPodAutoscaler`   | Traffic varies enough that fixed replicas wastes money or starves under load | New file under `k8s/`             |
| `PodDisruptionBudget`       | Running ≥3 replicas and you want zero-downtime node drains                   | New file                          |
| `NetworkPolicy`             | Multi-tenant cluster, want to restrict egress to DB and outbound APIs        | New file                          |
| `topologySpreadConstraints` | Multi-zone cluster, want zone-failure tolerance                              | Inline in the Deployment spec     |
| Multiple environments       | When you actually run more than the `vibestrap` namespace                    | `k8s/staging/` parallel directory |

## Troubleshooting

| Symptom                                                | Likely cause                                                                  | Check                                                           |
| ------------------------------------------------------ | ----------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `ImagePullBackOff`                                     | Harbor credentials wrong, or `harbor-secret` missing in `vibestrap` namespace | `kubectl get secret harbor-secret -n vibestrap`                 |
| Pod `CrashLoopBackOff` on first start                  | Missing required env (`DATABASE_URL`, `BETTER_AUTH_SECRET`, …)                | `kubectl logs <pod> -n vibestrap` — Zod prints the missing key  |
| Pod boots but DB queries throw "column does not exist" | Forgot to run `pnpm db:migrate` before applying the new image                 | Run the migration from your laptop, then restart the Deployment |
| TLS cert pending forever                               | DNS not pointing at the ingress LB, or ClusterIssuer missing                  | `kubectl describe certificate vibestrap-tls -n vibestrap`       |
| Stripe webhook 400 "invalid signature"                 | `STRIPE_WEBHOOK_SECRET` mismatched                                            | Re-copy from Stripe, refresh secret, restart Deployment         |

## Official docs

* [Kubernetes docs](https://kubernetes.io/docs/)
* [cert-manager](https://cert-manager.io/docs/)
* [nginx-ingress](https://kubernetes.github.io/ingress-nginx/)
* [Harbor](https://goharbor.io/docs/)
