I run a Hugo blog on my homelab Kubernetes cluster, and I wanted a proper GitOps workflow where pushing to main automatically deploys changes. No manual kubectl apply, no SSH-ing into servers, no scripts to remember. Just git push and walk away.
This post covers how I set up ArgoCD to deploy this blog with automatic image updates using the ArgoCD Image Updater.
The Goal
┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ ┌─────────────┐
│ Git Push │────▶│ GitLab CI │────▶│ Container │────▶│ ArgoCD │
│ (main) │ │ (build) │ │ Registry │ │ (deploy) │
└─────────────┘ └─────────────┘ └─────────────────┘ └─────────────┘
│ │ │
│ │ ▼
│ │ ┌───────────────┐
│ └─────────────▶│ Image Updater │
│ │ (detect new) │
▼ └───────────────┘
Tags image with │
git SHA (d67fe5d) ▼
┌───────────────┐
│ Kubernetes │
│ (updated) │
└───────────────┘
The workflow:
- I push a commit to
main - GitLab CI builds a Docker image and tags it with the git SHA
- ArgoCD Image Updater detects the new image
- ArgoCD updates the deployment with the new image tag
Prerequisites
- Kubernetes cluster with ArgoCD installed
- ArgoCD Image Updater installed
- GitLab Container Registry (or any OCI registry)
- A Helm chart for your application
The CI Pipeline
First, let’s look at the GitLab CI pipeline that builds and pushes images. The key is tagging images with the git commit SHA:
stages:
- build
variables:
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_DRIVER: overlay2
GIT_SUBMODULE_STRATEGY: recursive
docker-build:
stage: build
image: docker:24
services:
- docker:24-dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- docker build --no-cache
--build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
--build-arg GIT_COMMIT=$CI_COMMIT_SHA
-t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
-t $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG .
- |
if [ "$CI_COMMIT_REF_NAME" = "main" ]; then
docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA $CI_REGISTRY_IMAGE:latest
fi
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
- |
if [ "$CI_COMMIT_REF_NAME" = "main" ]; then
docker push $CI_REGISTRY_IMAGE:latest
fi
only:
- main
- tags
The important part is $CI_COMMIT_SHORT_SHA - this gives us tags like d67fe5d that the Image Updater can track.
The ArgoCD Application
The ArgoCD Application tells ArgoCD where to find your Helm chart and how to deploy it:
# kubernetes/argocd-application.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: minoko-life-blog
namespace: argocd
spec:
project: default
source:
repoURL: https://gitlab.com/whumphreys/minoko-life-blog.git
path: helm/minoko-life-blog
targetRevision: HEAD
helm:
valueFiles:
- values-prod.yaml
destination:
server: https://kubernetes.default.svc
namespace: minoko-life-blog
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
Key settings:
| Setting | Value | Purpose |
|---|---|---|
source.path | helm/minoko-life-blog | Location of Helm chart in repo |
source.helm.valueFiles | values-prod.yaml | Production overrides |
syncPolicy.automated | prune: true, selfHeal: true | Auto-sync and remove orphaned resources |
syncOptions | CreateNamespace=true | Create namespace if it doesn’t exist |
The Image Updater
This is where the magic happens. The ImageUpdater CRD tells the ArgoCD Image Updater which images to watch and how to update them:
# kubernetes/image-updater.yaml
apiVersion: argocd-image-updater.argoproj.io/v1alpha1
kind: ImageUpdater
metadata:
name: minoko-life-blog-updater
namespace: argocd
spec:
namespace: argocd
applicationRefs:
- namePattern: minoko-life-blog
images:
- alias: blog
imageName: registry.gitlab.com/whumphreys/minoko-life-blog:latest
commonUpdateSettings:
allowTags: "regexp:^[a-f0-9]{7,8}$"
updateStrategy: newest-build
pullSecret: secret:argocd/gitlab-registry-creds#credentials
manifestTargets:
helm:
name: image.repository
tag: image.tag
writeBackConfig:
method: argocd
Let me break this down:
| Setting | Value | Purpose |
|---|---|---|
namePattern | minoko-life-blog | Which ArgoCD Application to update |
allowTags | regexp:^[a-f0-9]{7,8}$ | Only consider tags that look like git SHAs |
updateStrategy | newest-build | Pick the most recently built image |
pullSecret | secret:argocd/gitlab-registry-creds#credentials | Registry authentication |
manifestTargets.helm | name: image.repository, tag: image.tag | Which Helm values to update |
writeBackConfig.method | argocd | Update via ArgoCD API (not git commit) |
The allowTags regex is important - it ensures we only track commit SHA tags, not latest or branch names.
Applying the Resources
These are bootstrap resources that you apply once:
# Apply the ArgoCD Application (if not already in ArgoCD UI)
kubectl apply -f kubernetes/argocd-application.yaml
# Apply the Image Updater
kubectl apply -f kubernetes/image-updater.yaml
After this, ArgoCD handles everything automatically.
Registry Credentials
The Image Updater needs credentials to pull image metadata from GitLab’s registry. Create the secret:
kubectl create secret generic gitlab-registry-creds -n argocd \
--from-literal=credentials='<gitlab-username>:<gitlab-token>'
Use a GitLab Personal Access Token with read_registry scope.
Verifying It Works
Check the ArgoCD Application Status
$ argocd app get minoko-life-blog
Name: argocd/minoko-life-blog
Project: default
Server: https://kubernetes.default.svc
Namespace: minoko-life-blog
Sync Status: Synced to HEAD (d67fe5d)
Health Status: Healthy
GROUP KIND NAMESPACE NAME STATUS HEALTH
ServiceAccount minoko-life-blog minoko-life-blog Synced
Service minoko-life-blog minoko-life-blog Synced Healthy
apps Deployment minoko-life-blog minoko-life-blog Synced Healthy
Check Image Updater Logs
kubectl logs -n argocd -l app.kubernetes.io/name=argocd-image-updater \
-c argocd-image-updater-controller --tail=50
You should see:
level=info msg="Successfully fetched ImageUpdater resource." imageUpdater_name=minoko-life-blog-updater
level=info msg="Starting image update cycle, considering 1 application(s) for update"
level=info msg="Processing results: applications=1 images_considered=1 images_skipped=0 images_updated=0 errors=0"
Key indicators:
images_considered=1- It found your imageerrors=0- No authentication or connection issuesimages_updated=0- No update needed (you’re on the latest)
Check the Deployed Image
$ kubectl get deployment minoko-life-blog -n minoko-life-blog \
-o jsonpath='{.spec.template.spec.containers[0].image}'
registry.gitlab.com/whumphreys/minoko-life-blog:d67fe5d4
List All Image Updaters
$ kubectl get imageupdaters -n argocd
NAME AGE
minoko-life-blog-updater 10m
trade-dashboard-updater 42h
Using k9s
In k9s, type :imageupdater to view all ImageUpdater resources. You can also check logs on the argocd-image-updater pod (make sure to select the controller container, not the linkerd sidecar if you’re using a service mesh).
Gotchas and Lessons Learned
1. The Image Updater CRD vs Annotations
There are two ways to configure ArgoCD Image Updater:
- Annotations on the Application - The older method
- ImageUpdater CRD - The newer, cleaner approach
I’m using the CRD approach because it keeps the image update config separate from the Application definition.
2. Tag Regex Matters
Without allowTags, the updater might try to use latest or branch tags. The regex ^[a-f0-9]{7,8}$ ensures only git SHA tags are considered.
3. Write-back Methods
There are two write-back methods:
argocd- Updates the Application’s helm parameters via API (simpler)git- Commits changes back to the repo (full audit trail)
I use argocd for simplicity. The downside is that the image tag override lives in ArgoCD’s state, not in git.
4. Linkerd Sidecar Logs
If you’re running Linkerd, the default kubectl logs might show the proxy logs instead of the actual controller. Use -c argocd-image-updater-controller to get the right container.
5. Bootstrap vs GitOps
These ArgoCD resources are applied manually because they’re bootstrap/infrastructure config. Once applied, the actual application deployment is fully GitOps - changes to the Helm chart in git are automatically synced.
Directory Structure
Here’s how my project is organized:
minoko-life-blog/
├── .gitlab-ci.yml # CI pipeline
├── Dockerfile # Hugo build
├── helm/
│ └── minoko-life-blog/
│ ├── Chart.yaml
│ ├── values.yaml
│ └── values-prod.yaml
├── kubernetes/
│ ├── argocd-application.yaml
│ └── image-updater.yaml
└── content/
└── posts/
└── ...
Conclusion
With this setup, my blog deployment workflow is:
- Write a post
git add . && git commit -m "New post" && git push- Wait ~5 minutes
- Post is live
No kubectl, no SSH, no manual intervention. The CI builds the image, the Image Updater detects it, and ArgoCD deploys it. True GitOps.
The same pattern works for any application - just adjust the Helm values paths and image names in the ImageUpdater config.