Publish reusable Quay chart with optional PostgreSQL and Valkey
Validate, publish and promote Quay chart / chart (push) Failing after 21s
Validate and deploy Quay / Downtime deployment (push) Successful in 19s
Validate and deploy Quay / Validate manifest (push) Successful in 11s

This commit is contained in:
2026-10-20 08:54:24 +02:00
parent 05da8823f0
commit 3335a6956f
15 changed files with 439 additions and 6 deletions
+72
View File
@@ -0,0 +1,72 @@
name: Validate, publish and promote Quay chart
on:
push:
pull_request:
jobs:
chart:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: azure/setup-helm@v4
with:
version: v4.3.0
- name: Validate chart
run: |
python3 -m venv "${RUNNER_TEMP}/chart-test"
"${RUNNER_TEMP}/chart-test/bin/pip" install PyYAML
helm dependency build chart/quay
PATH="${RUNNER_TEMP}/chart-test/bin:${PATH}" bash chart/test.sh
- name: Select immutable chart version
if: gitea.ref == 'refs/heads/main'
run: printf 'CHART_VERSION=%s\n' "$(./scripts/chart-version.sh)" >> "${GITHUB_ENV}"
- name: Publish chart
if: gitea.ref == 'refs/heads/main'
env:
PACKAGE_USERNAME: ${{ secrets.PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "${PACKAGE_USERNAME}" && test -n "${PACKAGE_TOKEN}"
mkdir -p .chart-packages
helm package chart/quay --destination .chart-packages --version "${CHART_VERSION}"
curl --fail --silent --show-error --user "${PACKAGE_USERNAME}:${PACKAGE_TOKEN}" \
--request POST --upload-file ".chart-packages/quay-${CHART_VERSION}.tgz" \
https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts
- name: Check current main
id: current-main
if: gitea.ref == 'refs/heads/main'
env:
COMMIT_SHA: ${{ gitea.sha }}
run: |
git fetch --no-tags origin main
if test "$(git rev-parse FETCH_HEAD)" = "${COMMIT_SHA}"; then
echo 'promote=true' >> "${GITEA_OUTPUT}"
else
echo 'promote=false' >> "${GITEA_OUTPUT}"
fi
- uses: actions/checkout@v4
if: steps.current-main.outputs.promote == 'true'
with:
repository: feedc0de/homelab-gitops
ref: main
token: ${{ secrets.GITOPS_TOKEN }}
path: homelab-gitops
- name: Promote chart
if: steps.current-main.outputs.promote == 'true'
working-directory: homelab-gitops
run: |
set -euo pipefail
./scripts/update-chart-version.sh quay "${CHART_VERSION}"
git config user.name feedc0de
git config user.email feedc0de@users.noreply.code.brunner.ninja
git add bootstrap/quay.yaml
if git diff --cached --quiet; then exit 0; fi
git commit -m "Deploy Quay chart ${CHART_VERSION}"
for attempt in 1 2 3; do
git push origin HEAD:main && exit 0
test "${attempt}" = 3 && exit 1
git fetch origin main
git rebase origin/main
done
+2
View File
@@ -1,3 +1,5 @@
config.yaml
*.kubeconfig
.env
chart/quay/charts/
.chart-packages/
+9 -6
View File
@@ -1,12 +1,15 @@
# Quay on Kubernetes
Plain Kubernetes deployment for `https://registry.brunner.ninja` in the
`default` namespace. Quay updates are deliberately simple: change both matching
`quay.io/projectquay/quay` image references in `quay.yaml`, commit, and push to
`main`. Gitea Actions performs the Kubernetes update.
This repository now maintains a [reusable Helm chart](chart/README.md) for
Project Quay. It declares Quay's registry and repository mirror, optional
PostgreSQL and Valkey subcharts, and the Service and Ingress. The chart uses
the official Quay image and is published to `https://brunner.ninja/charts`.
The deployment uses plain YAML. There is no Helm chart, Operator, Kustomize,
Argo CD, or custom container build.
The homelab migration is staged in
[`homelab-gitops/apps/quay`](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/quay).
Until Argo CD has adopted and verified the live Deployments, the older plain
manifest and direct-deploy workflow below remain the rollback path. Do not
change `quay.yaml` during the database cutover.
## Persistent data
+44
View File
@@ -0,0 +1,44 @@
# Quay Helm chart
This chart runs the official Project Quay image and owns the registry
Deployment, repository-mirror Deployment, Service, Ingress, and probes. It can
also deploy PostgreSQL and Valkey through optional Bitnami subcharts. It does
not create a second container image.
An S3-compatible object store and bucket are required. Provision them outside
this chart, then create a private `config.yaml` using
[`config.yaml.example`](config.yaml.example). The file includes the database
URI, S3 credentials, Redis credentials, and two persistent Quay encryption
keys. Keep all of those out of Helm values and public Git. Store the file in a
Secret in the release namespace:
```sh
kubectl -n default create secret generic quay-config --from-file=config.yaml
```
Set `quay.configSecretName` to that Secret name. For an existing installation,
preserve its `SECRET_KEY`, `DATABASE_SECRET_KEY`, bucket, and database records.
The chart never creates or deletes S3 objects or buckets.
The optional PostgreSQL subchart is enabled with `postgresql.enabled=true`.
Use a durable storage class and stable `postgresql.auth.existingSecret`; the
`database-password` key must match the password inside Quay's `DB_URI`. The
chart also accepts an external PostgreSQL server with the subchart disabled.
Likewise, `valkey.enabled=true` installs a cache; keep the Redis hosts and
password in `config.yaml` aligned with either that subchart or your external
cache. Both dependencies are independently optional.
Published releases are available from the indexed Helm repository:
```sh
helm repo add brunner.ninja https://brunner.ninja/charts
helm repo update brunner.ninja
helm upgrade --install quay brunner.ninja/quay --namespace default \
--values values.yaml --wait --timeout 15m
```
For local development, run `helm dependency build chart/quay` and
`bash chart/test.sh`. Chart package versions derive from the actual Quay Git
release tag with `git describe`; `Chart.yaml.appVersion` is the tested upstream
image version. Homelab-specific values are in
[`homelab-gitops/apps/quay`](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/quay).
+30
View File
@@ -0,0 +1,30 @@
# Create a private config.yaml from this template and store it as the
# config.yaml key in the Secret selected by quay.configSecretName.
SETUP_COMPLETE: true
SERVER_HOSTNAME: quay.example.org
PREFERRED_URL_SCHEME: https
EXTERNAL_TLS_TERMINATION: true
AUTHENTICATION_TYPE: Database
SECRET_KEY: REPLACE_WITH_STABLE_RANDOM_SECRET_KEY
DATABASE_SECRET_KEY: REPLACE_WITH_STABLE_RANDOM_DATABASE_SECRET_KEY
DB_URI: postgresql://quay:REPLACE_WITH_DATABASE_PASSWORD@quay-postgresql.default.svc.cluster.local/quay
BUILDLOGS_REDIS:
host: quay-valkey-primary.default.svc.cluster.local
port: 6379
password: REPLACE_WITH_VALKEY_PASSWORD
USER_EVENTS_REDIS:
host: quay-valkey-primary.default.svc.cluster.local
port: 6379
password: REPLACE_WITH_VALKEY_PASSWORD
DISTRIBUTED_STORAGE_CONFIG:
default:
- RadosGWStorage
- access_key: REPLACE_WITH_S3_ACCESS_KEY
secret_key: REPLACE_WITH_S3_SECRET_KEY
bucket_name: REPLACE_WITH_EXISTING_BUCKET
hostname: REPLACE_WITH_S3_HOST
is_secure: true
storage_path: /
DISTRIBUTED_STORAGE_DEFAULT_LOCATIONS: []
DISTRIBUTED_STORAGE_PREFERENCE:
- default
+9
View File
@@ -0,0 +1,9 @@
dependencies:
- name: postgresql
repository: https://charts.bitnami.com/bitnami
version: 18.12.4
- name: valkey
repository: https://charts.bitnami.com/bitnami
version: 6.3.4
digest: sha256:ae4c73e405ad893c310aeac3f35de37f7c8cbbd28a7b3089d973a680048990cc
generated: "2026-10-20T08:46:49.462960335+02:00"
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v2
name: quay
description: Project Quay registry with optional PostgreSQL and Valkey
type: application
version: 0.0.0
appVersion: 3.18.0
dependencies:
- name: postgresql
version: 18.12.4
repository: https://charts.bitnami.com/bitnami
condition: postgresql.enabled
- name: valkey
version: 6.3.4
repository: https://charts.bitnami.com/bitnami
condition: valkey.enabled
+70
View File
@@ -0,0 +1,70 @@
{{- if .Values.quay.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: quay
labels:
app: quay
app.kubernetes.io/name: quay
app.kubernetes.io/component: registry
spec:
replicas: {{ .Values.quay.replicas }}
progressDeadlineSeconds: 900
strategy:
type: Recreate
selector:
matchLabels:
app: quay
template:
metadata:
labels:
app: quay
app.kubernetes.io/name: quay
app.kubernetes.io/component: registry
spec:
containers:
- name: quay
image: "{{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}"
env:
- name: DEBUGLOG
value: {{ .Values.quay.debugLog | quote }}
ports:
- name: http
containerPort: 8080
protocol: TCP
startupProbe:
httpGet:
path: /health/instance
port: http
failureThreshold: 60
periodSeconds: 5
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /health/endtoend
port: http
failureThreshold: 3
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /health/instance
port: http
failureThreshold: 3
periodSeconds: 30
timeoutSeconds: 5
{{- with .Values.resources }}
resources:
{{ toYaml . | indent 12 }}
{{- end }}
volumeMounts:
- name: quay-config
mountPath: /conf/stack/config.yaml
subPath: config.yaml
readOnly: true
volumes:
- name: quay-config
secret:
secretName: {{ .Values.quay.configSecretName | quote }}
defaultMode: 0440
{{- end }}
+29
View File
@@ -0,0 +1,29 @@
{{- if and .Values.quay.enabled .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: quay
{{- with .Values.ingress.annotations }}
annotations:
{{ toYaml . | indent 4 }}
{{- end }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . | quote }}
{{- end }}
{{- with .Values.ingress.tls }}
tls:
{{ toYaml . | indent 4 }}
{{- end }}
rules:
- host: {{ .Values.ingress.host | quote }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: quay
port:
name: http
{{- end }}
+45
View File
@@ -0,0 +1,45 @@
{{- if and .Values.quay.enabled .Values.repomirror.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: quay-repomirror
labels:
app: quay-repomirror
app.kubernetes.io/name: quay
app.kubernetes.io/component: repository-mirror
spec:
replicas: {{ .Values.repomirror.replicas }}
strategy:
type: Recreate
selector:
matchLabels:
app: quay-repomirror
template:
metadata:
labels:
app: quay-repomirror
app.kubernetes.io/name: quay
app.kubernetes.io/component: repository-mirror
spec:
containers:
- name: quay-repomirror
image: "{{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}"
args: [repomirror]
env:
- name: DEBUGLOG
value: {{ .Values.quay.debugLog | quote }}
{{- with .Values.repomirrorResources }}
resources:
{{ toYaml . | indent 12 }}
{{- end }}
volumeMounts:
- name: quay-config
mountPath: /conf/stack/config.yaml
subPath: config.yaml
readOnly: true
volumes:
- name: quay-config
secret:
secretName: {{ .Values.quay.configSecretName | quote }}
defaultMode: 0440
{{- end }}
+17
View File
@@ -0,0 +1,17 @@
{{- if .Values.quay.enabled }}
apiVersion: v1
kind: Service
metadata:
name: quay
labels:
app.kubernetes.io/name: quay
spec:
type: ClusterIP
ports:
- name: http
port: {{ .Values.service.port }}
protocol: TCP
targetPort: 8080
selector:
app: quay
{{- end }}
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"image": {"type": "object", "properties": {"repository": {"type": "string", "minLength": 1}, "tag": {"type": "string"}}},
"quay": {"type": "object", "required": ["enabled", "configSecretName"], "properties": {"enabled": {"type": "boolean"}, "replicas": {"type": "integer", "minimum": 0}, "configSecretName": {"type": "string", "minLength": 1}, "debugLog": {"type": "boolean"}}},
"repomirror": {"type": "object", "properties": {"enabled": {"type": "boolean"}, "replicas": {"type": "integer", "minimum": 0}}},
"service": {"type": "object", "properties": {"port": {"type": "integer", "minimum": 1, "maximum": 65535}}},
"ingress": {"type": "object", "properties": {"enabled": {"type": "boolean"}, "host": {"type": "string", "minLength": 1}, "className": {"type": "string"}, "annotations": {"type": "object", "additionalProperties": {"type": "string"}}, "tls": {"type": "array"}}},
"resources": {"type": "object"},
"repomirrorResources": {"type": "object"}
}
}
+35
View File
@@ -0,0 +1,35 @@
image:
repository: quay.io/projectquay/quay
tag: ""
quay:
enabled: true
replicas: 1
configSecretName: quay-config
debugLog: false
repomirror:
enabled: true
replicas: 1
service:
port: 80
ingress:
enabled: false
className: ""
host: quay.example.org
annotations: {}
tls: []
resources: {}
repomirrorResources: {}
postgresql:
enabled: false
auth:
username: quay
database: quay
valkey:
enabled: false
Executable
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
chart_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/quay" && pwd)
helm lint "$chart_dir" --strict
python3 - "$chart_dir" <<'PY'
import subprocess
import sys
import yaml
chart = sys.argv[1]
def render(*args):
output = subprocess.check_output(
['helm', 'template', 'quay', chart, '--namespace', 'default', *args], text=True
)
return {(doc['kind'], doc['metadata']['name']): doc for doc in yaml.safe_load_all(output) if doc}
active = render()
assert {('Deployment', 'quay'), ('Deployment', 'quay-repomirror'),
('Service', 'quay')} <= active.keys()
registry = active['Deployment', 'quay']
mirror = active['Deployment', 'quay-repomirror']
assert registry['spec']['selector']['matchLabels'] == {'app': 'quay'}
assert mirror['spec']['selector']['matchLabels'] == {'app': 'quay-repomirror'}
assert registry['spec']['strategy']['type'] == mirror['spec']['strategy']['type'] == 'Recreate'
assert registry['spec']['template']['spec']['containers'][0]['image'] == mirror['spec']['template']['spec']['containers'][0]['image']
assert registry['spec']['template']['spec']['volumes'][0]['secret']['secretName'] == 'quay-config'
assert active['Service', 'quay']['spec']['selector'] == {'app': 'quay'}
staged = render('--set', 'quay.enabled=false', '--set', 'postgresql.enabled=true')
assert ('StatefulSet', 'quay-postgresql') in staged
assert not any(name == 'quay' or name == 'quay-repomirror' for kind, name in staged)
assert ('Service', 'quay-postgresql') in staged
PY
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
description=$(git -C "$project_dir" describe --tags --long --match 'v[0-9]*' --dirty)
case "$description" in
*-dirty) echo 'Refusing to package a dirty tree.' >&2; exit 1 ;;
esac
version=${description#v}
case "$version" in
*-0-g*) version=${version%%-0-g*} ;;
esac
printf '%s\n' "$version"