add deploy-app skill and agent for automated repo→app deployment [ci skip]

This commit is contained in:
Viktor Barzin 2026-03-16 18:06:24 +00:00 committed by Viktor Barzin
parent 3b195d661a
commit 04084a8f0f
2 changed files with 941 additions and 0 deletions

View file

@ -0,0 +1,370 @@
---
name: deploy-app
description: Deploy a GitHub repo as a running web app on the cluster with full CI/CD (GHA build, Woodpecker deploy, Terraform stack, DNS, TLS, auth). Use when given a GitHub URL or repo name to deploy.
tools: Read, Write, Edit, Bash, Grep, Glob, Agent, AskUserQuestion
model: opus
---
You are a deployment automation engineer. Your job is to take a GitHub repository and deploy it as a running web application on a Kubernetes cluster with full CI/CD.
## Architecture
```
GitHub push → GHA builds Docker image → pushes DockerHub
→ GHA POSTs Woodpecker API → Woodpecker runs kubectl set image
→ K8s rolls out new deployment → app live at <name>.viktorbarzin.me
```
## Environment
- **Kubeconfig**: `/Users/viktorbarzin/code/config` (use `KUBECONFIG=/Users/viktorbarzin/code/config kubectl ...`)
- **Infra repo**: `/Users/viktorbarzin/code/infra`
- **Terraform apply**: `cd /Users/viktorbarzin/code/infra/stacks/<stack> && ../../scripts/tg apply --non-interactive`
- **Vault**: `vault login -method=oidc` if needed, then `vault kv get`
## Workflow
Follow these 12 steps in order. Do NOT skip steps. Ask the user for input in Step 1, then execute the rest autonomously, pausing only for confirmation before Terraform apply and git push.
### Step 1: Collect Information
Ask the user for these fields. Auto-detect what you can from the repo first.
| Field | Default | Notes |
|-------|---------|-------|
| `github_repo` | — | `owner/repo` or full URL (required) |
| `app_name` | repo name | K8s namespace/deployment name |
| `subdomain` | `app_name` | DNS subdomain (may differ from app_name) |
| `image_name` | `viktorbarzin/<app_name>` | DockerHub image |
| `port` | 8000 | Container port |
| `database` | none | `postgresql` / `mysql` / `none` |
| `protected` | true | Authentik SSO gate |
| `env_vars` | `{}` | Key=value pairs |
| `needs_storage` | false | NFS persistent volume |
**Auto-detect** via `gh api`:
```bash
OWNER="..." REPO="..."
DEFAULT_BRANCH=$(gh api repos/$OWNER/$REPO --jq '.default_branch')
gh api repos/$OWNER/$REPO/contents/Dockerfile --jq '.name' 2>/dev/null # Dockerfile exists?
gh api repos/$OWNER/$REPO/contents/package.json --jq '.name' 2>/dev/null # Node?
gh api repos/$OWNER/$REPO/contents/requirements.txt --jq '.name' 2>/dev/null # Python?
gh api repos/$OWNER/$REPO/contents/pyproject.toml --jq '.name' 2>/dev/null # Python?
gh api repos/$OWNER/$REPO/contents/go.mod --jq '.name' 2>/dev/null # Go?
```
Present detected values as defaults. Let user confirm or override.
### Steps 2-4: Create CI Files via `gh` PR
Create a branch, add files, create and merge a PR — all remote, no local clone.
```bash
# Create branch from default branch HEAD
SHA=$(gh api repos/$OWNER/$REPO/git/ref/heads/$DEFAULT_BRANCH --jq '.object.sha')
gh api repos/$OWNER/$REPO/git/refs -X POST -f ref=refs/heads/ci-setup -f sha=$SHA
```
**Add these files** (upload each via GitHub API with base64 content):
#### File 1: Dockerfile (only if missing)
Generate based on project type:
**Python** (requirements.txt):
```dockerfile
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE <PORT>
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "<PORT>"]
```
**Node** (package.json):
```dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app .
EXPOSE <PORT>
CMD ["node", "build"]
```
**Go** (go.mod):
```dockerfile
FROM golang:1.24 AS build
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /app/server .
FROM gcr.io/distroless/static
COPY --from=build /app/server /server
EXPOSE <PORT>
CMD ["/server"]
```
#### File 2: `.woodpecker/deploy.yml`
```yaml
when:
- event: [manual, push]
steps:
- name: check-vars
image: alpine
commands:
- "[ -n \"$IMAGE_TAG\" ] || (echo 'IMAGE_TAG not set, skipping deploy'; exit 78)"
- name: deploy
image: bitnami/kubectl:latest
commands:
- "kubectl set image deployment/<APP_NAME> <APP_NAME>=${IMAGE_NAME}:${IMAGE_TAG} -n <APP_NAME>"
- "kubectl rollout status deployment/<APP_NAME> -n <APP_NAME> --timeout=300s"
- name: notify
image: woodpeckerci/plugin-slack
settings:
webhook:
from_secret: slack-webhook-url
channel: general
when:
- status: [success, failure]
```
#### File 3: `.github/workflows/build-and-deploy.yml`
Use `REPO_ID_PLACEHOLDER` — replaced in Step 10.
```yaml
name: Build and Deploy
on:
push:
branches: [<DEFAULT_BRANCH>]
env:
IMAGE_NAME: <APP_NAME>
jobs:
build:
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.meta.outputs.sha }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- id: meta
run: echo "sha=$(echo ${{ github.sha }} | cut -c1-8)" >> $GITHUB_OUTPUT
- uses: docker/build-push-action@v6
with:
push: true
platforms: linux/amd64
tags: |
viktorbarzin/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.sha }}
viktorbarzin/${{ env.IMAGE_NAME }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Trigger Woodpecker deploy
run: |
for attempt in 1 2 3; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
"https://ci.viktorbarzin.me/api/repos/REPO_ID_PLACEHOLDER/pipelines" \
-H "Authorization: Bearer ${{ secrets.WOODPECKER_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"branch":"<DEFAULT_BRANCH>","variables":{"IMAGE_TAG":"${{ needs.build.outputs.image_tag }}","IMAGE_NAME":"viktorbarzin/${{ env.IMAGE_NAME }}"}}')
if [ "$STATUS" -ge 200 ] && [ "$STATUS" -lt 300 ]; then
echo "Woodpecker deploy triggered (HTTP $STATUS)"
exit 0
fi
echo "Attempt $attempt failed (HTTP $STATUS), retrying in 30s..."
sleep 30
done
echo "Failed to trigger Woodpecker deploy after 3 attempts"
exit 1
```
**Upload each file:**
```bash
# Write file content to /tmp, then upload
gh api repos/$OWNER/$REPO/contents/<PATH> -X PUT \
-f message="ci: add CI/CD pipeline" -f branch=ci-setup \
-f content="$(base64 < /tmp/file)"
```
**Create and merge PR:**
```bash
gh pr create --repo $OWNER/$REPO --head ci-setup --base $DEFAULT_BRANCH \
--title "ci: add CI/CD pipeline" --body "Adds GHA build + Woodpecker deploy pipeline"
gh pr merge --repo $OWNER/$REPO --merge --auto
```
The merge triggers GHA — build succeeds (pushes image), deploy fails harmlessly (404 from placeholder). This is intentional.
### Step 5: Set GitHub Repo Secrets
```bash
DOCKERHUB_USERNAME=$(vault kv get -field=docker_username secret/ci/global)
DOCKERHUB_TOKEN=$(vault kv get -field=dockerhub-pat secret/ci/global)
WOODPECKER_TOKEN=$(vault kv get -field=woodpecker_api_token secret/ci/global)
gh secret set DOCKERHUB_USERNAME --repo $OWNER/$REPO --body "$DOCKERHUB_USERNAME"
gh secret set DOCKERHUB_TOKEN --repo $OWNER/$REPO --body "$DOCKERHUB_TOKEN"
gh secret set WOODPECKER_TOKEN --repo $OWNER/$REPO --body "$WOODPECKER_TOKEN"
```
Verify: `gh secret list --repo $OWNER/$REPO` — must show 3 secrets.
### Step 6: Create Terraform Stack
Create `/Users/viktorbarzin/code/infra/stacks/<APP_NAME>/` with:
**`terragrunt.hcl`:**
```hcl
include "root" {
path = find_in_parent_folders()
}
dependency "platform" {
config_path = "../platform"
skip_outputs = true
}
dependency "vault" {
config_path = "../vault"
skip_outputs = true
}
```
**`main.tf`:** Generate with these resources:
- `kubernetes_namespace` — tier label `local.tiers.aux`
- `kubernetes_deployment`:
- `image = "viktorbarzin/<IMAGE_NAME>:latest"`, `image_pull_policy = "Always"`
- `lifecycle { ignore_changes = [spec[0].template[0].spec[0].dns_config] }` (Kyverno ndots)
- `annotations = { "reloader.stakater.com/auto" = "true" }`
- Resources: **256Mi** request=limit, **10m** CPU request
- Port, env vars, optional volume mounts
- `kubernetes_service` — port 80 → container port, name = subdomain
- `module "tls_secret"` from `../../modules/kubernetes/setup_tls_secret`
- `module "ingress"` from `../../modules/kubernetes/ingress_factory` — set `protected` flag
**Conditional resources:**
- If database or secrets needed: `kubernetes_manifest` ExternalSecret from `vault-kv` ClusterSecretStore
- If needs_storage: `module "nfs_data"` from `../../modules/kubernetes/nfs_volume`
Reference `/Users/viktorbarzin/code/infra/stacks/f1-stream/main.tf` for exact HCL patterns.
### Step 7: Add DNS Entry
Edit `/Users/viktorbarzin/code/infra/terraform.tfvars`:
- If `protected`: add `"<SUBDOMAIN>"` to `cloudflare_proxied_names` (line ~1154)
- If not protected: add `"<SUBDOMAIN>"` to `cloudflare_non_proxied_names` (line ~1157)
### Step 8: Apply Terraform
**Ask user for confirmation before applying.**
```bash
cd /Users/viktorbarzin/code/infra/stacks/<APP_NAME> && ../../scripts/tg apply --non-interactive
cd /Users/viktorbarzin/code/infra/stacks/platform && ../../scripts/tg apply --non-interactive
```
Verify:
```bash
KUBECONFIG=/Users/viktorbarzin/code/config kubectl get pods -n <APP_NAME>
KUBECONFIG=/Users/viktorbarzin/code/config kubectl get svc -n <APP_NAME>
```
### Step 9: Activate Woodpecker Repo
```bash
WOODPECKER_TOKEN=$(vault kv get -field=woodpecker_api_token secret/ci/global)
GITHUB_REPO_ID=$(gh api repos/$OWNER/$REPO --jq '.id')
# Try API activation
curl -s -X POST "https://ci.viktorbarzin.me/api/repos" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"forge_remote_id\":\"$GITHUB_REPO_ID\"}"
# Get Woodpecker numeric repo ID
WP_REPO_ID=$(curl -s -H "Authorization: Bearer $WOODPECKER_TOKEN" \
"https://ci.viktorbarzin.me/api/repos/lookup/$OWNER/$REPO" | jq '.id')
echo "Woodpecker repo ID: $WP_REPO_ID"
```
If API activation fails, tell the user to activate via `https://ci.viktorbarzin.me` UI.
### Step 10: Update GHA Workflow with Real Repo ID
```bash
FILE_SHA=$(gh api repos/$OWNER/$REPO/contents/.github/workflows/build-and-deploy.yml \
--jq '.sha' -H "Accept: application/vnd.github.v3+json")
gh api repos/$OWNER/$REPO/contents/.github/workflows/build-and-deploy.yml \
--jq '.content' | base64 -d | sed "s/REPO_ID_PLACEHOLDER/$WP_REPO_ID/" | base64 > /tmp/workflow.b64
gh api repos/$OWNER/$REPO/contents/.github/workflows/build-and-deploy.yml \
-X PUT -f message="ci: set Woodpecker repo ID ($WP_REPO_ID)" \
-f content="$(cat /tmp/workflow.b64)" -f sha="$FILE_SHA"
```
This triggers the first full build→deploy cycle.
### Step 11: Verify End-to-End
1. Watch GHA: `gh run watch --repo $OWNER/$REPO`
2. Check Woodpecker: query API for latest pipeline status
3. Check pod: `KUBECONFIG=/Users/viktorbarzin/code/config kubectl get pods -n <APP_NAME> -o jsonpath='{..image}'`
4. Check URL: `curl -sI https://<SUBDOMAIN>.viktorbarzin.me`
### Step 12: Commit Infra Changes
**Ask user for confirmation before pushing.**
```bash
cd /Users/viktorbarzin/code/infra
git add stacks/<APP_NAME>/ terraform.tfvars
git commit -m "$(cat <<'EOF'
add <APP_NAME> stack and DNS entry [ci skip]
EOF
)"
git push origin master
```
## Critical Rules
- **Woodpecker API uses numeric repo IDs** — NOT owner/name paths
- **Global secrets need `manual` in allowed events** — already configured
- **Docker images must be `linux/amd64`**
- **Use 8-char SHA tags**`:latest` causes stale pull-through cache
- **`image_pull_policy = "Always"`** required for CI updates
- **Always add `lifecycle { ignore_changes = [dns_config] }`** on deployments
- **256Mi memory default** — 128Mi causes OOM for many apps
- **Never skip the lifecycle block** — Kyverno injects dns_config and causes perpetual TF drift
## NEVER Do
- Never clone repos locally — use `gh` API for all remote repo operations
- Never `kubectl apply/edit/patch` raw manifests — all changes through Terraform
- Never push to git without user confirmation
- Never delete PVCs or PVs
- Never hardcode secrets in Terraform — use Vault + ExternalSecrets

View file

@ -0,0 +1,571 @@
---
name: deploy-app
description: Deploy a GitHub repo as a running web app on the cluster with full CI/CD.
Use when the user says "deploy app", "deploy repo", "set up CI/CD for", or provides
a GitHub URL and wants it running on the cluster. Handles Dockerfile, GHA build,
Woodpecker deploy, Terraform stack, DNS, TLS, and auth — end to end.
---
# deploy-app Skill
Deploy a GitHub repository as a running web application on the Kubernetes cluster with full CI/CD.
**Architecture**: `GitHub push → GHA builds Docker image → pushes DockerHub → POSTs Woodpecker API → kubectl set image → app live at <name>.viktorbarzin.me`
## Checklist
- [ ] Step 1: Collect information (interactive prompts)
- [ ] Step 2-4: Create CI files via `gh` PR (Dockerfile, Woodpecker, GHA)
- [ ] Step 5: Set GitHub repo secrets
- [ ] Step 6: Create Terraform stack
- [ ] Step 7: Add DNS entry to terraform.tfvars
- [ ] Step 8: Apply Terraform
- [ ] Step 9: Activate Woodpecker repo
- [ ] Step 10: Update GHA workflow with real repo ID
- [ ] Step 11: Verify end-to-end
- [ ] Step 12: Commit infra changes
---
## Step 1: Collect Information
Prompt the user for each field. Auto-detect what you can from the repo.
| Field | Default | Notes |
|-------|---------|-------|
| `github_repo` | — | `owner/repo` or full URL (required) |
| `app_name` | repo name | K8s namespace/deployment name |
| `subdomain` | `app_name` | DNS subdomain (may differ, e.g. f1-stream uses `f1`) |
| `image_name` | `viktorbarzin/<app_name>` | DockerHub image |
| `port` | 8000 | Container port |
| `database` | none | `postgresql` / `mysql` / `none` |
| `protected` | true | Authentik SSO gate |
| `env_vars` | `{}` | Key=value pairs for the container |
| `needs_storage` | false | NFS persistent volume |
**Auto-detect from repo** (use `gh api`):
- Project type: check for `package.json`, `requirements.txt`/`pyproject.toml`, `go.mod`
- Default branch
- Whether a Dockerfile already exists
```bash
# Example detection
OWNER="..." REPO="..."
DEFAULT_BRANCH=$(gh api repos/$OWNER/$REPO --jq '.default_branch')
gh api repos/$OWNER/$REPO/contents/Dockerfile --jq '.name' 2>/dev/null && echo "Dockerfile exists"
gh api repos/$OWNER/$REPO/contents/package.json --jq '.name' 2>/dev/null && echo "Node project"
gh api repos/$OWNER/$REPO/contents/requirements.txt --jq '.name' 2>/dev/null && echo "Python project"
gh api repos/$OWNER/$REPO/contents/pyproject.toml --jq '.name' 2>/dev/null && echo "Python project (pyproject)"
gh api repos/$OWNER/$REPO/contents/go.mod --jq '.name' 2>/dev/null && echo "Go project"
```
Present detected values as defaults, let user confirm or override.
---
## Steps 2-4: Create CI Files via `gh` PR
Create all CI files in the remote repo without cloning locally. Use `gh` CLI to create a branch, add files, and merge a PR.
### Create the branch
```bash
DEFAULT_BRANCH=$(gh api repos/$OWNER/$REPO --jq '.default_branch')
SHA=$(gh api repos/$OWNER/$REPO/git/ref/heads/$DEFAULT_BRANCH --jq '.object.sha')
gh api repos/$OWNER/$REPO/git/refs -X POST -f ref=refs/heads/ci-setup -f sha=$SHA
```
### File 1: Dockerfile (only if missing)
Generate based on detected project type:
**Python** (requirements.txt or pyproject.toml):
```dockerfile
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE {{PORT}}
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "{{PORT}}"]
```
**Node** (package.json):
```dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app .
EXPOSE {{PORT}}
CMD ["node", "build"]
```
**Go** (go.mod):
```dockerfile
FROM golang:1.24 AS build
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /app/server .
FROM gcr.io/distroless/static
COPY --from=build /app/server /server
EXPOSE {{PORT}}
CMD ["/server"]
```
### File 2: `.woodpecker/deploy.yml`
```yaml
when:
- event: [manual, push]
steps:
- name: check-vars
image: alpine
commands:
- "[ -n \"$IMAGE_TAG\" ] || (echo 'IMAGE_TAG not set, skipping deploy'; exit 78)"
- name: deploy
image: bitnami/kubectl:latest
commands:
- "kubectl set image deployment/{{APP_NAME}} {{APP_NAME}}=${IMAGE_NAME}:${IMAGE_TAG} -n {{APP_NAME}}"
- "kubectl rollout status deployment/{{APP_NAME}} -n {{APP_NAME}} --timeout=300s"
- name: notify
image: woodpeckerci/plugin-slack
settings:
webhook:
from_secret: slack-webhook-url
channel: general
when:
- status: [success, failure]
```
### File 3: `.github/workflows/build-and-deploy.yml`
Use `REPO_ID_PLACEHOLDER` — will be replaced in Step 10 after Woodpecker activation.
```yaml
name: Build and Deploy
on:
push:
branches: [{{DEFAULT_BRANCH}}]
env:
IMAGE_NAME: {{APP_NAME}}
jobs:
build:
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.meta.outputs.sha }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- id: meta
run: echo "sha=$(echo ${{ github.sha }} | cut -c1-8)" >> $GITHUB_OUTPUT
- uses: docker/build-push-action@v6
with:
push: true
platforms: linux/amd64
tags: |
viktorbarzin/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.sha }}
viktorbarzin/${{ env.IMAGE_NAME }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Trigger Woodpecker deploy
run: |
for attempt in 1 2 3; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
"https://ci.viktorbarzin.me/api/repos/REPO_ID_PLACEHOLDER/pipelines" \
-H "Authorization: Bearer ${{ secrets.WOODPECKER_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"branch":"{{DEFAULT_BRANCH}}","variables":{"IMAGE_TAG":"${{ needs.build.outputs.image_tag }}","IMAGE_NAME":"viktorbarzin/${{ env.IMAGE_NAME }}"}}')
if [ "$STATUS" -ge 200 ] && [ "$STATUS" -lt 300 ]; then
echo "Woodpecker deploy triggered (HTTP $STATUS)"
exit 0
fi
echo "Attempt $attempt failed (HTTP $STATUS), retrying in 30s..."
sleep 30
done
echo "Failed to trigger Woodpecker deploy after 3 attempts"
exit 1
```
### Upload files and create PR
For each file, upload via GitHub API:
```bash
# Upload a file (base64-encoded)
gh api repos/$OWNER/$REPO/contents/PATH -X PUT \
-f message="ci: add CI/CD pipeline" -f branch=ci-setup \
-f content="$(base64 < /tmp/file)"
```
Then create and merge the PR:
```bash
gh pr create --repo $OWNER/$REPO --head ci-setup --base $DEFAULT_BRANCH \
--title "ci: add CI/CD pipeline" --body "Adds GHA build + Woodpecker deploy pipeline"
gh pr merge --repo $OWNER/$REPO --merge --auto
```
**Note**: Merging triggers GHA — build job succeeds (pushes Docker image), deploy job fails gracefully (404 from placeholder). This is intentional so the image exists before Terraform creates the deployment.
---
## Step 5: Set GitHub Repo Secrets
```bash
# Get values from Vault
DOCKERHUB_USERNAME=$(vault kv get -field=docker_username secret/ci/global)
DOCKERHUB_TOKEN=$(vault kv get -field=dockerhub-pat secret/ci/global)
WOODPECKER_TOKEN=$(vault kv get -field=woodpecker_api_token secret/ci/global)
# Set via gh CLI
gh secret set DOCKERHUB_USERNAME --repo $OWNER/$REPO --body "$DOCKERHUB_USERNAME"
gh secret set DOCKERHUB_TOKEN --repo $OWNER/$REPO --body "$DOCKERHUB_TOKEN"
gh secret set WOODPECKER_TOKEN --repo $OWNER/$REPO --body "$WOODPECKER_TOKEN"
```
Verify: `gh secret list --repo $OWNER/$REPO` — should show 3 secrets.
---
## Step 6: Create Terraform Stack
Create `/Users/viktorbarzin/code/infra/stacks/{{APP_NAME}}/` with two files.
### `terragrunt.hcl`
```hcl
include "root" {
path = find_in_parent_folders()
}
dependency "platform" {
config_path = "../platform"
skip_outputs = true
}
dependency "vault" {
config_path = "../vault"
skip_outputs = true
}
```
### `main.tf`
Generate based on collected information. Use the f1-stream stack as the primary reference, plus the template's lifecycle block.
**Minimum required resources:**
- `kubernetes_namespace` with tier label (`aux`)
- `kubernetes_deployment` with:
- `image = "viktorbarzin/{{IMAGE_NAME}}:latest"` (initial; CI updates via `kubectl set image`)
- `image_pull_policy = "Always"`
- `lifecycle { ignore_changes = [spec[0].template[0].spec[0].dns_config] }` (Kyverno ndots drift)
- `annotations = { "reloader.stakater.com/auto" = "true" }` (auto-restart on secret changes)
- Resources: **256Mi** request=limit, **10m** CPU request
- Port, env vars, optional volume mounts
- `kubernetes_service` (port 80 → container port)
- `module "tls_secret"` from `../../modules/kubernetes/setup_tls_secret`
- `module "ingress"` from `../../modules/kubernetes/ingress_factory` with `protected` flag
**Conditional resources:**
- If `database != "none"` or `env_vars` has secrets: add `kubernetes_manifest` ExternalSecret from `vault-kv` ClusterSecretStore, key = `{{APP_NAME}}`
- If `needs_storage`: add `module "nfs_data"` from `../../modules/kubernetes/nfs_volume`
**Example main.tf** (adapt per collected info):
```hcl
variable "tls_secret_name" {
type = string
sensitive = true
}
# Add variable "nfs_server" { type = string } if needs_storage
resource "kubernetes_namespace" "{{APP_NAME}}" {
metadata {
name = "{{APP_NAME}}"
labels = {
"istio-injection" : "disabled"
tier = local.tiers.aux
}
}
}
# Include ExternalSecret block if database or secrets needed:
# resource "kubernetes_manifest" "external_secret" {
# manifest = {
# apiVersion = "external-secrets.io/v1beta1"
# kind = "ExternalSecret"
# metadata = {
# name = "{{APP_NAME}}-secrets"
# namespace = "{{APP_NAME}}"
# }
# spec = {
# refreshInterval = "15m"
# secretStoreRef = {
# name = "vault-kv"
# kind = "ClusterSecretStore"
# }
# target = {
# name = "{{APP_NAME}}-secrets"
# }
# dataFrom = [{
# extract = {
# key = "{{APP_NAME}}"
# }
# }]
# }
# }
# depends_on = [kubernetes_namespace.{{APP_NAME}}]
# }
resource "kubernetes_deployment" "{{APP_NAME}}" {
metadata {
name = "{{APP_NAME}}"
namespace = kubernetes_namespace.{{APP_NAME}}.metadata[0].name
labels = {
app = "{{APP_NAME}}"
tier = local.tiers.aux
}
annotations = {
"reloader.stakater.com/auto" = "true"
}
}
spec {
replicas = 1
selector {
match_labels = {
app = "{{APP_NAME}}"
}
}
template {
metadata {
labels = {
app = "{{APP_NAME}}"
}
}
spec {
container {
image = "viktorbarzin/{{IMAGE_NAME}}:latest"
image_pull_policy = "Always"
name = "{{APP_NAME}}"
resources {
limits = {
memory = "256Mi"
}
requests = {
cpu = "10m"
memory = "256Mi"
}
}
port {
container_port = {{PORT}}
}
# Add env blocks for env_vars here
# For secret refs:
# env {
# name = "DB_PASSWORD"
# value_from {
# secret_key_ref {
# name = "{{APP_NAME}}-secrets"
# key = "db_password"
# }
# }
# }
}
}
}
}
lifecycle {
ignore_changes = [spec[0].template[0].spec[0].dns_config]
}
}
resource "kubernetes_service" "{{APP_NAME}}" {
metadata {
name = "{{SUBDOMAIN}}"
namespace = kubernetes_namespace.{{APP_NAME}}.metadata[0].name
labels = {
"app" = "{{APP_NAME}}"
}
}
spec {
selector = {
app = "{{APP_NAME}}"
}
port {
port = "80"
target_port = "{{PORT}}"
}
}
}
module "tls_secret" {
source = "../../modules/kubernetes/setup_tls_secret"
namespace = kubernetes_namespace.{{APP_NAME}}.metadata[0].name
tls_secret_name = var.tls_secret_name
}
module "ingress" {
source = "../../modules/kubernetes/ingress_factory"
namespace = kubernetes_namespace.{{APP_NAME}}.metadata[0].name
name = "{{SUBDOMAIN}}"
tls_secret_name = var.tls_secret_name
# protected = true # Set based on user choice
}
```
---
## Step 7: Add DNS Entry
Edit `/Users/viktorbarzin/code/infra/terraform.tfvars`:
- If `protected` (Authentik-gated): add `"{{SUBDOMAIN}}"` to `cloudflare_proxied_names` (line ~1154)
- If public/not protected: add `"{{SUBDOMAIN}}"` to `cloudflare_non_proxied_names` (line ~1157)
---
## Step 8: Apply Terraform
```bash
cd /Users/viktorbarzin/code/infra
# Ensure Vault auth
vault login -method=oidc # if needed
# Apply the new stack
cd stacks/{{APP_NAME}} && ../../scripts/tg apply --non-interactive
# Apply platform to create the DNS record
cd ../platform && ../../scripts/tg apply --non-interactive
```
Verify:
```bash
KUBECONFIG=/Users/viktorbarzin/code/config kubectl get pods -n {{APP_NAME}}
KUBECONFIG=/Users/viktorbarzin/code/config kubectl get svc -n {{APP_NAME}}
curl -s -o /dev/null -w "%{http_code}" https://{{SUBDOMAIN}}.viktorbarzin.me
```
---
## Step 9: Activate Woodpecker Repo
Try API-first (fully automated):
```bash
WOODPECKER_TOKEN=$(vault kv get -field=woodpecker_api_token secret/ci/global)
GITHUB_REPO_ID=$(gh api repos/$OWNER/$REPO --jq '.id')
curl -s -X POST "https://ci.viktorbarzin.me/api/repos" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"forge_remote_id\":\"$GITHUB_REPO_ID\"}"
```
If API activation fails, guide the user to activate via `https://ci.viktorbarzin.me` UI.
Then get the Woodpecker numeric repo ID:
```bash
WP_REPO_ID=$(curl -s -H "Authorization: Bearer $WOODPECKER_TOKEN" \
"https://ci.viktorbarzin.me/api/repos/lookup/$OWNER/$REPO" | jq '.id')
echo "Woodpecker repo ID: $WP_REPO_ID"
```
---
## Step 10: Update GHA Workflow with Real Repo ID
Replace `REPO_ID_PLACEHOLDER` in the GHA workflow remotely:
```bash
# Get current file SHA
FILE_SHA=$(gh api repos/$OWNER/$REPO/contents/.github/workflows/build-and-deploy.yml \
--jq '.sha' -H "Accept: application/vnd.github.v3+json")
# Download, replace placeholder, re-upload
gh api repos/$OWNER/$REPO/contents/.github/workflows/build-and-deploy.yml \
--jq '.content' | base64 -d | sed "s/REPO_ID_PLACEHOLDER/$WP_REPO_ID/" | base64 > /tmp/workflow.b64
gh api repos/$OWNER/$REPO/contents/.github/workflows/build-and-deploy.yml \
-X PUT -f message="ci: set Woodpecker repo ID ($WP_REPO_ID)" \
-f content="$(cat /tmp/workflow.b64)" -f sha="$FILE_SHA"
```
This commit triggers the first full build→deploy cycle.
---
## Step 11: Verify End-to-End
1. Wait for GHA build: `gh run watch --repo $OWNER/$REPO`
2. Check Woodpecker deploy triggered:
```bash
curl -s -H "Authorization: Bearer $WOODPECKER_TOKEN" \
"https://ci.viktorbarzin.me/api/repos/$WP_REPO_ID/pipelines?page=1&per_page=1" | jq '.[0].status'
```
3. Check pod running with new image:
```bash
KUBECONFIG=/Users/viktorbarzin/code/config kubectl get pods -n {{APP_NAME}} -o jsonpath='{..image}'
```
4. Check URL: `curl -sI https://{{SUBDOMAIN}}.viktorbarzin.me`
---
## Step 12: Commit Infra Changes
```bash
cd /Users/viktorbarzin/code/infra
git add stacks/{{APP_NAME}}/ terraform.tfvars
git commit -m "$(cat <<'EOF'
add {{APP_NAME}} stack and DNS entry [ci skip]
EOF
)"
git push origin master
```
---
## Chicken-and-Egg Resolution
The Woodpecker repo ID is needed in GHA but only exists after activation:
1. **PR merge** (Steps 2-4): GHA workflow with `REPO_ID_PLACEHOLDER`. Build succeeds (image pushed), deploy fails harmlessly (404).
2. **Terraform** (Step 8): Creates deployment using `:latest` — the image from the first build.
3. **Activation** (Step 9): Woodpecker repo activated, deploy.yml already in place.
4. **API update** (Step 10): Real repo ID patched into workflow → first full CI/CD cycle succeeds.
## Important Notes
- **Woodpecker API uses numeric repo IDs** (`/api/repos/10/pipelines`), NOT owner/name paths
- **Global secrets must have `manual` in allowed events** for API-triggered pipelines (already configured)
- **Docker images must be `linux/amd64`** — the cluster runs amd64
- **Use 8-char SHA tags**`:latest` causes stale pull-through cache issues
- **`image_pull_policy = "Always"`** is required for CI image updates to take effect
- **Kyverno ndots drift**: Always add `lifecycle { ignore_changes = [spec[0].template[0].spec[0].dns_config] }`
- **Vault KV path for secrets**: `secret/{{APP_NAME}}` — create via `vault kv put secret/{{APP_NAME}} KEY=value` if needed
- **256Mi memory default** is safer than 128Mi (many apps OOM at 128Mi)