Initial release: self-hosted client photo gallery
ci / docker (push) Successful in 13s

Rust (axum + sqlx) API and worker sharing a Postgres-backed job queue
(SKIP LOCKED, heartbeat, reaper, typed statuses), S3 storage with derived
keys and a fully private bucket, OIDC photographer login with per-request
allowlist checks, client share links with argon2 passwords and lockout,
cookie-based image authorization with sliding expiry, hand-rolled
spec-compliant streaming ZIP downloads with exact Content-Length,
React + Vite gallery frontend, single Docker image, Helm chart for
external S3 + Postgres, and Gitea CI.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-17 13:12:42 +02:00
co-authored by Claude
commit 16d2a56a78
55 changed files with 11962 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: photos
description: Self-hosted client photo gallery (Rust API + worker, React frontend)
type: application
version: 0.1.0
appVersion: "0.1.0"
+42
View File
@@ -0,0 +1,42 @@
{{- define "photos.fullname" -}}
{{- if contains "photos" .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-photos" .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- define "photos.labels" -}}
app.kubernetes.io/name: photos
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{- define "photos.secretName" -}}
{{- if .Values.secrets.existingSecret -}}
{{- .Values.secrets.existingSecret -}}
{{- else -}}
{{- include "photos.fullname" . -}}
{{- end -}}
{{- end -}}
{{- define "photos.env" -}}
- name: PUBLIC_URL
value: {{ .Values.publicUrl | quote }}
- name: S3_BUCKET
value: {{ .Values.config.s3.bucket | quote }}
- name: S3_REGION
value: {{ .Values.config.s3.region | quote }}
{{- if .Values.config.s3.endpoint }}
- name: S3_ENDPOINT
value: {{ .Values.config.s3.endpoint | quote }}
{{- end }}
- name: S3_FORCE_PATH_STYLE
value: {{ .Values.config.s3.forcePathStyle | quote }}
- name: OIDC_ISSUER
value: {{ .Values.config.oidcIssuer | quote }}
- name: ALLOWED_EMAILS
value: {{ .Values.config.allowedEmails | quote }}
- name: RUST_LOG
value: {{ .Values.config.logLevel | quote }}
{{- end -}}
@@ -0,0 +1,47 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "photos.fullname" . }}-api
labels:
{{- include "photos.labels" . | nindent 4 }}
app.kubernetes.io/component: api
spec:
replicas: {{ .Values.api.replicas }}
selector:
matchLabels:
app.kubernetes.io/name: photos
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: api
template:
metadata:
labels:
{{- include "photos.labels" . | nindent 8 }}
app.kubernetes.io/component: api
spec:
containers:
- name: api
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["server"]
ports:
- name: http
containerPort: 8080
env:
{{- include "photos.env" . | nindent 12 }}
- name: BIND_ADDR
value: "0.0.0.0:8080"
envFrom:
- secretRef:
name: {{ include "photos.secretName" . }}
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 3
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 10
resources:
{{- toYaml .Values.api.resources | nindent 12 }}
@@ -0,0 +1,34 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "photos.fullname" . }}-worker
labels:
{{- include "photos.labels" . | nindent 4 }}
app.kubernetes.io/component: worker
spec:
replicas: {{ .Values.worker.replicas }}
selector:
matchLabels:
app.kubernetes.io/name: photos
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: worker
template:
metadata:
labels:
{{- include "photos.labels" . | nindent 8 }}
app.kubernetes.io/component: worker
spec:
containers:
- name: worker
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["worker"]
env:
{{- include "photos.env" . | nindent 12 }}
- name: WORKER_CONCURRENCY
value: {{ .Values.worker.concurrency | quote }}
envFrom:
- secretRef:
name: {{ include "photos.secretName" . }}
resources:
{{- toYaml .Values.worker.resources | nindent 12 }}
+33
View File
@@ -0,0 +1,33 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "photos.fullname" . }}
labels:
{{- include "photos.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.ingress.host }}
secretName: {{ .Values.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "photos.fullname" . }}
port:
name: http
{{- end }}
+15
View File
@@ -0,0 +1,15 @@
{{- if not .Values.secrets.existingSecret }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "photos.fullname" . }}
labels:
{{- include "photos.labels" . | nindent 4 }}
stringData:
DATABASE_URL: {{ required "secrets.databaseUrl (or secrets.existingSecret) is required" .Values.secrets.databaseUrl | quote }}
S3_ACCESS_KEY: {{ required "secrets.s3AccessKey is required" .Values.secrets.s3AccessKey | quote }}
S3_SECRET_KEY: {{ required "secrets.s3SecretKey is required" .Values.secrets.s3SecretKey | quote }}
OIDC_CLIENT_ID: {{ required "secrets.oidcClientId is required" .Values.secrets.oidcClientId | quote }}
OIDC_CLIENT_SECRET: {{ required "secrets.oidcClientSecret is required" .Values.secrets.oidcClientSecret | quote }}
SESSION_SECRET: {{ required "secrets.sessionSecret is required" .Values.secrets.sessionSecret | quote }}
{{- end }}
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "photos.fullname" . }}
labels:
{{- include "photos.labels" . | nindent 4 }}
spec:
selector:
app.kubernetes.io/name: photos
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: api
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
+68
View File
@@ -0,0 +1,68 @@
image:
repository: ghcr.io/CHANGE-ME/photos
tag: latest
pullPolicy: IfNotPresent
api:
replicas: 1
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 1Gi
worker:
replicas: 1
concurrency: 2
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
memory: 2Gi
# External base URL of the app; the OIDC redirect URI is <publicUrl>/api/auth/callback
publicUrl: https://photos.example.com
config:
s3:
# Leave endpoint empty for AWS S3; set for MinIO/Ceph/etc.
endpoint: ""
region: us-east-1
bucket: photos
forcePathStyle: false
oidcIssuer: https://auth.example.com
# Comma-separated photographer emails allowed to sign in
allowedEmails: you@example.com
logLevel: info,sqlx=warn
# Sensitive settings. Either reference an existing Secret containing the keys
# DATABASE_URL, S3_ACCESS_KEY, S3_SECRET_KEY, OIDC_CLIENT_ID,
# OIDC_CLIENT_SECRET, SESSION_SECRET — or inline the values and the chart
# creates the Secret for you.
secrets:
existingSecret: ""
databaseUrl: ""
s3AccessKey: ""
s3SecretKey: ""
oidcClientId: ""
oidcClientSecret: ""
sessionSecret: ""
service:
port: 80
ingress:
enabled: true
className: traefik
host: photos.example.com
# Traefik needs nothing extra: no default body-size limit, streams uploads.
# For ingress-nginx set className: nginx and uncomment (multi-GB raw
# uploads hit nginx's 1MiB default limit otherwise):
# nginx.ingress.kubernetes.io/proxy-body-size: "0"
# nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
annotations: {}
tls:
enabled: true
secretName: photos-tls