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
Add or Edit Quick Links / Services
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
- Add the secret in GitHub → Settings → Secrets → Actions → New secret
- Name:
VITE_PROWLARR_API_KEY -
Value: your API key
-
Add to
.github/workflows/deploy.ymlinbuild-args: -
Add to
Dockerfile: -
Add to
public/config.yaml: -
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
- Go to GitHub Secrets → update the value
- Trigger a rebuild:
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:
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 |