Skip to content

Swiss-HD: K3s Deployment & CI/CD

Production deployment of Swiss-HD on K3s via ArgoCD with a fully automated GitHub Actions CI/CD pipeline.


1. Architecture Overview

Swiss-HD runs as a containerized Nginx application on K3s. The deployment pipeline follows the GitOps pattern — source code and infrastructure manifests are kept in separate repositories.

┌─────────────────────────────────────────────────────────────┐
│  Developer pushes to github.com/onthecouch/swisshd (master) │
└──────────────────────────┬──────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│  GitHub Actions                                             │
│  1. Build Docker image (linux/amd64 + linux/arm64)          │
│  2. Bake VITE_* secrets into JS bundle at build time        │
│  3. Push ghcr.io/onthecouch/swisshd:SHA to ghcr.io          │
│  4. Commit new image tag to k3s-repo/apps/services/         │
└──────────────────────────┬──────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│  ArgoCD watches github.com/onthecouch/k3s-repo              │
│  Detects change in apps/services/swisshd.yaml               │
│  Triggers rolling restart on K3s                            │
└──────────────────────────┬──────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│  Dashboard live at http://100.96.53.104:30800               │
└─────────────────────────────────────────────────────────────┘

Two-repo GitOps split:

Repository Purpose
onthecouch/swisshd App source code, Dockerfile, nginx.conf, GitHub Actions workflow
onthecouch/k3s-repo K8s manifests — apps/services/swisshd.yaml

2. Infrastructure Files

Dockerfile

Multi-stage build. Stage 1 compiles the React app with Node 20; Stage 2 serves the static output with Nginx Alpine.

FROM node:20-alpine AS builder
WORKDIR /app

# Declare build-time secrets (injected by GitHub Actions)
ARG VITE_METRIC_1_AUTH
ARG VITE_K3S_AUTH
# ... (all VITE_* vars)

ENV VITE_METRIC_1_AUTH=$VITE_METRIC_1_AUTH
# ... (expose as ENV so Vite reads them)

COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80

Why secrets are baked in

Vite reads VITE_* variables via import.meta.env at build time — they are compiled into the JavaScript bundle. They cannot be injected at runtime. This is why they must be passed as Docker --build-arg during the CI build.

nginx.conf

Serves the SPA and proxies all /api/* routes to internal Tailscale services, replacing the Vite dev proxy:

server {
  listen 80;
  root /usr/share/nginx/html;

  # SPA fallback
  location / {
    try_files $uri $uri/ /index.html;
  }

  # Proxmox proxy
  location /api/proxmox/ {
    proxy_pass https://100.110.170.60:8006/;
    proxy_ssl_verify off;
    proxy_pass_request_headers on;
  }

  # K3s proxy
  location /api/k3s/ {
    proxy_pass https://100.96.53.104:6443/;
    proxy_ssl_verify off;
    proxy_pass_request_headers on;
  }

  # ... (qBittorrent, ArgoCD, Longhorn, Grafana, n8n)
}

k8s/swisshd.yaml (lives in k3s-repo)

Single-file manifest combining Deployment and Service:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: swisshd
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: swisshd
  template:
    spec:
      containers:
        - name: swisshd
          image: ghcr.io/onthecouch/swisshd:SHA   # updated by CI
          imagePullPolicy: Always
      imagePullSecrets:
        - name: ghcr-secret
---
apiVersion: v1
kind: Service
metadata:
  name: swisshd
spec:
  type: NodePort
  ports:
    - port: 80
      targetPort: 80
      nodePort: 30800

3. GitHub Actions Workflow

File: .github/workflows/deploy.yml

on:
  push:
    branches: [main, master]

jobs:
  build:
    steps:
      - uses: docker/setup-qemu-action@v3       # multi-arch support
      - uses: docker/setup-buildx-action@v3

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          platforms: linux/amd64,linux/arm64    # supports ARM K3s nodes
          push: true
          tags: ghcr.io/onthecouch/swisshd:${{ github.sha }}
          build-args: |
            VITE_METRIC_1_AUTH=${{ secrets.VITE_METRIC_1_AUTH }}
            VITE_K3S_AUTH=${{ secrets.VITE_K3S_AUTH }}
            # ... all VITE_* secrets

      - name: Update image tag in k3s-repo
        run: |
          git clone https://x-access-token:${{ secrets.GH_PAT }}@github.com/onthecouch/k3s-repo.git
          sed -i "s|ghcr.io/onthecouch/swisshd:.*|ghcr.io/onthecouch/swisshd:${{ github.sha }}|g" \
            apps/services/swisshd.yaml
          git commit -m "chore: update swisshd image to ${{ github.sha }}"
          git push

4. One-Time Setup

4.1 GitHub Secrets

Go to github.com/onthecouch/swisshd → Settings → Secrets → Actions. Add each secret separately, without quotes:

Secret Value
GH_PAT GitHub classic PAT with repo scope (for writing to k3s-repo)
VITE_METRIC_1_AUTH PVEAPIToken=USER@PAM!TOKENID=SECRET
VITE_K3S_AUTH Bearer eyJhbGci...
VITE_GOOGLE_CALENDAR_ID yourname@gmail.com
VITE_GOOGLE_CALENDAR_API_KEY Google Calendar API key
VITE_SONARR_API_KEY Sonarr API key
VITE_RADARR_API_KEY Radarr API key
VITE_LIDARR_API_KEY Lidarr API key
VITE_QBITTORRENT_USERNAME qBittorrent username
VITE_QBITTORRENT_PASSWORD qBittorrent password
VITE_JELLYFIN_API_KEY Jellyfin API key
VITE_ARGOCD_TOKEN ArgoCD token
VITE_GRAFANA_API_KEY Grafana service account key
VITE_N8N_API_KEY n8n API key

4.2 ghcr.io Pull Secret on K3s

K3s needs credentials to pull from the private ghcr.io registry:

kubectl create secret docker-registry ghcr-secret \
  --docker-server=ghcr.io \
  --docker-username=onthecouch \
  --docker-password=YOUR_GITHUB_PAT \
  --namespace=default

The PAT needs read:packages scope (classic token).

4.3 ArgoCD Application

If ArgoCD already watches k3s-repo/apps/services/, adding swisshd.yaml is sufficient — it will be picked up on next sync.

To register manually:

kubectl apply -f - <<EOF
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: swiss-dashboard
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/onthecouch/k3s-repo.git
    targetRevision: HEAD
    path: apps/services
  destination:
    server: https://kubernetes.default.svc
    namespace: default
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
EOF

5. Making Changes

Edit public/config.yaml — no code changes needed:

# Add a quick link
links:
  - title: Reddit
    url: https://reddit.com

# Add a service (no secret needed)
services:
  - name: Bazarr
    url: http://100.121.191.61:6767

# Add a service with metrics
services:
  - name: Prowlarr
    url: http://100.121.191.61:9696
    appType: prowlarr

Then push:

git add public/config.yaml
git commit -m "feat: add Reddit link and Prowlarr service"
git push origin master

Add a New Service With a Secret

  1. Add the secret in GitHub → Settings → Secrets → Actions → New secret
  2. Name: VITE_PROWLARR_API_KEY
  3. Value: your API key

  4. Add to .github/workflows/deploy.yml in build-args:

    VITE_PROWLARR_API_KEY=${{ secrets.VITE_PROWLARR_API_KEY }}
    

  5. Add to Dockerfile:

    ARG VITE_PROWLARR_API_KEY
    ENV VITE_PROWLARR_API_KEY=$VITE_PROWLARR_API_KEY
    

  6. Add to public/config.yaml:

    - name: Prowlarr
      url: http://100.121.191.61:9696
      appType: prowlarr
    

  7. Push — CI/CD handles the rest.

No .env.local needed for production

.env.local is only for local npm run dev development. For the K3s deployment, GitHub Secrets is your .env.local.

Update an Existing Secret

  1. Go to GitHub Secrets → update the value
  2. Trigger a rebuild:
    git commit --allow-empty -m "chore: rebuild with updated secrets"
    git push origin master
    

6. Verification

# Check pod status
kubectl get pods -n default | grep swisshd

# Check logs
kubectl logs -n default -l app=swisshd

# Check events (useful for image pull issues)
kubectl describe pod -n default -l app=swisshd | tail -20

Expected output when healthy:

swisshd-xxx   1/1     Running   0   5m

Dashboard URL: http://100.96.53.104:30800


7. Troubleshooting

Error Cause Fix
exec format error Image built for wrong CPU arch Ensure platforms: linux/amd64,linux/arm64 in workflow
403 Forbidden (image pull) ghcr-secret missing or wrong Re-create secret with valid PAT
not found (image pull) Image not pushed yet Check GitHub Actions ran successfully
nothing to commit Re-running same commit SHA Normal — workflow skips commit gracefully
exit code 128 (git clone) GH_PAT secret missing or expired Add/renew PAT in GitHub Secrets
Metrics not showing Secrets not baked in Rebuild after adding secrets