Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
name: Deploy nextcloud

# Push to main → path-filtered apply against the in-cluster k3s API via the
# runner pod's ServiceAccount (`arc-itguys-ro-nextcloud-gha-rs-no-permission`,
# bound to Role `nextcloud-deployer` in ns nextcloud and Role `cf-token-writer`
# in ns cert-manager — see manifests/bootstrap/11-ci-deployer-rbac.yaml).
#
# `secrets` always runs (cheap, idempotent). `manifests` and `helm` jobs run
# only when their respective paths changed (or workflow_dispatch reconcile_all).

on:
push:
branches: [main]
paths:
- 'helm/**'
- 'manifests/**'
- '.github/workflows/deploy.yml'
workflow_dispatch:
inputs:
reconcile_all:
description: "Apply everything (helm + manifests + secrets) regardless of paths"
type: boolean
default: false

permissions:
contents: read

concurrency:
group: deploy-nextcloud
cancel-in-progress: false

env:
KUBE_NS: nextcloud
HELM_RELEASE: nextcloud
HELM_CHART_VERSION: "9.1.0"
HELM_REPO_URL: "https://nextcloud.github.io/helm/"

jobs:
changes:
runs-on: arc-itguys-ro-nextcloud
outputs:
helm: ${{ steps.f.outputs.helm }}
manifests: ${{ steps.f.outputs.manifests }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 2
- id: f
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with:
filters: |
helm:
- 'helm/**'
- '.github/workflows/deploy.yml'
manifests:
- 'manifests/*.yaml'
- '!manifests/60-nginx-tls-proxy.yaml'

secrets:
runs-on: arc-itguys-ro-nextcloud
steps:
- uses: azure/setup-kubectl@829323503d1be3d00ca8346e5391ca0b07a9ab0d # v5.1.0
- name: Verify in-cluster auth (SelfSubjectAccessReview, no RBAC needed)
run: |
set -euo pipefail
kubectl auth can-i update secret/cloudflare-api-token -n cert-manager
kubectl auth can-i create secret -n nextcloud
kubectl auth can-i create deployment.apps -n nextcloud
- name: Render and apply Secret/cloudflare-api-token (cert-manager)
env:
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
run: |
set -euo pipefail
: "${CF_API_TOKEN:?missing GH secret CF_API_TOKEN}"
# Patch in place (Role permits update only on this name).
kubectl -n cert-manager patch secret cloudflare-api-token \
--type=merge \
-p "{\"stringData\":{\"api-token\":\"${CF_API_TOKEN}\"}}"
- name: Render and apply nextcloud-admin
env:
NEXTCLOUD_ADMIN_USERNAME: ${{ secrets.NEXTCLOUD_ADMIN_USERNAME }}
NEXTCLOUD_ADMIN_PASSWORD: ${{ secrets.NEXTCLOUD_ADMIN_PASSWORD }}
run: |
set -euo pipefail
: "${NEXTCLOUD_ADMIN_USERNAME:?missing}"
: "${NEXTCLOUD_ADMIN_PASSWORD:?missing}"
kubectl -n "${KUBE_NS}" apply --server-side -f - <<EOF
apiVersion: v1
kind: Secret
metadata:
name: nextcloud-admin
namespace: ${KUBE_NS}
type: Opaque
stringData:
nextcloud-username: "${NEXTCLOUD_ADMIN_USERNAME}"
nextcloud-password: "${NEXTCLOUD_ADMIN_PASSWORD}"
EOF
- name: Render and apply nextcloud-db
env:
MARIADB_ROOT_PASSWORD: ${{ secrets.MARIADB_ROOT_PASSWORD }}
MARIADB_USERNAME: ${{ secrets.MARIADB_USERNAME }}
MARIADB_PASSWORD: ${{ secrets.MARIADB_PASSWORD }}
run: |
set -euo pipefail
: "${MARIADB_ROOT_PASSWORD:?missing}"
: "${MARIADB_USERNAME:?missing}"
: "${MARIADB_PASSWORD:?missing}"
kubectl -n "${KUBE_NS}" apply --server-side -f - <<EOF
apiVersion: v1
kind: Secret
metadata:
name: nextcloud-db
namespace: ${KUBE_NS}
type: Opaque
stringData:
mariadb-root-password: "${MARIADB_ROOT_PASSWORD}"
db-username: "${MARIADB_USERNAME}"
db-password: "${MARIADB_PASSWORD}"
EOF
- name: Render and apply valkey-auth
env:
VALKEY_PASSWORD: ${{ secrets.VALKEY_PASSWORD }}
run: |
set -euo pipefail
: "${VALKEY_PASSWORD:?missing}"
kubectl -n "${KUBE_NS}" apply --server-side -f - <<EOF
apiVersion: v1
kind: Secret
metadata:
name: valkey-auth
namespace: ${KUBE_NS}
type: Opaque
stringData:
redis-password: "${VALKEY_PASSWORD}"
EOF
- name: Render and apply backup-ssh
env:
BACKUP_SSH_PRIVATE_KEY: ${{ secrets.BACKUP_SSH_PRIVATE_KEY }}
run: |
set -euo pipefail
: "${BACKUP_SSH_PRIVATE_KEY:?missing}"
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
# Write key to a file so the YAML heredoc can embed it via `|`
# without quoting headaches.
printf '%s\n' "$BACKUP_SSH_PRIVATE_KEY" > "$tmp"
kubectl create secret generic backup-ssh \
-n "${KUBE_NS}" \
--from-file=id_ed25519="$tmp" \
--dry-run=client -o yaml \
| kubectl apply --server-side -f -

manifests:
needs: [changes, secrets]
if: ${{ needs.changes.outputs.manifests == 'true' || inputs.reconcile_all }}
runs-on: arc-itguys-ro-nextcloud
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: azure/setup-kubectl@829323503d1be3d00ca8346e5391ca0b07a9ab0d # v5.1.0
- name: Apply manifests (CI-scoped only; excludes bootstrap/ and shared nginx proxy)
run: |
set -euo pipefail
for f in \
manifests/20-certificate.yaml \
manifests/30-mariadb.yaml \
manifests/40-valkey.yaml \
manifests/50-nextcloud-pvcs.yaml \
manifests/70-backup-cronjob.yaml; do
echo "::group::apply $f"
kubectl apply -f "$f"
echo "::endgroup::"
done
- name: Wait for non-chart Deployment rollouts
run: |
set -euo pipefail
kubectl -n "${KUBE_NS}" rollout status deploy/nextcloud-mariadb --timeout=3m
kubectl -n "${KUBE_NS}" rollout status deploy/valkey --timeout=2m

helm:
needs: [changes, secrets]
if: ${{ needs.changes.outputs.helm == 'true' || inputs.reconcile_all }}
runs-on: arc-itguys-ro-nextcloud
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
- uses: azure/setup-kubectl@829323503d1be3d00ca8346e5391ca0b07a9ab0d # v5.1.0
- name: helm upgrade --install nextcloud
run: |
set -euo pipefail
helm repo add nextcloud "${HELM_REPO_URL}"
helm repo update nextcloud
helm upgrade --install "${HELM_RELEASE}" nextcloud/nextcloud \
--namespace "${KUBE_NS}" \
--version "${HELM_CHART_VERSION}" \
-f helm/nextcloud-values.yaml \
--atomic --timeout 5m
- name: Wait for nextcloud rollout
run: kubectl -n "${KUBE_NS}" rollout status deploy/nextcloud --timeout=5m
72 changes: 72 additions & 0 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: PR checks

# Validates manifests + helm chart against the live API server WITHOUT applying.
# No GH Secrets are consumed; no Secret manifests are rendered.

on:
pull_request:
paths:
- 'helm/**'
- 'manifests/**'
- '.github/workflows/deploy.yml'
- '.github/workflows/pr-checks.yml'

permissions:
contents: read

concurrency:
group: pr-checks-${{ github.ref }}
cancel-in-progress: true

env:
KUBE_NS: nextcloud
HELM_CHART_VERSION: "9.1.0"
HELM_REPO_URL: "https://nextcloud.github.io/helm/"

jobs:
validate:
runs-on: arc-itguys-ro-nextcloud
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: azure/setup-kubectl@829323503d1be3d00ca8346e5391ca0b07a9ab0d # v5.1.0
- uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0

- name: helm template + server-side dry-run
run: |
set -euo pipefail
helm repo add nextcloud "${HELM_REPO_URL}"
helm repo update nextcloud
helm template nextcloud nextcloud/nextcloud \
--version "${HELM_CHART_VERSION}" \
--namespace "${KUBE_NS}" \
-f helm/nextcloud-values.yaml \
| kubectl apply -n "${KUBE_NS}" --dry-run=server -f -

- name: kubectl --dry-run=server -f manifests (CI-scoped subset)
run: |
set -euo pipefail
for f in \
manifests/20-certificate.yaml \
manifests/30-mariadb.yaml \
manifests/40-valkey.yaml \
manifests/50-nextcloud-pvcs.yaml \
manifests/70-backup-cronjob.yaml; do
echo "::group::dry-run $f"
kubectl apply --dry-run=server -f "$f"
echo "::endgroup::"
done

- name: kubectl diff (advisory; does not fail the check)
continue-on-error: true
run: |
set -euo pipefail
for f in \
manifests/20-certificate.yaml \
manifests/30-mariadb.yaml \
manifests/40-valkey.yaml \
manifests/50-nextcloud-pvcs.yaml \
manifests/70-backup-cronjob.yaml; do
echo "::group::diff $f"
kubectl diff -f "$f" || true
echo "::endgroup::"
done
34 changes: 17 additions & 17 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
# CLAUDE.md

Deployment artifacts for Nextcloud on existing 3-node k3s-over-Cloudflare-WARP-Mesh homelab. Not app code — no build/lint/test. Live at `https://nextcloud.itguys.ro` (Mesh-only, :443 via proxy hostPort).
Nextcloud deploy artifacts on 3-node k3s-over-Cloudflare-WARP-Mesh. Not app code. Live `https://nextcloud.itguys.ro` (Mesh-only, :443 via proxy hostPort).

## Operating model: versioned-imperative (no GitOps)
## Operating model: push-to-main CI

- No GitOps controller; nothing here auto-applied. Changes reach cluster only via manual `helm upgrade -f ...` / `kubectl apply -f ...` by operator.
- Adding artifact → also update apply-order docs in `README.md`. Repo = record; human = deployer.
- No cluster CI — validate before propose-apply: `helm template <release> <chart> -f helm/<values>.yaml` + `helm lint`; `kubectl apply --dry-run=server -f manifests/<file>.yaml`.

## Source-of-truth (read before cluster-affecting work)

- `docs/2026-05-17-nextcloud-k3s-design.md` — approved design. Decisions settled unless user reopens.
- `~/cloudflare-mesh-k3s-state.md` (outside repo) — cluster runbook; SoT for node names, Mesh IPs, existing components (degoog, headlamp). This repo intentionally doesn't duplicate.
- Push main → ARC runner `arc-itguys-ro-nextcloud` (asus-pinned, in-cluster) runs `.github/workflows/deploy.yml`. PR → `pr-checks.yml` dry-runs (no apply, no secrets).
- Runner SA bound to Role `nextcloud-deployer` (ns `nextcloud`, broad CRUD) + Role `cf-token-writer` (ns `cert-manager`, narrow update of Secret `cloudflare-api-token` only). RBAC at `manifests/bootstrap/11-ci-deployer-rbac.yaml`.
- Path filter SKIPS `manifests/60-nginx-tls-proxy.yaml` — shared :443 also serves headlamp/degoog/searxng/degoog-mcp w/ extra vhosts + `/tls-warp` cert mount not in repo. Edit live out-of-band; repo file = historical baseline (header warns DO NOT apply).
- `manifests/bootstrap/` + ARC scale set helm release = one-time manual (CI cannot grant itself rights). Re-apply on fresh cluster.
- `workflow_dispatch` with `reconcile_all=true` forces full reconcile.
- Design: `docs/superpowers/specs/2026-05-25-nextcloud-ci-deployment-design.md`. Original: `docs/2026-05-17-nextcloud-k3s-design.md`.
- Cluster runbook (outside repo): `~/cloudflare-mesh-k3s-state.md` — SoT for node names, Mesh IPs, components.

## Architecture invariants (don't violate without revisiting design)

- **Single-node pin:** whole stack in ns `nextcloud` w/ `nodeSelector: kubernetes.io/hostname=asus-laptop` on every pod. Storage = `local-path` (node-local RWO) on asus only. Nothing may schedule to `acer-laptop` or `wsl`. No storage HA — accepted trade-off.
- **Mesh-only, no public:** asus-pinned nginx TLS proxy binds asus host `:443` via `hostPort` (single replica; Service ClusterIP). DNS `nextcloud.itguys.ro` → A `100.96.0.2`, DNS-only / grey-cloud. Cluster cloudflared tunnel deliberately NOT used. No ingress controller (traefik disabled) — this proxy = single shared :443 TLS entrypoint; future apps = added nginx SNI server blocks + own cert.
- **Components:** official `nextcloud/nextcloud` Helm chart (Apache image, plain HTTP on Service :8080; chart's nginx sidecar disabled — TLS terminated by separate front nginx, plan Deviation #1). Dedicated MariaDB (Postgres explicitly rejected). Dedicated Valkey for `memcache.locking`/`memcache.distributed` — fresh instance, do NOT reuse degoog's Valkey. TLS via cert-manager (ns `cert-manager`) w/ Cloudflare DNS-01 ClusterIssuer, auto-renewed (proxy self-reloads on rotation).
- **Backups ≠ replication:** nightly asus-pinned CronJob does `mariadb-dump --single-transaction` (no `occ`/maintenance-mode — plan Deviation #2) + `config.php` copy to local PVC, then `rsync` data + dump to `acer-laptop` over Mesh SSH. Acer copy = real recovery path; local PVC only covers accidental deletion. No live storage replication by design.
- **Single-node pin:** whole stack in ns `nextcloud` w/ `nodeSelector: kubernetes.io/hostname=asus-laptop` on every pod. Storage = `local-path` (node-local RWO) on asus only. Nothing schedules to `acer-laptop` or `wsl`. No storage HA — accepted.
- **Mesh-only, no public:** asus nginx TLS proxy binds host `:443` via `hostPort` (single replica; Service ClusterIP). DNS `nextcloud.itguys.ro` → A `100.96.0.2`, DNS-only / grey-cloud. Cluster cloudflared tunnel deliberately NOT used. No ingress controller (traefik disabled). Future apps = added nginx SNI server blocks + own cert.
- **Components:** chart `nextcloud/nextcloud` (Apache img, plain HTTP Service :8080; chart nginx sidecar OFF — TLS terminated by separate front nginx, Deviation #1). Dedicated MariaDB (Postgres rejected). Dedicated Valkey for `memcache.locking`/`memcache.distributed` — fresh instance, do NOT reuse degoog's. TLS via cert-manager (ns `cert-manager`) w/ Cloudflare DNS-01 ClusterIssuer, auto-renewed (proxy self-reloads on rotation).
- **Backups ≠ replication:** nightly asus CronJob `mariadb-dump --single-transaction` (no `occ`/maintenance-mode — Deviation #2) + `config.php` copy to local PVC, then `rsync` data + dump to `acer-laptop` over Mesh SSH. Acer copy = real recovery path; local PVC only covers accidental deletion.

## Secrets policy (hard rule — repo may go to GitHub)

- Never commit plaintext. `.gitignore` enforces — do NOT loosen.
- Pattern: commit `secrets/<name>.example` (placeholders) → operator copies to `secrets/<name>.yaml` (gitignored, real) → `kubectl apply` out-of-band.
- Five secrets: scoped Cloudflare API token (`Zone:DNS:Edit` + `Zone:Zone:Read` on `itguys.ro`; `cf-api-token`), Nextcloud admin (`nextcloud-admin`), MariaDB root + nextcloud DB (`nextcloud-db`), Valkey (`valkey-auth`), backup SSH key (`backup-ssh`).
- Non-k8s dependency: out-of-band Cloudflare Gateway "Do Not Inspect" rule for `nextcloud.itguys.ro` required (see README "Operational dependencies" / design §5).
- Source-of-truth = GitHub Actions repo Secrets. Workflow renders k8s Secret manifests at apply time. Local `secrets/*.yaml` (gitignored) only used to seed `gh secret set`.
- Commit `secrets/<name>.example` (placeholders) as schema reference.
- CF API token scope: `Zone:DNS:Edit` + `Zone:Zone:Read` on `itguys.ro` only.
- Non-k8s dep: out-of-band Cloudflare Gateway "Do Not Inspect" rule for `nextcloud.itguys.ro` (see README "Operational dependencies" / design §5). Silent failure if removed: clients get Gateway CA cert, Android app breaks, traffic decrypted at CF edge.
Loading