# Gitea Backup CronJob — Session Detail (2026-07-14)

## Context

After deploying InfluxDB backup CronJob (§4.3i), user requested the
same backup pattern for Gitea. Unlike InfluxDB (single PVC, single
backup command), Gitea has two data stores:
1. **PVC** (`/data`) — repos, LFS, config, attachments, avatars
2. **External Galera DB** (10.0.30.70:3306) — users, issues, PRs, labels

## Design Decisions

### tar over `gitea dump`
- `gitea dump` produces a zip but can miss LFS objects and repos outside
  the default path
- `tar czf /data` captures everything reliably
- Trade-off: larger archive (143 MB), but complete

### kubectl exec + kubectl cp for PVC access
- PVC is RWO, mounted by the running `gitea-0` pod
- Can't mount it in the backup pod (Multi-Attach error)
- Solution: exec into the running pod to tar, then cp the archive out
- Requires RBAC: ServiceAccount with pods/exec permission

### Separate mysqldump initContainer
- `mysql:8.0` image has `mysqldump`
- Credentials from existing `gitea-db` K8s secret (already used by Gitea)
- `--single-transaction` for consistent dump without locking

### S3 credentials reuse
- Same Norris S3 account as Postgres/InfluxDB backups
- New ExternalSecret per namespace, same 1Password item keys
- New bucket: `homelab-gitea-backup`

## Manifest Files Created

| File | Purpose |
|------|---------|
| `clusters/main/gitea/backup-rbac.yaml` | ServiceAccount + Role (pods/exec, pods/get) + RoleBinding |
| `clusters/main/gitea/backup-s3-secret.yaml` | ExternalSecret → 1Password `postgres-s3-backup` creds |
| `clusters/main/gitea/backup-cronjob.yaml` | CronJob: 2 initContainers (files + db) + 1 upload container |

Commit: `1679786`

## CronJob Spec

- **Schedule:** `30 3 * * *` (03:30 UTC — 30 min after InfluxDB backup)
- **initContainer 1 (gitea-files):** `bitnami/kubectl:1.31` — kubectl exec tar + kubectl cp
- **initContainer 2 (gitea-db):** `mysql:8.0` — mysqldump against Galera
- **Container (upload):** `amazon/aws-cli:2.35.22` — aws s3 cp to Norris S3
- **Shared volume:** emptyDir (passed between all containers)
- **ServiceAccount:** `gitea-backup` (with RBAC for kubectl exec/cp)

## S3 Upload Paths

```
s3://homelab-gitea-backup/<YYYY-MM-DD>/gitea-data.tar.gz
s3://homelab-gitea-backup/<YYYY-MM-DD>/gitea-db.sql.gz
```

## Execution Results

### S3 Bucket Creation — CRITICAL: `--region us-east-1` not `nsc-nbg`

Both buckets (`homelab-influxdb-backup` + `homelab-gitea-backup`) were
created successfully via one-off pods. **CRITICAL PITFALL**: `aws s3 mb`
with `--region nsc-nbg` fails with `InvalidLocationConstraint: The
nsc-nbg location constraint is not valid`. Must use `--region us-east-1`
for bucket creation. The `nsc-nbg` region works for subsequent `aws s3 cp`
operations but NOT for `s3 mb`.

```bash
# CORRECT — us-east-1 for bucket creation
kubectl run aws-mb-$bucket --image=amazon/aws-cli:2.35.22 --restart=Never \
  --env=AWS_ACCESS_KEY_ID=$AKI --env=AWS_SECRET_ACCESS_KEY=$SAK \
  --command -- sh -c "aws s3 mb s3://$bucket \
    --endpoint-url https://rgw.nbg.nsc.noris.cloud --region us-east-1"
```

### InfluxDB Backup — VERIFIED SUCCESSFUL ✅

InfluxDB backup CronJob ran successfully:
- 77 shards backed up
- `backup.tar.gz` = 25.4 MB
- Uploaded to `s3://homelab-influxdb-backup/2026-07-14/backup.tar.gz`
- Total time: ~45 seconds

### Gitea Backup — BLOCKED by Image Pull Issues

The Gitea CronJob requires a kubectl image for the initContainer that
runs `kubectl exec` + `kubectl cp`. Multiple images failed:

1. **`bitnami/kubectl:1.31`** — tag does NOT exist on Docker Hub
   (`failed to resolve reference: docker.io/bitnami/kubectl:1.31: not found`)
2. **`bitnami/kubectl:1.31.4`** — same NotFound error
3. **`bitnami/kubectl:latest`** — pulling but extremely slow (Docker Hub rate-limit)
4. **`alpine/k8s:1.31.4`** — pulling but took 3+ minutes, still Pending

The `amazon/aws-cli:2.35.22` and `mysql:8.0` images pull fine (already
cached from the InfluxDB backup CronJob). Only the kubectl image is
problematic.

### kubectl cp Fails on Large Files

Attempting `kubectl cp` of the 143 MB gitea-data.tar.gz from the
mgmt-runner into an upload pod failed with:
```
write tcp 10.0.30.124:35976->10.0.30.51:6443: write: connection reset by peer
websocket: close 1006 (abnormal closure): unexpected EOF
```

`kubectl cp` uses the K8s API websocket to stream file data. Large files
(>~50 MB) are unreliable — the websocket connection resets before
transfer completes. Small files (<1 MB) work fine.

### ArgoCD Reports Success But Doesn't Create New Resources

When new manifest files (backup-rbac.yaml, backup-s3-secret.yaml,
backup-cronjob.yaml) were added to the existing `gitea-config` Application's
directory (`clusters/main/gitea/`), ArgoCD showed:
- Sync status: `Succeeded`
- Operation phase: `Succeeded`
- Message: `successfully synced (all tasks run)`
- BUT: `kubectl get cronjob -n gitea` returned `No resources found`

This is a known ArgoCD behavior with `ServerSideApply=true` — new files
in an existing directory aren't always detected on the first sync cycle.
The hard-refresh annotation was applied but still showed `OutOfSync`.

**Fix that worked:** Transfer the manifest files to VM200 via base64
encoding through SSH, then `kubectl apply -f` manually. The CronJob,
RBAC, and ExternalSecret were all created successfully this way.

```bash
# Transfer files from Hermes host to VM200 via base64
B64=$(base64 -w0 /path/to/manifest.yaml)
ssh root@10.0.30.124 "echo '$B64' | base64 -d > /root/iac-homelab/path/to/manifest.yaml"
ssh root@10.0.30.124 "KUBECONFIG=/root/.kube/config kubectl apply -f /root/iac-homelab/path/to/manifest.yaml"
```

### VM200 Repo Remote Not Updated After Gitea Migration

The mgmt-runner's `/root/iac-homelab` repo still pointed at the old
Gitea on CT108 (`http://...@10.0.30.105:3000/...`). After ArgoCD was
switched to K8s Gitea, the VM200 repo became stale. New commits pushed
to K8s Gitea were invisible to VM200.

**Fix**: Update the remote URL on VM200 to point at K8s Gitea, or
transfer files via base64 encoding through SSH.

### Manual Backup from mgmt-runner — Successful Workaround

When the CronJob pod approach is blocked by image issues, run the backup
directly from the mgmt-runner (VM200, 10.0.30.124) which has kubectl +
mysqldump already installed:

```bash
ssh -i ~/.ssh/id_ed25519_proxmox root@10.0.30.124 '
KUBECONFIG=/root/.kube/config
set -e

GITEA_POD=$(kubectl -n gitea get pod -l app.kubernetes.io/name=gitea \
  -o jsonpath="{.items[0].metadata.name}")
AKI=$(kubectl -n gitea get secret gitea-s3-backup \
  -o jsonpath="{.data.aws_access_key_id}" | base64 -d)
SAK=$(kubectl -n gitea get secret gitea-s3-backup \
  -o jsonpath="{.data.aws_secret_access_key}" | base64 -d)
DB_PASS=$(kubectl -n gitea get secret gitea-db \
  -o jsonpath="{.data.password}" | base64 -d)
DATE=$(date +%Y-%m-%d)

# 1. tar /data from gitea pod
kubectl -n gitea exec $GITEA_POD -- tar czf /tmp/gitea-data.tar.gz -C / data
kubectl -n gitea cp $GITEA_POD:/tmp/gitea-data.tar.gz /tmp/gitea-data.tar.gz
kubectl -n gitea exec $GITEA_POD -- rm -f /tmp/gitea-data.tar.gz

# 2. mysqldump against Galera
mysqldump --host=10.0.30.70 --port=3306 --user=gitea \
  --password="$DB_PASS" --single-transaction \
  --routines --triggers gitea | gzip > /tmp/gitea-db.sql.gz

# 3. Upload via one-off pod with emptyDir (avoid hostPath — pods run on
#    different nodes!)
#    See pitfall below about hostPath
'
```

**Pitfall: hostPath volumes are node-local.** A pod scheduled on
worker-01 cannot read files from the mgmt-runner's `/tmp` via hostPath.
The pod lands on whichever node the scheduler chooses. Use `kubectl cp`
to transfer files into a pod with emptyDir, or use `nodeName:` to pin
the pod to a specific node (fragile — not recommended for production).

**Pitfall: `kubectl cp` to upload pod fails for large files.** The
143 MB gitea-data.tar.gz caused websocket connection resets. Workaround:
split the upload into smaller chunks, or use an initContainer that
generates the data directly (avoiding the cp step entirely).

## Recommended Fix for CronJob

The CronJob approach needs a kubectl image that can be reliably pulled.
Options:

1. **Pre-pull the image on all worker nodes** — `crictl pull alpine/k8s:1.31.4`
   on each worker, then the CronJob pulls instantly from local cache.
2. **Use a different kubectl image from a non-Docker-Hub registry** — e.g.
   `registry.k8s.io/kubectl-sidecar` or an internally hosted image.
3. **Restructure the CronJob to avoid kubectl entirely** — mount the PVC
   read-only (requires PVC to be RWX, or stop the pod first).
4. **Run backup as a script on the mgmt-runner** via systemd timer or
   crontab — pragmatic but not GitOps-managed.

Until the image issue is resolved, the manual mgmt-runner approach
(above) produces correct backups.

## Remaining Steps

1. ~~Create S3 bucket `homelab-gitea-backup`~~ ✅ Done
2. ~~Create S3 bucket `homelab-influxdb-backup`~~ ✅ Done
3. ~~InfluxDB backup test~~ ✅ Verified (25.4 MB uploaded)
4. **Fix Gitea CronJob kubectl image** — pre-pull or use alternative
5. **Gitea backup test run** — verify both files in S3
6. Monitor first scheduled runs (03:00 + 03:30 UTC)

## Pitfalls Summary

### `bitnami/kubectl:1.31` tag does NOT exist
The bitnami/kubectl image uses full semver tags (`1.31.4`, not `1.31`).
However, even with correct tags, Docker Hub rate-limiting causes
extremely slow pulls (3+ minutes to Pending). Pre-pull on all workers
or use an alternative registry.

### S3 bucket creation: `--region us-east-1` not `nsc-nbg`
`aws s3 mb` with `--region nsc-nbg` fails with `InvalidLocationConstraint`.
Use `--region us-east-1` for bucket creation. The `nsc-nbg` region works
for subsequent `aws s3 cp` operations.

### `kubectl cp` unreliable for large files (>50 MB)
WebSocket connection resets before transfer completes. Small files
(<1 MB) work fine. For large file transfers, consider splitting or
avoiding `kubectl cp` entirely.

### VM200 repo remote may be stale after Gitea migration
After switching ArgoCD from CT108 to K8s Gitea, the mgmt-runner's local
repo remote still points at the old CT108 URL. New commits are invisible.
Fix: `git remote set-url origin <new-url>` on VM200, or transfer files
via base64 through SSH.

### hostPath volumes are node-local
A pod using `hostPath: /tmp` reads from the NODE it's scheduled on, not
from the machine that created the files. If files are on the mgmt-runner
but the pod runs on a worker, the files are invisible. Use emptyDir +
`kubectl cp`, or pin the pod with `nodeName:` (fragile).

### RBAC required for kubectl exec/cp
Without explicit Role granting `pods/exec` + `pods/get`, the backup
pod's kubectl commands fail with `forbidden`. Must create ServiceAccount,
Role, and RoleBinding before the CronJob can function.

### kubectl cp namespace syntax
`kubectl cp` requires `namespace/pod:path` format. Missing the
namespace prefix causes failures when the backup pod and target pod
are in different namespaces (though typically same namespace here).

## Pattern Reuse

This pattern (PVC tar via kubectl exec + external DB dump + S3 upload)
applies to any K8s service with both PVC data and an external database:
- Gitea (PVC + Galera)
- Future services with similar dual-data-store architecture

For services with only PVC data (no external DB), skip the mysqldump
initContainer. For services with only an external DB (no PVC), skip
the kubectl exec initContainer.
