Add VPS warm-standby/backup site (Phase 0-1b)

Foundation for a DR/backup path using an always-on VPS as a second
ArgoCD-managed cluster, plus DB/backup standardization work that fell
out of it:

- vps-standby ArgoCD cluster destination + AppProject, MinIO backup
  receiver, VPS bootstrap script (k3s, Netbird, cert-manager)
- Dual-site DNS failover watcher + home-IP DDNS CronJob, Cloudflare
  token moved out of git into Vault+ExternalSecret
- Nextcloud migrated from ad-hoc MariaDB to CNPG + redis-operator
  (matches n8n/Authentik/GitLab's backup-native pattern)
- Authentik's CNPG manifests moved into the actual ArgoCD-synced
  manifests/ path (were present but never wired into the sync path)
- Vault raft-snapshot CronJob, CNPG barmanObjectStore backups
  (Authentik/n8n/Nextcloud), Nextcloud file-PVC restic sync - all
  targeting the new VPS MinIO receiver

See VPS Warm-Standby plan doc for full design rationale.
This commit is contained in:
Scooby Husky
2026-08-17 14:59:26 -05:00
parent 5163403e24
commit 7990f1fa47
25 changed files with 1161 additions and 139 deletions
@@ -0,0 +1,102 @@
---
# Keeps home.kube.huskypup.net pointed at this cluster's current public IP in
# Cloudflare. This is the health-check target the VPS's DNS failover watcher
# (scripts/vps-dns-failover.sh) uses to decide whether home is reachable -
# it only needs to run while home is up, which is exactly when it can run.
#
# Reuses the cloudflare-token-secret already wired via ExternalSecret for
# cert-manager's DNS-01 solver (see secret-cf-token.yaml) - same zone, same
# token, no new secret plumbing.
apiVersion: v1
kind: ConfigMap
metadata:
name: home-ip-ddns-script
namespace: cert-manager
data:
update.sh: |
#!/bin/sh
set -eu
ZONE_NAME="kube.huskypup.net"
RECORD_NAME="home.kube.huskypup.net"
TOKEN="$(cat /etc/cf/cloudflare-token)"
CURRENT_IP="$(curl -sf https://cloudflare.com/cdn-cgi/trace | grep -o '^ip=.*' | cut -d= -f2)"
if [ -z "$CURRENT_IP" ]; then
echo "ERROR: could not determine public IP"
exit 1
fi
ZONE_ID="$(curl -sf -H "Authorization: Bearer ${TOKEN}" \
"https://api.cloudflare.com/client/v4/zones?name=${ZONE_NAME}" \
| jq -r '.result[0].id')"
RECORD_JSON="$(curl -sf -H "Authorization: Bearer ${TOKEN}" \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?name=${RECORD_NAME}&type=A")"
RECORD_ID="$(echo "$RECORD_JSON" | jq -r '.result[0].id // empty')"
EXISTING_IP="$(echo "$RECORD_JSON" | jq -r '.result[0].content // empty')"
if [ "$EXISTING_IP" = "$CURRENT_IP" ]; then
echo "home.kube.huskypup.net already up to date (${CURRENT_IP})"
exit 0
fi
BODY="{\"type\":\"A\",\"name\":\"${RECORD_NAME}\",\"content\":\"${CURRENT_IP}\",\"ttl\":120,\"proxied\":false}"
if [ -n "$RECORD_ID" ]; then
echo "Updating ${RECORD_NAME}: ${EXISTING_IP} -> ${CURRENT_IP}"
curl -sf -X PATCH -H "Authorization: Bearer ${TOKEN}" -H "Content-Type: application/json" \
-d "$BODY" \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" >/dev/null
else
echo "Creating ${RECORD_NAME} -> ${CURRENT_IP}"
curl -sf -X POST -H "Authorization: Bearer ${TOKEN}" -H "Content-Type: application/json" \
-d "$BODY" \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" >/dev/null
fi
echo "done"
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: home-ip-ddns
namespace: cert-manager
spec:
schedule: "*/10 * * * *" # every 10 minutes; cheap, and only matters while home is up
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 65534
containers:
- name: ddns-update
image: alpine/k8s:1.32.13 # already has curl + jq (see MEMORY.md kubectl image note)
command: ["/bin/sh", "/scripts/update.sh"]
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: script
mountPath: /scripts
- name: cf-token
mountPath: /etc/cf
readOnly: true
volumes:
- name: script
configMap:
name: home-ip-ddns-script
defaultMode: 0755
- name: cf-token
secret:
secretName: cloudflare-token-secret
items:
- key: cloudflare-token
path: cloudflare-token
@@ -1,9 +1,22 @@
---
apiVersion: v1
kind: Secret
# Was previously a plain Secret with the Cloudflare token committed in git.
# Rotated to ExternalSecret+Vault (see scripts/rotate-cloudflare-token.sh) -
# never commit a live token here again.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: cloudflare-token-secret
namespace: cert-manager
type: Opaque
stringData:
cloudflare-token: Mg9Yx1ku3-rIn7VR7Wf_PZ1uir7AqUsx3IZVpuRX # be sure you are generating an API token and not a global API key https://cert-manager.io/docs/configuration/acme/dns01/cloudflare/#api-tokens
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-backend
target:
name: cloudflare-token-secret
creationPolicy: Owner
data:
- secretKey: cloudflare-token
remoteRef:
key: cloudflare-dns-token
property: token