17 KiB
| name | description |
|---|---|
| deploy-app | 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
ghPR (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
# 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
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):
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):
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):
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
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.
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:
# 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:
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
# 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
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_namespacewith tier label (aux)kubernetes_deploymentwith:image = "viktorbarzin/{{IMAGE_NAME}}:latest"(initial; CI updates viakubectl 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_secretmodule "ingress"from../../modules/kubernetes/ingress_factorywithprotectedflag
Conditional resources:
- If
database != "none"orenv_varshas secrets: addkubernetes_manifestExternalSecret fromvault-kvClusterSecretStore, key ={{APP_NAME}} - If
needs_storage: addmodule "nfs_data"from../../modules/kubernetes/nfs_volume
Example main.tf (adapt per collected info):
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}}"tocloudflare_proxied_names(line ~1154) - If public/not protected: add
"{{SUBDOMAIN}}"tocloudflare_non_proxied_names(line ~1157)
Step 8: Apply Terraform
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:
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):
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:
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:
# 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
- Wait for GHA build:
gh run watch --repo $OWNER/$REPO - Check Woodpecker deploy triggered:
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' - Check pod running with new image:
KUBECONFIG=/Users/viktorbarzin/code/config kubectl get pods -n {{APP_NAME}} -o jsonpath='{..image}' - Check URL:
curl -sI https://{{SUBDOMAIN}}.viktorbarzin.me
Step 12: Commit Infra Changes
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:
- PR merge (Steps 2-4): GHA workflow with
REPO_ID_PLACEHOLDER. Build succeeds (image pushed), deploy fails harmlessly (404). - Terraform (Step 8): Creates deployment using
:latest— the image from the first build. - Activation (Step 9): Woodpecker repo activated, deploy.yml already in place.
- 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
manualin allowed events for API-triggered pipelines (already configured) - Docker images must be
linux/amd64— the cluster runs amd64 - Use 8-char SHA tags —
:latestcauses 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 viavault kv put secret/{{APP_NAME}} KEY=valueif needed - 256Mi memory default is safer than 128Mi (many apps OOM at 128Mi)