Compare commits

...
Author SHA1 Message Date
forust 8a5d23b560 docs(postgres): format Paperless compatibility table
ci / Compose (pull_request) Successful in 26s
ci / Workflows (pull_request) Successful in 18s
ci / Shell (pull_request) Successful in 52s
ci / Formatting (pull_request) Successful in 27s
ci / Python and tests (pull_request) Successful in 8s
ci / YAML (pull_request) Successful in 17s
ci / Dockerfiles (pull_request) Successful in 13s
ci / Kubernetes (pull_request) Successful in 12s
ci / image-plan (pull_request) Skipped
ci / Image (${{ matrix.name }}) (pull_request) Skipped
ci / build (pull_request) Skipped
2026-10-08 00:34:43 +02:00
forust fb46fb48b5 feat(paperless): deploy Paperless-ngx on shared PostgreSQL
ci / Compose (pull_request) Canceled after 0s
ci / Workflows (pull_request) Canceled after 0s
ci / Shell (pull_request) Canceled after 0s
ci / Formatting (pull_request) Canceled after 0s
ci / Python and tests (pull_request) Canceled after 0s
ci / YAML (pull_request) Canceled after 0s
ci / Dockerfiles (pull_request) Canceled after 0s
ci / Kubernetes (pull_request) Canceled after 0s
ci / image-plan (pull_request) Canceled after 0s
ci / Image (${{ matrix.name }}) (pull_request) Canceled after 0s
ci / build (pull_request) Canceled after 0s
2026-10-08 00:32:54 +02:00
forust 4c7c53e0f2 fix(cicd): align apply timeout with stage budgets
ci / Compose (push) Successful in 11s
ci / Workflows (push) Successful in 6s
ci / Shell (push) Successful in 20s
ci / Formatting (push) Successful in 17s
ci / Python and tests (push) Successful in 7s
ci / YAML (push) Successful in 10s
ci / Dockerfiles (push) Successful in 4s
ci / image-plan (push) Successful in 13s
ci / Image (error-pages) (push) Successful in 15s
ci / Image (forust-homepage) (push) Successful in 15s
ci / Kubernetes (push) Successful in 7s
ci / Image (xdfnx-homepage) (push) Successful in 15s
ci / build (push) Successful in 19s
2026-10-07 23:28:17 +02:00
forust ba934265ac Merge pull request 'docs: record completed EDU ownership handoff' (#115) from docs/record-edu-handoff-complete into main
ci / Compose (push) Successful in 14s
ci / Workflows (push) Successful in 8s
ci / Python and tests (push) Successful in 7s
ci / YAML (push) Successful in 8s
ci / Kubernetes (push) Successful in 8s
ci / Image (forust-homepage) (push) Successful in 13s
ci / Shell (push) Successful in 18s
ci / Formatting (push) Successful in 24s
ci / Dockerfiles (push) Successful in 5s
ci / image-plan (push) Successful in 14s
ci / Image (error-pages) (push) Successful in 18s
ci / Image (xdfnx-homepage) (push) Successful in 17s
ci / build (push) Successful in 20s
Reviewed-on: #115
2026-10-07 21:10:52 +00:00
forust c7155808d9 docs: record completed EDU ownership handoff
ci / Workflows (pull_request) Successful in 13s
ci / Shell (pull_request) Successful in 31s
ci / Compose (pull_request) Successful in 26s
ci / Dockerfiles (pull_request) Successful in 11s
ci / Formatting (pull_request) Successful in 45s
ci / Python and tests (pull_request) Successful in 13s
ci / YAML (pull_request) Successful in 21s
ci / Kubernetes (pull_request) Successful in 13s
ci / image-plan (pull_request) Skipped
ci / Image (${{ matrix.name }}) (pull_request) Skipped
ci / build (pull_request) Skipped
2026-10-07 20:43:12 +02:00
forust fc4d64bdb2 chore(streaming): disable Kubernetes routing
ci / Workflows (push) Successful in 17s
ci / Compose (push) Successful in 29s
ci / Shell (push) Successful in 57s
ci / Formatting (push) Successful in 29s
ci / Python and tests (push) Successful in 14s
ci / YAML (push) Successful in 14s
ci / Dockerfiles (push) Successful in 8s
ci / Kubernetes (push) Successful in 7s
ci / image-plan (push) Successful in 15s
ci / Image (error-pages) (push) Successful in 28s
ci / Image (forust-homepage) (push) Successful in 33s
ci / Image (xdfnx-homepage) (push) Successful in 34s
ci / build (push) Successful in 37s
2026-10-07 18:23:16 +02:00
forust a6f7fc6030 chore(streaming): disable streaming stack
ci / Compose (pull_request) Successful in 14s
ci / Formatting (pull_request) Successful in 21s
ci / Python and tests (pull_request) Successful in 7s
ci / YAML (pull_request) Successful in 8s
ci / Kubernetes (pull_request) Successful in 7s
ci / Workflows (pull_request) Successful in 8s
ci / Shell (pull_request) Successful in 19s
ci / Dockerfiles (pull_request) Successful in 5s
ci / image-plan (pull_request) Skipped
ci / Image (${{ matrix.name }}) (pull_request) Skipped
ci / build (pull_request) Skipped
ci / Compose (push) Successful in 14s
ci / Formatting (push) Successful in 20s
ci / Kubernetes (push) Successful in 7s
ci / image-plan (push) Successful in 11s
ci / Workflows (push) Successful in 8s
ci / Shell (push) Successful in 14s
ci / Python and tests (push) Successful in 8s
ci / YAML (push) Successful in 10s
ci / Dockerfiles (push) Successful in 5s
ci / Image (error-pages) (push) Successful in 13s
ci / Image (forust-homepage) (push) Successful in 15s
ci / Image (xdfnx-homepage) (push) Successful in 15s
ci / build (push) Successful in 16s
2026-10-07 17:48:54 +02:00
forust 11de1d1468 Merge pull request 'fix(cicd): preserve AIO tag in rollback snapshot' (#112) from fix/nextcloud-aio-rollback-tag into main
ci / Workflows (push) Successful in 7s
ci / Image (forust-homepage) (push) Successful in 13s
ci / Image (xdfnx-homepage) (push) Successful in 18s
ci / Compose (push) Successful in 14s
ci / Shell (push) Successful in 21s
ci / Formatting (push) Successful in 24s
ci / Python and tests (push) Successful in 9s
ci / YAML (push) Successful in 8s
ci / Dockerfiles (push) Successful in 5s
ci / Kubernetes (push) Successful in 9s
ci / image-plan (push) Successful in 15s
ci / Image (error-pages) (push) Successful in 17s
ci / build (push) Successful in 18s
Reviewed-on: #112
2026-10-07 15:29:11 +00:00
forust 64962d1a63 fix(cicd): preserve AIO tag in recovery snapshot
ci / Workflows (pull_request) Successful in 6s
ci / Kubernetes (pull_request) Successful in 6s
ci / image-plan (pull_request) Skipped
ci / Image (${{ matrix.name }}) (pull_request) Skipped
ci / build (pull_request) Skipped
ci / Compose (pull_request) Successful in 15s
ci / Shell (pull_request) Successful in 19s
ci / Formatting (pull_request) Successful in 30s
ci / Python and tests (pull_request) Successful in 8s
ci / YAML (pull_request) Successful in 10s
ci / Dockerfiles (pull_request) Successful in 4s
2026-10-07 16:53:14 +02:00
forust b08a0a927d Merge pull request 'fix(cicd): preserve Nextcloud AIO tag' (#111) from fix/preserve-nextcloud-aio-tag into main
ci / Compose (push) Successful in 27s
ci / Workflows (push) Successful in 14s
ci / Shell (push) Successful in 50s
ci / Kubernetes (push) Successful in 5s
ci / image-plan (push) Successful in 12s
ci / Image (forust-homepage) (push) Successful in 14s
ci / Image (xdfnx-homepage) (push) Successful in 15s
ci / Formatting (push) Successful in 24s
ci / Python and tests (push) Successful in 9s
ci / YAML (push) Successful in 11s
ci / Dockerfiles (push) Successful in 5s
ci / Image (error-pages) (push) Successful in 16s
ci / build (push) Successful in 18s
Reviewed-on: #111
2026-10-07 14:46:02 +00:00
forust 8203ba1b0b fix(cicd): preserve Nextcloud AIO image tag
ci / Workflows (pull_request) Successful in 9s
ci / Compose (pull_request) Successful in 14s
ci / Shell (pull_request) Successful in 19s
ci / Formatting (pull_request) Successful in 33s
ci / Python and tests (pull_request) Successful in 16s
ci / Dockerfiles (pull_request) Successful in 14s
ci / Kubernetes (pull_request) Successful in 17s
ci / Image (${{ matrix.name }}) (pull_request) Skipped
ci / build (pull_request) Skipped
ci / YAML (pull_request) Successful in 19s
ci / image-plan (pull_request) Skipped
2026-10-07 14:45:10 +00:00
forust 48033b5495 Merge pull request 'fix(cicd): support Helm 4 release listing' (#110) from fix/helm4-list into main
ci / Workflows (push) Successful in 6s
ci / Shell (push) Successful in 17s
ci / Image (error-pages) (push) Successful in 13s
ci / Image (forust-homepage) (push) Successful in 13s
ci / Compose (push) Successful in 11s
ci / Formatting (push) Successful in 20s
ci / Python and tests (push) Successful in 6s
ci / YAML (push) Successful in 9s
ci / Dockerfiles (push) Successful in 4s
ci / Kubernetes (push) Successful in 7s
ci / image-plan (push) Successful in 11s
ci / Image (xdfnx-homepage) (push) Successful in 13s
ci / build (push) Successful in 16s
Reviewed-on: #110
2026-10-07 13:55:15 +00:00
forust 73d2af73e5 fix(cicd): support Helm 4 release listing
ci / build (pull_request) Skipped
ci / Workflows (pull_request) Successful in 7s
ci / Python and tests (pull_request) Successful in 5s
ci / Compose (pull_request) Successful in 11s
ci / Shell (pull_request) Successful in 17s
ci / Formatting (pull_request) Successful in 17s
ci / YAML (pull_request) Successful in 8s
ci / Dockerfiles (pull_request) Successful in 5s
ci / Kubernetes (pull_request) Successful in 7s
ci / image-plan (pull_request) Skipped
ci / Image (${{ matrix.name }}) (pull_request) Skipped
2026-10-07 15:51:57 +02:00
forust 64253e005e Merge pull request 'fix(cicd): use Gitea artifact v4 backend' (#109) from fix/gitea-v4-artifacts into main
ci / Workflows (push) Successful in 8s
ci / Shell (push) Successful in 22s
ci / YAML (push) Successful in 9s
ci / image-plan (push) Successful in 49s
ci / Compose (push) Successful in 14s
ci / Formatting (push) Successful in 19s
ci / Python and tests (push) Successful in 8s
ci / Dockerfiles (push) Successful in 5s
ci / Kubernetes (push) Successful in 8s
ci / Image (error-pages) (push) Successful in 39s
ci / Image (forust-homepage) (push) Successful in 16s
ci / Image (xdfnx-homepage) (push) Successful in 13s
ci / build (push) Successful in 17s
Reviewed-on: #109
2026-10-07 13:35:36 +00:00
forust 69accd1752 fix(cicd): use Gitea artifact v4 backend
ci / Shell (push) Skipped
ci / Formatting (push) Skipped
ci / Python and tests (push) Skipped
ci / YAML (push) Skipped
ci / Dockerfiles (push) Skipped
ci / Kubernetes (push) Skipped
ci / Compose (pull_request) Successful in 11s
ci / Kubernetes (pull_request) Successful in 7s
ci / Compose (push) Skipped
ci / Workflows (push) Skipped
ci / Workflows (pull_request) Successful in 7s
ci / Shell (pull_request) Successful in 18s
ci / Formatting (pull_request) Successful in 20s
ci / Python and tests (pull_request) Successful in 7s
ci / YAML (pull_request) Successful in 8s
ci / Dockerfiles (pull_request) Successful in 4s
ci / image-plan (pull_request) Skipped
ci / Image (${{ matrix.name }}) (pull_request) Skipped
ci / build (pull_request) Skipped
2026-10-07 15:29:49 +02:00
forust 0cf4b08a95 Merge pull request 'fix(cicd): isolate pull request runner jobs' (#108) from fix/cicd-pr-runner into main
ci / Workflows (push) Successful in 7s
ci / Shell (push) Successful in 21s
ci / Python and tests (push) Successful in 5s
ci / Compose (push) Successful in 15s
ci / Formatting (push) Successful in 17s
ci / Kubernetes (push) Successful in 6s
ci / YAML (push) Successful in 9s
ci / Dockerfiles (push) Successful in 4s
renovate-ci / validate-renovate (push) Successful in 2m21s
ci / image-plan (push) Successful in 18s
ci / Image (error-pages) (push) Successful in 13s
ci / Image (xdfnx-homepage) (push) Successful in 12s
ci / Image (forust-homepage) (push) Successful in 11s
ci / build (push) Successful in 16s
Reviewed-on: #108
2026-10-07 13:03:01 +00:00
forust 86df5d9048 docs(cicd): document user-scoped PR runner
ci / Compose (push) Skipped
ci / Workflows (push) Skipped
ci / Shell (push) Skipped
ci / Formatting (push) Skipped
ci / Python and tests (push) Skipped
ci / Dockerfiles (push) Skipped
ci / Workflows (pull_request) Successful in 14s
ci / Shell (pull_request) Successful in 33s
ci / Formatting (pull_request) Successful in 36s
ci / YAML (pull_request) Successful in 28s
ci / Kubernetes (pull_request) Successful in 11s
ci / YAML (push) Skipped
ci / Kubernetes (push) Skipped
ci / Compose (pull_request) Successful in 23s
ci / Python and tests (pull_request) Successful in 16s
ci / Dockerfiles (pull_request) Successful in 11s
ci / Image (${{ matrix.name }}) (pull_request) Skipped
ci / build (pull_request) Skipped
ci / image-plan (pull_request) Skipped
2026-10-07 14:50:32 +02:00
forust d7441bbbc2 fix(cicd): isolate pull request runner jobs
ci / Compose (push) Skipped
ci / Workflows (push) Skipped
ci / Shell (push) Skipped
ci / Formatting (push) Skipped
ci / Python and tests (push) Skipped
ci / Compose (pull_request) Successful in 37s
ci / Shell (pull_request) Successful in 18s
ci / YAML (push) Skipped
ci / Dockerfiles (push) Skipped
ci / Kubernetes (push) Skipped
ci / Workflows (pull_request) Successful in 8s
ci / Python and tests (pull_request) Successful in 11s
ci / Kubernetes (pull_request) Successful in 8s
ci / Formatting (pull_request) Successful in 18s
ci / YAML (pull_request) Successful in 11s
ci / Dockerfiles (pull_request) Successful in 7s
ci / image-plan (pull_request) Skipped
ci / Image (${{ matrix.name }}) (pull_request) Skipped
ci / build (pull_request) Skipped
2026-10-07 14:39:57 +02:00
forust 2be089e048 Merge pull request 'Collect Headscale, NetBird, Gitea and Immich metrics' (#100) from feat/service-metrics into main
ci / Compose (push) Successful in 11s
ci / Workflows (push) Successful in 7s
ci / YAML (push) Successful in 9s
ci / Dockerfiles (push) Successful in 5s
ci / Shell (push) Successful in 16s
ci / Formatting (push) Successful in 17s
ci / Python and tests (push) Successful in 7s
ci / Kubernetes (push) Successful in 6s
ci / image-plan (push) Successful in 15s
ci / Image (error-pages) (push) Successful in 12s
ci / Image (forust-homepage) (push) Successful in 12s
ci / Image (xdfnx-homepage) (push) Successful in 11s
ci / build (push) Successful in 13s
Reviewed-on: #100
2026-10-07 11:46:16 +00:00
forust 83b2e68371 feat(metrics): collect Headscale, NetBird, Gitea and Immich metrics 2026-10-07 11:46:16 +00:00
forust 597f64cbb0 Merge pull request 'Show CI checks and deployment results' (#99) from codex/ci-visible-checks into main
ci / Workflows (push) Successful in 7s
ci / Formatting (push) Successful in 17s
ci / Python and tests (push) Successful in 7s
ci / YAML (push) Successful in 9s
ci / Compose (push) Successful in 11s
ci / Shell (push) Successful in 16s
ci / Kubernetes (push) Successful in 7s
ci / Dockerfiles (push) Successful in 5s
ci / Image (forust-homepage) (push) Successful in 12s
ci / Image (xdfnx-homepage) (push) Successful in 11s
renovate-ci / validate-renovate (push) Successful in 11s
ci / image-plan (push) Successful in 16s
ci / Image (error-pages) (push) Successful in 41s
ci / build (push) Successful in 14s
Reviewed-on: #99
2026-10-07 11:46:05 +00:00
42 changed files with 825 additions and 89 deletions

No files matched your search

+38 -30
View File
@@ -1,42 +1,50 @@
# EDU ownership handoff
## Current status
## Status
EDU PR #1 merged at 2026-10-07 08:04:30 UTC. Main release `5094952464ce315130839303985fd04d721bc1f2` passed CI run 1585 and deploy run 1586. The workstation checkout `/srv/edu-master` is at that SHA. The release changed the application image digests:
The EDU ownership handoff is complete. The homelab repository no longer owns
EDU workloads, images, routes, alerts, or deployment selection. The EDU
repository is the only deployment owner: [forust/edu-master](https://git.forust.xyz/forust/edu-master).
- Session keeper: `sha256:1e59473bd40fe4c22622017d808a8927a68788275fe073dc23d718c44b2fd5dd`
- Webinar checker: `sha256:987d9bf0770272766523ea5b94c7f3f849175d737551d46591ae55e058cf9f12`
Homelab PRs #99 and #105 are merged. PR #105 removed the EDU subtree and its
build, deploy, rollback, verification, route-probe, and registry references.
It also added the serial image build matrix for the homelab services. This
handoff record is the only remaining EDU-specific file in homelab Git.
The live workloads remain healthy in context `Default`, namespace `edu-master`. Both health and live probes return 200. Redis AUTH passes, session TTL is 1178 seconds, the delivery backlog is zero, all nine EDU alert rules are healthy, and the scrape target is UP. The unauthorized-pod Redis check passed. The Redis PVC UID and Secret UID and values, including the Fernet key, match their pre-release state.
The dedicated workstation checkout is `/srv/edu-master`, at release
`4f2b2a0e37dc11ac2c75441a15076c178e219d37`. It contains `k8s/active`; root
`active` is absent. The old untracked `/srv/homelab/edu_master` checkout was
moved outside the homelab repository to
`/srv/edu-master-legacy-archive-20261007/edu_master`. Its private files remain
mode `0600` inside an archive directory with mode `0700`. The homelab deploy
checkout has no EDU marker or tracked EDU application/deployment files.
`AUTODEPLOY=false` remains in place for homelab deployment.
The homelab EDU active marker was present after the EDU deployment. It was moved to the private snapshot as `homelab-k8s-active.marker` while holding `/tmp/homelab-apply.lock`. The homelab checkout at `/srv/homelab` is at `5f9354b` and has the tracked marker deletion. Its deploy preflight blocks a dirty checkout until this removal is reconciled. Preserve private ignored configuration when syncing that checkout.
## Release evidence
The remaining homelab change is PR #105, branch `feat/edu-handoff-matrix`, based on `codex/ci-visible-checks`. Its eight protected checks passed. Renovate runs 1587 and 1588 passed. Image publishing was skipped for the PR. The EDU runtime changes are in PR #3 from `fix/handoff-runtime` to `main`; CI run 1589 is in progress. Those runtime changes have not been released.
EDU PR #4 merged after its review and CI checks. Main-push CI run 1652 passed
all validation and both image builds. Deploy run 1653 passed for the exact main
SHA above.
## Approval gate and next steps
The workstation rollout completed for both Deployments. The deployment
verified `/health` and `/live` with HTTP 200, Redis AUTH, session TTL of 1058
seconds, a delivery backlog of zero, and all nine EDU vmalert rules with
matching expressions and healthy evaluation.
PR #99 must merge before PR #105 can target `main`. A merge attempt for PR #99 returned HTTP 405 because it needs one approval; the protected branch has `required_approvals=1` and whitelist approval is enabled. This approval gate prevents the remaining transfer steps.
The images now run by digest:
After the required approval:
- Session keeper: `sha256:998dea51aa3015fd9cabefb0f53b030157a650c3bef72e02fe84f17d5762613d`
- Webinar checker: `sha256:92f3c1fa2bb7f9b4680a9fc76a5b33dfbea8ef3dd9c6490ebc45876fd4c54461`
1. Merge PR #99.
2. Retarget PR #105 to `main`. Complete CI and review, then approve and merge it.
3. Under the homelab apply lock, sync `/srv/homelab` to the merged removal. Preserve private ignored configuration and keep the active marker removed. Confirm the deploy preflight is clean.
4. Merge the EDU runtime PR after its CI and review pass. The main-push CI run must complete successfully before its exact SHA can deploy.
5. Verify the new release SHA, image digests, workload health, Redis AUTH and TTL, backlog, PVC and Secret identity, and monitoring. Record the results in the EDU PR.
Redis StatefulSet was unchanged. PVC `redis-data-pvc` remains bound to PV
`pvc-a4f2a79a-363a-4c12-ae91-92cdfc2a0d2e` with capacity 1 GiB. The existing
runtime Secret and Fernet key were preserved during the handoff. Notification
delivery was verified before closeout, as confirmed by the operator. The
deployment did not record downtime.
`AUTODEPLOY=false` is explicitly configured. The EDU repository path and port secrets are confirmed, and `EDU_KUBE_CONTEXT=Default` is configured as a repository variable. Keep deployment and registry credentials outside Git. Never run both homelab and EDU deployment paths at the same time.
## Change summary
The homelab PR removes the EDU subtree, deployment and image selection, rollback and verification cases, route probes, Renovate references, and external-image exceptions. It adds a serial dynamic matrix for the three homelab images. Each job builds an image or reuses a matching immutable digest. The final job checks all image results and publishes full-SHA tags and the existing release artifact only after they pass. PRs do not publish images. The protected check names from PR #99 are preserved. PR #100's service-metrics work is independent of this handoff.
The EDU runtime PR adds the Playwright service manifest, reconciles Redis storage and Secret reload annotations, and adds pre-apply Redis backup and identity checks. It verifies application endpoints, Redis AUTH, session TTL, metrics, and all nine vmalert rules. Rollback checks workload and application health and reports when manual recovery is needed. Its deployment guard rejects an unexpected or dirty checkout and refuses deployment while either legacy homelab EDU marker exists.
## Rollback and limits
The private snapshot is `/home/forust/.local/state/edu-master-deploy/handoff-20261007T080838Z` on the workstation. It contains the pre-handoff Redis RDB and recovery data. RDB checksum verification confirmed twelve keys. Keep the snapshot outside Git. For an EDU release failure, restore the saved Kubernetes resources and inspect application health. The rollback does not automatically restore the Redis RDB; restore old Redis data only when recovery requires it.
For an ownership rollback, stop EDU deployment triggers first, restore the reviewed homelab source and marker, then reapply recorded immutable image digests. Verify both workload and application health. Never delete or recreate the Redis PVC.
The initial EDU release and the homelab marker move are complete. PR #99 approval and merge, PR #105 retarget and merge, homelab checkout reconciliation, EDU runtime PR merge, and release of those runtime changes remain pending. Synthetic Telegram delivery and Alertmanager-to-Telegram notification were not tested.
The release rollback snapshot is
`/home/forust/.local/state/edu-master-deploy/20261007T180541Z-4f2b2a0e37dc11ac2c75441a15076c178e219d37`.
The handoff data snapshot remains at
`/home/forust/.local/state/edu-master-deploy/handoff-20261007T080838Z`.
Both snapshots are outside Git. Do not restore old Redis data unless recovery
requires it. Never delete or recreate the Redis PVC.
+1
View File
@@ -7,4 +7,5 @@ self-hosted-runner:
labels:
- arch
- homelab
- homelab-pr
- prod
+35 -6
View File
@@ -1,17 +1,20 @@
# Homelab CI/CD
The native Gitea runner runs on **vps**; production runs on **workstation**.
Compose, workflow, shell, Python, formatting, YAML, Dockerfile and Kubernetes
checks appear as separate jobs. Jobs run on `homelab:host`, one at a time; the
build waits for every check to pass. CI and deploy runs also show a summary with
The native Gitea runners run on **vps**; production runs on **workstation**.
Main-branch checks and image builds use `homelab:host`. Pull request and
non-main checks use `homelab-pr:host` under a separate account without Docker
access. The `homelab-pr` runner is registered at User scope for `forust`, so
any repository under that account can schedule jobs that request this label.
Each runner accepts one job at a time; the build waits for every check to pass.
CI and deploy runs also show a summary with
the release SHA, image build or reuse results, deploy mode, selected services,
and image digests. Failed runs keep a summary of completed image builds, stage
results, apply results, and recorded Kubernetes recovery. The final deploy
summary is in the smoke job; earlier jobs show the state observed at that time.
Apply success is separate from health and recovery. Update the installed
workstation controller with `setup-workstation.sh` when no deploy is running.
No job images or Kubernetes credentials
are needed on the VPS. Builds use one pinned BuildKit helper container. CI and deploy are separate workflows.
No job images or Kubernetes credentials are needed on the VPS. Builds use one
pinned BuildKit helper container. CI and deploy are separate workflows.
## Runner installation
@@ -37,6 +40,32 @@ pushes directly to the registry, and caps retained local cache at 1 GiB with a
2 GiB free-space target. This is not a hard limit on peak build disk usage.
Nothing runs `docker system prune`, removes unrelated images, or deletes volumes.
### Pull request runner
Install the unprivileged host runner on the VPS:
```sh
sudo bash .gitea/runner/setup-pr-runner.sh
```
Get a registration token from the user Actions runner settings. Run the
installer in a terminal. It asks for the token without echoing it, registers the
runner as `homelab-pr` with label `homelab-pr:host`, then enables the service.
The work directory is `/var/lib/gitea-pr-runner`. Confirm that Gitea lists the
runner as User scope before merging the workflow change. An unmatched label can
fall back to the default job image.
Renovate PR validation uses `pull_request_target`, which reads the workflow from
the base branch. It checks out the PR head only after runner selection and runs
that code on `homelab-pr`. Keep this workflow read-only and do not add secrets.
The PR runner has a separate home and tool cache. Do not add it to the `docker`
group or give it access to `/var/run/docker.sock`. It runs repository code from
pull requests, so keep its registration and permissions separate from the
trusted `homelab` runner. This separates users and host permissions, but both
runners still share the VPS kernel and network. Use a disposable VM if PRs from
untrusted external authors must be fully isolated.
## Workstation setup
As the existing SSH deploy user on workstation:
+8
View File
@@ -0,0 +1,8 @@
runner:
file: /var/lib/gitea-pr-runner/.runner
capacity: 1
timeout: 5h
labels:
- homelab-pr:host
cache:
enabled: false
+27
View File
@@ -0,0 +1,27 @@
[Unit]
Description=Gitea Actions untrusted pull request runner
After=network-online.target
Wants=network-online.target
[Service]
User=gitea-pr-runner
Group=gitea-pr-runner
WorkingDirectory=/var/lib/gitea-pr-runner
Environment=HOME=/var/lib/gitea-pr-runner
Environment=PATH=/var/lib/gitea-pr-runner/.cache/homelab-ci/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/local/bin/gitea-runner daemon --config /etc/gitea-pr-runner/config.yaml
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=full
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
UMask=0077
[Install]
WantedBy=multi-user.target
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Install a native runner for untrusted PR jobs without Docker access.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
[ "$(id -u)" -eq 0 ] || { echo 'Run with sudo on the runner host' >&2; exit 1; }
for tool in cp cut date getent id install runuser systemctl useradd; do
command -v "$tool" >/dev/null || { echo "Install missing prerequisite: $tool" >&2; exit 1; }
done
command -v /usr/local/bin/gitea-runner >/dev/null || {
echo 'Install gitea-runner 3.0.2 at /usr/local/bin/gitea-runner first' >&2
exit 1
}
id gitea-pr-runner >/dev/null 2>&1 || \
useradd --system --create-home --home-dir /var/lib/gitea-pr-runner --shell /usr/bin/bash gitea-pr-runner
runner_home="$(getent passwd gitea-pr-runner | cut -d: -f6)"
[ "$runner_home" = /var/lib/gitea-pr-runner ] || {
echo 'Unexpected PR runner home; inspect the existing service first' >&2
exit 1
}
case " $(id -nG gitea-pr-runner) " in
*' docker '*)
echo 'The PR runner account must not belong to the docker group' >&2
exit 1
;;
esac
install -d -m 0755 /etc/gitea-pr-runner
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
for existing in /etc/gitea-pr-runner/config.yaml /etc/systemd/system/gitea-pr-runner.service; do
[ ! -f "$existing" ] || cp -p "$existing" "$existing.before-$stamp"
done
install -m 0644 "$here/pr-config.yaml" /etc/gitea-pr-runner/config.yaml
install -m 0644 "$here/pr-runner.service" /etc/systemd/system/gitea-pr-runner.service
if [ ! -f /var/lib/gitea-pr-runner/.runner ]; then
read -r -s -p 'Enter the Gitea repository runner registration token: ' runner_token
printf '\n'
[ -n "$runner_token" ] || { echo 'Runner token is required' >&2; exit 1; }
export GITEA_RUNNER_REGISTRATION_TOKEN="$runner_token"
unset runner_token
runuser --preserve-environment -u gitea-pr-runner -- \
/usr/local/bin/gitea-runner register \
--config /etc/gitea-pr-runner/config.yaml \
--instance https://gitea.forust.xyz \
--name homelab-pr \
--labels homelab-pr:host \
--no-interactive
unset GITEA_RUNNER_REGISTRATION_TOKEN
fi
chmod 0600 /var/lib/gitea-pr-runner/.runner
systemctl daemon-reload
systemctl enable --now gitea-pr-runner.service
systemctl restart gitea-pr-runner.service
echo "PR runner ready. Configuration backups: *.before-$stamp"
+13 -13
View File
@@ -14,7 +14,7 @@ concurrency:
jobs:
compose:
name: Compose
runs-on: homelab
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
timeout-minutes: 15
steps:
- name: Checkout repository
@@ -66,7 +66,7 @@ jobs:
fi
workflows:
name: Workflows
runs-on: homelab
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
timeout-minutes: 15
steps:
- name: Checkout repository
@@ -102,7 +102,7 @@ jobs:
fi
shell:
name: Shell
runs-on: homelab
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
timeout-minutes: 15
steps:
- name: Checkout repository
@@ -146,7 +146,7 @@ jobs:
fi
formatting:
name: Formatting
runs-on: homelab
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
timeout-minutes: 15
steps:
- name: Checkout repository
@@ -194,7 +194,7 @@ jobs:
fi
python:
name: Python and tests
runs-on: homelab
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
timeout-minutes: 15
steps:
- name: Checkout repository
@@ -232,7 +232,7 @@ jobs:
fi
yaml:
name: YAML
runs-on: homelab
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
timeout-minutes: 15
steps:
- name: Checkout repository
@@ -280,7 +280,7 @@ jobs:
fi
dockerfiles:
name: Dockerfiles
runs-on: homelab
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
timeout-minutes: 15
steps:
- name: Checkout repository
@@ -326,7 +326,7 @@ jobs:
fi
kubernetes:
name: Kubernetes
runs-on: homelab
runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
timeout-minutes: 15
steps:
- name: Checkout repository
@@ -395,7 +395,7 @@ jobs:
run: python3 .gitea/workflows/release.py prepare --output build-plan.json
- name: Store the image plan
id: artifact
uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: build-plan
path: build-plan.json
@@ -435,7 +435,7 @@ jobs:
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Download the checked image plan
id: inputs
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: build-plan
- name: Build or reuse this image
@@ -447,7 +447,7 @@ jobs:
run: python3 .gitea/workflows/release.py image --image "$IMAGE_NAME" --output image.json
- name: Store the image result
id: artifact
uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: image-${{ matrix.name }}
path: image.json
@@ -482,7 +482,7 @@ jobs:
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Download all image results
id: inputs
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
path: artifacts
- name: Pin SHA tags and write the complete release
@@ -495,7 +495,7 @@ jobs:
--plan artifacts/build-plan/build-plan.json
- name: Store commit release
id: artifact
uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: release-${{ github.sha }}
path: release.json
+12 -2
View File
@@ -45,12 +45,17 @@ def prepare(source_file):
before = json.loads(json.dumps(config))
for service, settings in config['services'].items():
reference = settings.get('image')
nextcloud_aio_master = project == 'nextcloud' and service == 'nextcloud-aio-mastercontainer'
if not reference or settings.get('build'):
raise ValueError(f'{project}/{service}: Compose deploy requires a published image')
image_repo = reference.split('@')[0].rsplit('/', 1)
image_repo[-1] = image_repo[-1].split(':')[0]
image_repo = '/'.join(image_repo)
if image_repo in release['images']:
# Nextcloud AIO validates the mastercontainer image reference and rejects
# a digest. Keep its configured tag so AIO can start and manage its stack.
if nextcloud_aio_master:
pinned = reference
elif image_repo in release['images']:
pinned = image_repo + '@' + release['images'][image_repo]
elif os.environ.get('REFRESH_IMAGES') != 'true' and reference in locks:
pinned = locks[reference]
@@ -75,7 +80,12 @@ def prepare(source_file):
actual.add(next((d for d in digests or [] if d.split('@')[0] == image_repo), image_id))
if len(actual) > 1:
raise ValueError(f'{project}/{service}: mixed running images, cannot capture one recovery config')
before['services'][service]['image'] = next(iter(actual)) if actual else reference
# AIO also rejects a digest in its recovery config. Preserve its tag in
# both deploy and recovery files.
if nextcloud_aio_master:
before['services'][service]['image'] = reference
else:
before['services'][service]['image'] = next(iter(actual)) if actual else reference
for name, data in (('compose', config), ('compose-before', before)):
folder = directory / name
folder.mkdir(mode=0o700, exist_ok=True)
+2 -1
View File
@@ -141,7 +141,8 @@ def make_plan(directory):
planner = load_module('deploy_plan', source / '.gitea/workflows/deploy-plan.py')
request = json.loads((directory / 'request.json').read_text())
previous = json.loads((STATE / 'last-success.json').read_text()) if (STATE / 'last-success.json').exists() else None
helm = json.loads(command('helm', 'list', '--all', '-A', '-o', 'json'))
# Helm 4 lists every release status by default and removed the --all flag.
helm = json.loads(command('helm', 'list', '-A', '-o', 'json'))
plan = planner.make_plan(source, CONFIG_REPO, request['release'], previous, request['mode'], helm)
if request['refresh_images']:
plan['selected']['compose'] = plan['active']['compose']
+2 -1
View File
@@ -180,7 +180,8 @@ save_snapshot() {
| select(any(.metadata.ownerReferences[]?; .uid == $w.metadata.uid))
| select($w.kind != "StatefulSet" or .metadata.name == $w.status.currentRevision) | .revision] | max // 0) end)
}]' "$dir/workloads.json" >"$dir/revisions.json" || return 1
releases="$(helm list --all -A -o json)" || return 1
# Helm 4 lists every release status by default and removed the --all flag.
releases="$(helm list -A -o json)" || return 1
for entry in "${HELM_RELEASES[@]}"; do
IFS='|' read -r release _ namespace _ _ _ <<<"$entry"
if ! jq -e --arg r "$release" --arg n "$namespace" \
+1 -1
View File
@@ -80,7 +80,7 @@ jobs:
apply:
needs: [gate]
runs-on: homelab
timeout-minutes: 100
timeout-minutes: 120
steps:
- name: Checkout checked commit
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+27 -17
View File
@@ -1,7 +1,9 @@
name: renovate-ci
on:
pull_request:
# Read the workflow from the trusted base branch. PR code runs only on the
# unprivileged runner selected below.
pull_request_target:
paths:
- "renovate/**"
- ".gitea/workflows/renovate-ci.yaml"
@@ -26,37 +28,47 @@ permissions:
jobs:
validate-renovate:
runs-on: homelab
runs-on: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }}
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
# renovate/k8s/cronjob.yaml is the single source of truth for the image tag,
# so the same version that runs in the cluster is the one validated here.
- name: Resolve the deployed Renovate image
# renovate/k8s/cronjob.yaml is the single source of truth for the version.
- name: Resolve the deployed Renovate version
id: image
shell: bash
run: |
set -euo pipefail
image="$(sed -n 's|.*image:[[:space:]]*\(renovate/renovate:[^[:space:]]*\).*|\1|p' \
renovate/k8s/cronjob.yaml | head -1)"
if [ -z "$image" ]; then
echo "::error::no renovate/renovate image found in renovate/k8s/cronjob.yaml"
if [[ ! "$image" =~ ^renovate/renovate:([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
echo "::error::expected a pinned renovate/renovate semantic version in renovate/k8s/cronjob.yaml"
exit 1
fi
echo "using $image"
echo "image=$image" >> "$GITHUB_OUTPUT"
version="${BASH_REMATCH[1]}"
echo "using Renovate $version"
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
- name: Validate Renovate repository config
- name: Prepare pinned validation tools
shell: bash
run: |
set -euo pipefail
docker run --rm \
-v "$PWD/renovate:/opt/renovate:ro" \
-e RENOVATE_CONFIG_FILE=/opt/renovate/renovate.json \
"${{ steps.image.outputs.image }}" \
renovate-config-validator /opt/renovate/renovate.json
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh kubeconform node)"
echo "$tools_dir" >> "$GITHUB_PATH"
- name: Validate Renovate repository config
shell: bash
env:
RENOVATE_VERSION: ${{ steps.image.outputs.version }}
run: |
set -euo pipefail
npm_cache="$(mktemp -d "${RUNNER_TEMP:-/tmp}/renovate-npm-cache.XXXXXXXX")"
trap 'rm -rf "$npm_cache"' EXIT
NPM_CONFIG_CACHE="$npm_cache" RENOVATE_CONFIG_FILE="$PWD/renovate/renovate.json" \
npm exec --yes --package="renovate@${RENOVATE_VERSION}" -- renovate-config-validator
# The CronJob cannot read the repository, so renovate/k8s/configmap.yaml
# carries an inlined copy of the config. Fail if it no longer matches.
@@ -70,8 +82,6 @@ jobs:
shell: bash
run: |
set -euo pipefail
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh kubeconform)"
export PATH="$tools_dir:$PATH"
kubeconform \
-strict \
-ignore-missing-schemas \
+11 -5
View File
@@ -32,11 +32,14 @@ concurrency:
jobs:
run-renovate:
if: github.ref == 'refs/heads/main'
runs-on: homelab
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: refs/heads/main
# renovate/k8s/cronjob.yaml is the single source of truth for the image tag.
# Reading it here means this workflow validates and runs the exact version
@@ -48,21 +51,23 @@ jobs:
set -euo pipefail
image="$(sed -n 's|.*image:[[:space:]]*\(renovate/renovate:[^[:space:]]*\).*|\1|p' \
renovate/k8s/cronjob.yaml | head -1)"
if [ -z "$image" ]; then
echo "::error::no renovate/renovate image found in renovate/k8s/cronjob.yaml"
if [[ ! "$image" =~ ^renovate/renovate:[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::expected a pinned renovate/renovate semantic version in renovate/k8s/cronjob.yaml"
exit 1
fi
echo "using $image"
echo "image=$image" >> "$GITHUB_OUTPUT"
printf 'image=%s\n' "$image" >> "$GITHUB_OUTPUT"
- name: Validate Renovate config
shell: bash
env:
RENOVATE_IMAGE: ${{ steps.image.outputs.image }}
run: |
set -euo pipefail
docker run --rm \
-v "$PWD/renovate/renovate.json:/opt/renovate/renovate.json:ro" \
-e RENOVATE_CONFIG_FILE=/opt/renovate/renovate.json \
"${{ steps.image.outputs.image }}" \
"$RENOVATE_IMAGE" \
renovate-config-validator
- name: Run Renovate
@@ -73,6 +78,7 @@ jobs:
RENOVATE_REPOSITORIES: ${{ inputs.repositories }}
RENOVATE_DRY_RUN: ${{ inputs.dry_run && 'full' || '' }}
LOG_LEVEL: ${{ inputs.log_level }}
RENOVATE_IMAGE: ${{ steps.image.outputs.image }}
run: |
set -euo pipefail
@@ -89,4 +95,4 @@ jobs:
-e RENOVATE_CONFIG_FILE=/opt/renovate/renovate.json \
-e RENOVATE_BASE_DIR=/tmp/renovate \
-e LOG_LEVEL="${LOG_LEVEL:-info}" \
"${{ steps.image.outputs.image }}"
"$RENOVATE_IMAGE"
+2
View File
@@ -20,6 +20,8 @@ data:
GITEA__mailer__ENABLED: "false"
GITEA__metrics__ENABLED: "true"
# No code/issue search needed: bleve reindexes the whole issue index on
# every pod restart (cron.rebuild_issue_indexer RUN_AT_START) and hammers
# the rotational disk for an hour. "db" serves issue search from postgres.
+2
View File
@@ -3,6 +3,8 @@ kind: Service
metadata:
name: gitea-service
namespace: gitea
labels:
app: gitea
spec:
selector:
app: gitea
+2 -1
View File
@@ -7,7 +7,8 @@ spec:
entryPoints:
- websecure
routes:
- match: Host(`gitea.forust.xyz`) || Host(`git.forust.xyz`)
# Metrics are scraped directly through the cluster Service.
- match: (Host(`gitea.forust.xyz`) || Host(`git.forust.xyz`)) && !PathPrefix(`/metrics`)
kind: Rule
services:
- name: gitea-service
+16
View File
@@ -0,0 +1,16 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: gitea
namespace: gitea
labels:
release: prometheus-stack
spec:
selector:
matchLabels:
app: gitea
endpoints:
- port: http
path: /metrics
interval: 30s
scrapeTimeout: 10s
+1 -1
View File
@@ -7,7 +7,7 @@
# # Dev server_url
# server_url: https://hs.dev_internal_domain.internal
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090
metrics_listen_addr: 0.0.0.0:9090
grpc_listen_addr: 127.0.0.1:50443
grpc_allow_insecure: false
noise:
@@ -3,6 +3,8 @@ kind: Service
metadata:
name: headscale-server-external
namespace: headscale
labels:
app: headscale
spec:
ports:
- port: 8080
+18
View File
@@ -0,0 +1,18 @@
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMServiceScrape
metadata:
name: headscale
namespace: headscale
labels:
release: prometheus-stack
spec:
# The external Service has a manually managed EndpointSlice, not Endpoints.
discoveryRole: endpointslice
selector:
matchLabels:
app: headscale
endpoints:
- port: metrics
path: /metrics
interval: 30s
scrapeTimeout: 10s
+4
View File
@@ -6,6 +6,10 @@ metadata:
data:
TZ: "Europe/Bratislava"
IMMICH_TELEMETRY_INCLUDE: "all"
IMMICH_API_METRICS_PORT: "8081"
IMMICH_MICROSERVICES_METRICS_PORT: "8082"
# The database in this namespace, not the shared one in the database
# namespace: v3 needs VectorChord, and only the dedicated image carries it.
DB_HOSTNAME: "immich-postgres"
+12
View File
@@ -3,6 +3,8 @@ kind: Service
metadata:
name: immich-service
namespace: immich
labels:
app: immich
spec:
selector:
app: immich
@@ -10,6 +12,12 @@ spec:
- name: http
port: 2283
targetPort: 2283
- name: api-metrics
port: 8081
targetPort: api-metrics
- name: worker-metrics
port: 8082
targetPort: worker-metrics
---
apiVersion: apps/v1
kind: Deployment
@@ -41,6 +49,10 @@ spec:
ports:
- name: http
containerPort: 2283
- name: api-metrics
containerPort: 8081
- name: worker-metrics
containerPort: 8082
volumeMounts:
- name: immich-data
mountPath: /data
+20
View File
@@ -0,0 +1,20 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: immich
namespace: immich
labels:
release: prometheus-stack
spec:
selector:
matchLabels:
app: immich
endpoints:
- port: api-metrics
path: /metrics
interval: 30s
scrapeTimeout: 10s
- port: worker-metrics
path: /metrics
interval: 30s
scrapeTimeout: 10s
+9
View File
@@ -3,6 +3,8 @@ kind: Service
metadata:
name: netbird-server-service
namespace: netbird
labels:
app: netbird-server
spec:
selector:
app: netbird-server
@@ -11,6 +13,10 @@ spec:
name: http
targetPort: 80
protocol: TCP
- port: 9090
name: metrics
targetPort: metrics
protocol: TCP
- port: 3478
name: stun
targetPort: 3478
@@ -59,6 +65,9 @@ spec:
- containerPort: 80
name: http
protocol: TCP
- containerPort: 9090
name: metrics
protocol: TCP
- containerPort: 3478
name: stun
protocol: UDP
+16
View File
@@ -0,0 +1,16 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: netbird-server
namespace: netbird
labels:
release: prometheus-stack
spec:
selector:
matchLabels:
app: netbird-server
endpoints:
- port: metrics
path: /metrics
interval: 30s
scrapeTimeout: 10s
+51
View File
@@ -0,0 +1,51 @@
# Paperless-ngx
Paperless-ngx runs in the `paperless` namespace. It uses the shared PostgreSQL
service in the `database` namespace and Valkey for its task queue. The document
library, exports, and consume folder are stored on the `local-path-retain`
volume. The PVC size is fixed at 50 GiB because this storage class does not
support volume expansion.
The local route is `https://papers.workstation.internal`; the public route is
`https://papers.forust.xyz`. Both use TLS. Paperless keeps its own login and
password authentication. OCR is configured for Russian and English documents.
Keep `papers.forust.xyz` in the existing Cloudflare DDNS `DOMAINS` setting so
the public record follows the workstation address.
## Prepare the secret
Create `k8s/secrets.yaml` on the workstation from
`k8s/secrets.yaml.example`. Set a unique random `PAPERLESS_SECRET_KEY`, a long
`PAPERLESS_ADMIN_PASSWORD`, and `PAPERLESS_DB_PASSWORD`.
Add the same `PAPERLESS_DB_PASSWORD` value to the local
`postgres/k8s/secrets.yaml` file. Keep both secret files out of Git. The
database bootstrap Job creates the `paperless` role and database from the
shared PostgreSQL secret. The job runs in the `database` namespace and needs
that namespace's existing `postgres-shared-secrets` Secret.
For example, generate a key with:
```sh
python3 -c 'import secrets; print(secrets.token_urlsafe(64))'
```
Then apply the secret before enabling the service:
```sh
kubectl apply -f paperless/k8s/namespace.yaml
kubectl apply -f postgres/k8s/secrets.yaml
kubectl apply -f paperless/k8s/secrets.yaml
```
The normal deploy workflow applies the remaining manifests when
`paperless/k8s/active` is present. Verify the rollout and ingress after deploy:
```sh
kubectl -n paperless rollout status deployment/paperless
kubectl -n paperless get pods,pvc,services
```
Back up the `paperless-data` PVC and the shared PostgreSQL database. The PVC
contains the originals, archived PDFs, and export/consume folders. Valkey has
no persistent volume; queued tasks are recreated after a restart.
+1
View File
@@ -0,0 +1 @@
+28
View File
@@ -0,0 +1,28 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: paperless-prod-tls
namespace: paperless
spec:
secretName: paperless-prod-tls
dnsNames:
- papers.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: paperless
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+58
View File
@@ -0,0 +1,58 @@
apiVersion: batch/v1
kind: Job
metadata:
name: paperless-database-init
namespace: database
spec:
backoffLimit: 5
template:
metadata:
labels:
app.kubernetes.io/name: paperless-database-init
spec:
restartPolicy: OnFailure
containers:
- name: create-database
image: postgres:17.11-alpine
command:
- /bin/sh
- -ec
- |
PGPASSWORD="$POSTGRES_ADMIN_PASSWORD" psql \
--host postgres \
--username postgres \
--dbname postgres \
--set ON_ERROR_STOP=1 \
--set paperless_password="$PAPERLESS_DB_PASSWORD" <<'SQL'
SELECT format(
'CREATE ROLE paperless LOGIN PASSWORD %L',
:'paperless_password'
)
WHERE NOT EXISTS (
SELECT FROM pg_roles WHERE rolname = 'paperless'
)
\gexec
SELECT format('CREATE DATABASE paperless OWNER paperless')
WHERE NOT EXISTS (
SELECT FROM pg_database WHERE datname = 'paperless'
)
\gexec
SQL
env:
- name: POSTGRES_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-shared-secrets
key: POSTGRES_ADMIN_PASSWORD
- name: PAPERLESS_DB_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-shared-secrets
key: PAPERLESS_DB_PASSWORD
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
+33
View File
@@ -0,0 +1,33 @@
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: paperless-prod
namespace: paperless
spec:
entryPoints:
- websecure
routes:
- match: Host(`papers.forust.xyz`)
kind: Rule
services:
- name: paperless
port: 8000
tls:
secretName: paperless-prod-tls
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: paperless-local
namespace: paperless
spec:
entryPoints:
- websecure
routes:
- match: Host(`papers.workstation.internal`)
kind: Rule
services:
- name: paperless
port: 8000
tls:
secretName: internal-wildcard-tls
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: paperless
+39
View File
@@ -0,0 +1,39 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: paperless-ingress
namespace: paperless
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: paperless
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik
ports:
- protocol: TCP
port: 8000
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: paperless-valkey-ingress
namespace: paperless
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: paperless-valkey
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: paperless
ports:
- protocol: TCP
port: 6379
+210
View File
@@ -0,0 +1,210 @@
apiVersion: v1
kind: Service
metadata:
name: paperless
namespace: paperless
labels:
app.kubernetes.io/name: paperless
spec:
selector:
app.kubernetes.io/name: paperless
ports:
- name: http
port: 8000
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: paperless
namespace: paperless
labels:
app.kubernetes.io/name: paperless
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: paperless
template:
metadata:
labels:
app.kubernetes.io/name: paperless
spec:
enableServiceLinks: false
containers:
- name: paperless
image: ghcr.io/paperless-ngx/paperless-ngx:3.2.1
ports:
- name: http
containerPort: 8000
env:
- name: PAPERLESS_URL
value: https://papers.forust.xyz
- name: PAPERLESS_ALLOWED_HOSTS
value: papers.forust.xyz,papers.workstation.internal
- name: PAPERLESS_CSRF_TRUSTED_ORIGINS
value: https://papers.forust.xyz,https://papers.workstation.internal
- name: PAPERLESS_TIME_ZONE
value: Europe/Bratislava
- name: PAPERLESS_REDIS
value: redis://paperless-valkey:6379
- name: PAPERLESS_DBENGINE
value: postgresql
- name: PAPERLESS_DBHOST
value: postgres.database.svc.cluster.local
- name: PAPERLESS_DBNAME
value: paperless
- name: PAPERLESS_DBUSER
value: paperless
- name: PAPERLESS_DBPASS
valueFrom:
secretKeyRef:
name: paperless-secrets
key: PAPERLESS_DB_PASSWORD
- name: PAPERLESS_OCR_LANGUAGE
value: rus+eng
- name: PAPERLESS_OCR_LANGUAGES
value: rus
- name: PAPERLESS_TASK_WORKERS
value: "1"
- name: PAPERLESS_ADMIN_USER
value: admin
- name: PAPERLESS_SECRET_KEY
valueFrom:
secretKeyRef:
name: paperless-secrets
key: PAPERLESS_SECRET_KEY
- name: PAPERLESS_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: paperless-secrets
key: PAPERLESS_ADMIN_PASSWORD
volumeMounts:
- name: documents
mountPath: /usr/src/paperless/data
subPath: data
- name: documents
mountPath: /usr/src/paperless/media
subPath: media
- name: documents
mountPath: /usr/src/paperless/export
subPath: export
- name: documents
mountPath: /usr/src/paperless/consume
subPath: consume
startupProbe:
httpGet:
path: /
port: http
httpHeaders:
- name: Host
value: papers.workstation.internal
failureThreshold: 60
periodSeconds: 10
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /
port: http
httpHeaders:
- name: Host
value: papers.workstation.internal
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /
port: http
httpHeaders:
- name: Host
value: papers.workstation.internal
periodSeconds: 30
timeoutSeconds: 5
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
volumes:
- name: documents
persistentVolumeClaim:
claimName: paperless-data
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: paperless-data
namespace: paperless
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path-retain
resources:
requests:
storage: 50Gi
---
apiVersion: v1
kind: Service
metadata:
name: paperless-valkey
namespace: paperless
labels:
app.kubernetes.io/name: paperless-valkey
spec:
selector:
app.kubernetes.io/name: paperless-valkey
ports:
- name: redis
port: 6379
targetPort: redis
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: paperless-valkey
namespace: paperless
labels:
app.kubernetes.io/name: paperless-valkey
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: paperless-valkey
template:
metadata:
labels:
app.kubernetes.io/name: paperless-valkey
spec:
containers:
- name: valkey
image: valkey/valkey:9.0.3-alpine
args:
- valkey-server
- --save
- ""
- --appendonly
- "no"
ports:
- name: redis
containerPort: 6379
readinessProbe:
exec:
command: ["valkey-cli", "ping"]
periodSeconds: 10
livenessProbe:
exec:
command: ["valkey-cli", "ping"]
periodSeconds: 30
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 250m
memory: 256Mi
+10
View File
@@ -0,0 +1,10 @@
apiVersion: v1
kind: Secret
metadata:
name: paperless-secrets
namespace: paperless
type: Opaque
stringData:
PAPERLESS_SECRET_KEY: "<GENERATE_WITH_python3_-c_import_secrets;_print(secrets.token_urlsafe(64))>"
PAPERLESS_ADMIN_PASSWORD: "<SET_A_LONG_UNIQUE_PASSWORD>"
PAPERLESS_DB_PASSWORD: "<SET_THE_SAME_VALUE_AS_database_PAPERLESS_DB_PASSWORD>"
+1
View File
@@ -4,4 +4,5 @@ GITEA_DB_PASSWORD=
NETBOX_DB_PASSWORD=
NETRONOME_DB_PASSWORD=
PENPOT_DB_PASSWORD=
PAPERLESS_DB_PASSWORD=
STATUSPAGE_DB_PASSWORD=
+12 -11
View File
@@ -1,20 +1,21 @@
# Shared PostgreSQL
This directory contains the shared PostgreSQL 17 deployment for Authentik,
Gitea, NetBox, Netronome, and Statuspage. It creates one database and one login role
per service. Per-service standalone databases were removed after the
migration (Sep 2026); Penpot stays on its own compose PostgreSQL (archived,
not part of the shared instance).
Gitea, NetBox, Netronome, Paperless-ngx, and Statuspage. It creates one database
and one login role per service. Per-service standalone databases were removed
after the migration (Sep 2026); Penpot stays on its own compose PostgreSQL
(archived, not part of the shared instance).
## Compatibility baseline
| Service | Current application | Shared PostgreSQL 17 |
| ---------- | ------------------- | -------------------------------------- |
| Authentik | 2025.10.x | Supported (Authentik requires 14+) |
| Gitea | 1.27.3 | Supported (Gitea requires 12+) |
| NetBox | 4.7.x | Supported (NetBox 4.x requires 13+) |
| Netronome | 0.14.0 | Supported (upstream's example uses 17) |
| Statuspage | custom | Supported |
| Service | Current application | Shared PostgreSQL 17 |
| ------------- | ------------------- | -------------------------------------- |
| Authentik | 2025.10.x | Supported (Authentik requires 14+) |
| Gitea | 1.27.3 | Supported (Gitea requires 12+) |
| NetBox | 4.7.x | Supported (NetBox 4.x requires 13+) |
| Netronome | 0.14.0 | Supported (upstream's example uses 17) |
| Paperless-ngx | 3.2.1 | Supported |
| Statuspage | custom | Supported |
A major-version change must use a logical dump/restore; changing only the
image tag while keeping a data directory is not supported.
+12
View File
@@ -26,6 +26,18 @@ spec:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: penpot
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: paperless
podSelector:
matchLabels:
app.kubernetes.io/name: paperless
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: database
podSelector:
matchLabels:
app.kubernetes.io/name: paperless-database-init
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: statuspage
+2
View File
@@ -126,6 +126,7 @@ data:
: "${NETBOX_DB_PASSWORD:?NETBOX_DB_PASSWORD is required}"
: "${NETRONOME_DB_PASSWORD:?NETRONOME_DB_PASSWORD is required}"
: "${PENPOT_DB_PASSWORD:?PENPOT_DB_PASSWORD is required}"
: "${PAPERLESS_DB_PASSWORD:?PAPERLESS_DB_PASSWORD is required}"
: "${STATUSPAGE_DB_PASSWORD:?STATUSPAGE_DB_PASSWORD is required}"
create_role_and_database() {
@@ -147,4 +148,5 @@ data:
create_role_and_database netbox netbox "$NETBOX_DB_PASSWORD"
create_role_and_database netronome netronome "$NETRONOME_DB_PASSWORD"
create_role_and_database penpot penpot "$PENPOT_DB_PASSWORD"
create_role_and_database paperless paperless "$PAPERLESS_DB_PASSWORD"
create_role_and_database statuspage statuspage "$STATUSPAGE_DB_PASSWORD"
+1
View File
@@ -11,4 +11,5 @@ stringData:
NETBOX_DB_PASSWORD: ""
NETRONOME_DB_PASSWORD: ""
PENPOT_DB_PASSWORD: ""
PAPERLESS_DB_PASSWORD: ""
STATUSPAGE_DB_PASSWORD: ""
+26
View File
@@ -15,3 +15,29 @@ The VictoriaMetrics Operator chart and its CRDs are installed before the
Kubernetes manifests by the normal deploy workflow. On a cluster where the
operator CRDs are not installed yet, CI skips the server-side dry-run of the
`VMAgent` resource; the deploy installs the chart before applying that resource.
## Application metrics
The application ServiceMonitors use a 30s interval and a 10s timeout:
- Headscale: the external Service points to the Compose host on port 19090.
A VMServiceScrape uses EndpointSlice discovery for this manually managed target.
The Compose configuration must bind metrics to `0.0.0.0:9090`.
- NetBird: the combined server exports `/metrics` on port 9090. The existing
`server.metricsPort` setting enables the listener.
- Gitea: `GITEA__metrics__ENABLED` enables `/metrics` on the HTTP port. The public
ingress excludes this path. The monitor uses the internal Service directly.
- Immich: `IMMICH_TELEMETRY_INCLUDE=all` enables API and worker metrics on ports
8081 and 8082. The monitor scrapes both ports on each server replica.
Deploy through the existing CI and deploy workflow. Gitea and Immich reload their
ConfigMap changes through Reloader. Check the VMAgent targets after deployment
and query `up{scraper="victoria",namespace=~"netbird|gitea|immich|headscale"}` in
VictoriaMetrics. All targets should report 1.
For rollback, revert the application metrics changes, run CI, and deploy the
revert. Remove the three application ServiceMonitors and the Headscale VMServiceScrape explicitly: the deployment
workflow applies manifests and does not prune removed resources.
For Headscale rollback, remove its VMServiceScrape and Service label, restore the
previous Compose metrics bind address, and restart only the Headscale service.
View File
Whitespace-only changes.
View File
Whitespace-only changes.