Deploy immich-sync through Argo CD
Publish and promote container image / Build and push (push) Successful in 36s
Publish and promote container image / Promote image in GitOps (push) Successful in 17s

This commit is contained in:
2026-10-18 13:02:46 +02:00
parent a6cb14a675
commit dea9ed7ab0
7 changed files with 116 additions and 310 deletions
+39 -49
View File
@@ -1,12 +1,11 @@
name: Publish container image
name: Publish and promote container image
on:
push:
env:
IMAGE: registry.brunner.ninja/feedc0de/immich-sync
KUBERNETES_API: https://host.containers.internal:6443
KUBERNETES_TLS_SERVER_NAME: 192.168.0.2
GITOPS_REPOSITORY: feedc0de/homelab-gitops
jobs:
publish:
@@ -48,60 +47,51 @@ jobs:
docker tag "${IMAGE}:${COMMIT_SHA}" "${IMAGE}:latest"
docker push "${IMAGE}:latest"
deploy:
name: Deploy to Kubernetes
promote:
name: Promote image in GitOps
if: gitea.ref == 'refs/heads/main'
needs: publish
runs-on: ubuntu-latest
steps:
- name: Install kubectl
env:
KUBECTL_VERSION: v1.36.2
run: |
curl --fail --silent --show-error --location \
--output "${RUNNER_TEMP}/kubectl" \
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
chmod 0700 "${RUNNER_TEMP}/kubectl"
- name: Check out source
uses: actions/checkout@v4
- name: Configure Kubernetes access
env:
KUBE_CONFIG_BASE64: ${{ secrets.KUBE_CONFIG_BASE64 }}
run: |
test -n "${KUBE_CONFIG_BASE64}"
printf '%s' "${KUBE_CONFIG_BASE64}" | base64 --decode > "${RUNNER_TEMP}/kubeconfig"
chmod 0600 "${RUNNER_TEMP}/kubeconfig"
export KUBECONFIG="${RUNNER_TEMP}/kubeconfig"
"${RUNNER_TEMP}/kubectl" config set-cluster cluster \
--server="${KUBERNETES_API}" \
--tls-server-name="${KUBERNETES_TLS_SERVER_NAME}"
- name: Deploy commit image
- name: Check that this is still the current main commit
id: current-main
env:
COMMIT_SHA: ${{ gitea.sha }}
run: |
export KUBECONFIG="${RUNNER_TEMP}/kubeconfig"
"${RUNNER_TEMP}/kubectl" --namespace default set image \
deployment/immich-sync \
"immich-sync=${IMAGE}:${COMMIT_SHA}"
git fetch --no-tags origin main
if [ "$(git rev-parse FETCH_HEAD)" = "${COMMIT_SHA}" ]; then
echo "promote=true" >> "${GITEA_OUTPUT}"
else
echo "This build is no longer the current main commit; skipping promotion"
echo "promote=false" >> "${GITEA_OUTPUT}"
fi
for attempt in {1..60}; do
IFS='|' read -r generation observed desired updated available unavailable <<< "$(
"${RUNNER_TEMP}/kubectl" --namespace default get deployment/immich-sync \
--output=jsonpath='{.metadata.generation}|{.status.observedGeneration}|{.spec.replicas}|{.status.updatedReplicas}|{.status.availableReplicas}|{.status.unavailableReplicas}'
)"
- name: Check out GitOps repository
if: steps.current-main.outputs.promote == 'true'
uses: actions/checkout@v4
with:
repository: ${{ env.GITOPS_REPOSITORY }}
ref: main
token: ${{ secrets.GITOPS_TOKEN }}
path: homelab-gitops
echo "Rollout ${attempt}/60: generation ${observed}/${generation}, replicas ${updated}/${desired} updated, ${available}/${desired} available"
if [[ "${observed}" == "${generation}" \
&& "${updated}" == "${desired}" \
&& "${available}" == "${desired}" \
&& ( -z "${unavailable}" || "${unavailable}" == "0" ) ]]; then
echo "Deployment successfully rolled out"
exit 0
fi
sleep 5
done
"${RUNNER_TEMP}/kubectl" --namespace default describe deployment/immich-sync
exit 1
- name: Commit image promotion
if: steps.current-main.outputs.promote == 'true'
working-directory: homelab-gitops
env:
COMMIT_SHA: ${{ gitea.sha }}
run: |
./scripts/update-immich-sync-image.sh "${COMMIT_SHA}"
git config user.name "immich-sync CI"
git config user.email "immich-sync-ci@brunner.ninja"
git add apps/immich-sync/kustomization.yaml
if git diff --cached --quiet; then
echo "GitOps already selects ${COMMIT_SHA}"
exit 0
fi
git commit -m "Deploy immich-sync ${COMMIT_SHA}"
git push origin HEAD:main
+19 -23
View File
@@ -69,27 +69,29 @@ The synchronization job repeats inspection so its checksum plan and permissions
```sh
docker build -t registry.brunner.ninja/feedc0de/immich-sync:latest .
docker run --rm -p 8090:8090 registry.brunner.ninja/feedc0de/immich-sync:latest
./install.sh
kubectl apply -f deploy/example.yaml
kubectl port-forward service/immich-sync 8090:80
```
The Kubernetes manifest follows the neighboring `brunner-ninja` and `visual-studio-code` layout. It pulls the public
`registry.brunner.ninja/feedc0de/immich-sync:latest` image on every pod start, so no Kubernetes image-pull secret is
required. It assumes the `immich-sync.brunner.ninja` hostname shown in the manifest. The Ingress is intentionally
public and has no authentication middleware. Because the
service can make outbound requests to user-supplied URLs, operators should monitor it for SSRF and bandwidth abuse.
`deploy/example.yaml` is a portable starting point for self-hosting. It creates one Deployment and a ClusterIP Service,
uses the public image, and does not require an image-pull secret. Add persistent storage, an Ingress, and monitoring to
match your cluster. Because the service can make outbound requests to user-supplied URLs, operators should monitor it
for SSRF and bandwidth abuse.
The Deployment runs two replicas with preferred cross-node placement, a zero-unavailable rolling-update strategy, and
a PodDisruptionBudget that keeps at least one replica available. A small `ReadWriteMany` `rook-cephfs` claim stores the
shared lifetime counters. WebSockets need no sticky-session setting: after the HTTP upgrade, the selected pod owns that
connection until the short-lived job finishes or disconnects.
The author's complete
[`apps/immich-sync` GitOps application](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/immich-sync)
is public and can be used as an Argo CD/Kustomize deployment reference. It includes homelab-specific CephFS storage,
multiple replicas, an Ingress hostname, and Prometheus Operator monitoring, so fork it and adjust those assumptions
before deploying it to another cluster. WebSockets need no sticky-session setting: after the HTTP upgrade, the
selected pod owns that connection until the short-lived job finishes or disconnects.
## Statistics and Prometheus
The homepage reads `/api/stats` every 30 seconds and displays shared totals for completed synchronization runs, all
successfully synchronized assets, photos, videos, and bytes. Counters begin at zero when statistics are first enabled.
Each replica exposes Prometheus text metrics at `/metrics`. The manifest includes a `ServiceMonitor` that selects the
`immich-sync` Service and is discovered by the existing Prometheus Operator. Useful Grafana queries include:
Each replica exposes Prometheus text metrics at `/metrics`. A deployment can add a `ServiceMonitor` selecting the
`immich-sync` Service. Useful Grafana queries include:
```promql
sum(immich_sync_active_synchronizations)
@@ -116,21 +118,15 @@ The workflow expects these Gitea Actions repository secrets:
- `QUAY_USERNAME`: the complete Quay robot account name, including the `feedc0de+` prefix.
- `QUAY_TOKEN`: the robot account token.
- `KUBE_CONFIG_BASE64`: a kubeconfig for the restricted `immich-sync-deployer` service account, base64-encoded on one line.
- `GITOPS_TOKEN`: a `write:repository` token belonging to a dedicated account with write access only to
`feedc0de/homelab-gitops`.
Give the robot account `Write` permission only on the `feedc0de/immich-sync` repository. Public image pulls do not use
these credentials and do not require a Gitea secret.
On successful `main` builds, the deploy job patches only the `default/immich-sync` Deployment to the immutable commit
image and waits up to five minutes for rollout. Apply `ci-deployer.yaml` once, generate a kubeconfig for its token, and
store it as `KUBE_CONFIG_BASE64`. The role cannot access secrets, pods, or any other Deployment.
```sh
kubectl apply -f ci-deployer.yaml
./create-ci-kubeconfig.sh
```
Copy the single output line from `create-ci-kubeconfig.sh` into the Gitea repository secret. Treat it as a password.
After a successful `main` build, the promotion job updates the single image tag in `homelab-gitops` and pushes a
deployment commit. Argo CD observes that commit and reconciles Kubernetes. The workflow has no kubeconfig and no
direct Kubernetes access. See the GitOps repository README for the token setup, bootstrap procedure, and rollback path.
## Current scope
-47
View File
@@ -1,47 +0,0 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: immich-sync-deployer
namespace: default
automountServiceAccountToken: false
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: immich-sync-deployer
namespace: default
rules:
- apiGroups:
- apps
resources:
- deployments
resourceNames:
- immich-sync
verbs:
- get
- patch
- update
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: immich-sync-deployer
namespace: default
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: immich-sync-deployer
subjects:
- kind: ServiceAccount
name: immich-sync-deployer
namespace: default
---
apiVersion: v1
kind: Secret
metadata:
name: immich-sync-deployer-token
namespace: default
annotations:
kubernetes.io/service-account.name: immich-sync-deployer
type: kubernetes.io/service-account-token
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
namespace=default
secret=immich-sync-deployer-token
workdir=$(mktemp --directory)
trap 'rm -rf "${workdir}"' EXIT
server=$(kubectl config view --minify --output 'jsonpath={.clusters[0].cluster.server}')
kubectl --namespace "${namespace}" get secret "${secret}" --output 'jsonpath={.data.ca\.crt}' \
| base64 --decode > "${workdir}/ca.crt"
token=$(kubectl --namespace "${namespace}" get secret "${secret}" --output 'jsonpath={.data.token}' \
| base64 --decode)
export KUBECONFIG="${workdir}/config"
kubectl config set-cluster cluster --server "${server}" --certificate-authority "${workdir}/ca.crt" --embed-certs=true \
> /dev/null
kubectl config set-credentials immich-sync-deployer --token "${token}" > /dev/null
kubectl config set-context immich-sync --cluster cluster --user immich-sync-deployer --namespace "${namespace}" \
> /dev/null
kubectl config use-context immich-sync > /dev/null
base64 --wrap=0 "${KUBECONFIG}"
printf '\n'
+58
View File
@@ -0,0 +1,58 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: immich-sync
spec:
replicas: 1
selector:
matchLabels:
app: immich-sync
template:
metadata:
labels:
app: immich-sync
spec:
automountServiceAccountToken: false
containers:
- name: immich-sync
image: registry.brunner.ninja/feedc0de/immich-sync:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 8090
readinessProbe:
httpGet:
path: /healthz
port: http
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: immich-sync
spec:
selector:
app: immich-sync
ports:
- name: http
port: 80
targetPort: http
-162
View File
@@ -1,162 +0,0 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: immich-sync-stats
spec:
accessModes:
- ReadWriteMany
storageClassName: rook-cephfs
resources:
requests:
storage: 64Mi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: immich-sync
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: immich-sync
template:
metadata:
labels:
app: immich-sync
spec:
automountServiceAccountToken: false
securityContext:
fsGroup: 65532
fsGroupChangePolicy: OnRootMismatch
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: immich-sync
topologyKey: kubernetes.io/hostname
containers:
- name: immich-sync
image: registry.brunner.ninja/feedc0de/immich-sync:latest
imagePullPolicy: Always
env:
- name: PORT
value: "8090"
- name: STATS_FILE
value: /data/stats.json
ports:
- name: http
containerPort: 8090
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 1
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 15
resources:
requests:
cpu: 10m
memory: 32Mi
ephemeral-storage: 64Mi
limits:
cpu: "1"
memory: 128Mi
ephemeral-storage: 20Gi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
volumeMounts:
- name: tmp
mountPath: /tmp
- name: stats
mountPath: /data
volumes:
- name: tmp
emptyDir:
sizeLimit: 20Gi
- name: stats
persistentVolumeClaim:
claimName: immich-sync-stats
---
apiVersion: v1
kind: Service
metadata:
name: immich-sync
labels:
app: immich-sync
spec:
ports:
- name: http
port: 80
protocol: TCP
targetPort: http
selector:
app: immich-sync
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: immich-sync
spec:
minAvailable: 1
selector:
matchLabels:
app: immich-sync
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: immich-sync
labels:
release: prometheus
spec:
selector:
matchLabels:
app: immich-sync
namespaceSelector:
matchNames:
- default
endpoints:
- port: http
path: /metrics
interval: 30s
scrapeTimeout: 10s
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: immich-sync
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
ingressClassName: traefik
rules:
- host: immich-sync.brunner.ninja
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: immich-sync
port:
name: http
-4
View File
@@ -1,4 +0,0 @@
#!/bin/sh
kubectl apply -f immich-sync.yaml