Skip to main content
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.

What ships in the manifest

k8s/vibestrap.yaml defines four resources, in this order:
  1. Namespacevibestrap
  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 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:
1

Builds the image

The runner stage of the Dockerfile becomes harbor.funkro.com/vibestrap/vibestrap:<version>, also re-tagged as :latest.
2

Pushes to Harbor

Authenticated with HARBOR_USERNAME + HARBOR_PASSWORD from GitHub Actions secrets.
3

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

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.
No kubeconfig lives in CI. The deploy itself is still your hands-on-keyboard step — by design.

GitHub Actions secrets

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

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:
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.
If you’ve already created harbor-secret for another app in this cluster, copy it into the vibestrap namespace instead of retyping credentials:

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.
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.
k8s/.env is gitignored. Don’t commit it. Re-run create-secrets.sh whenever you add or change a value.

3. ingress-nginx + cert-manager

The Ingress assumes ingressClassName: nginx and a letsencrypt-prod ClusterIssuer. If you don’t have them yet:
Then create a letsencrypt-prod ClusterIssuer following the cert-manager docs.

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:
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:
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:
If you only changed env vars (no new code), refresh the Secret and roll the Deployment:

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.

Troubleshooting

Official docs