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:

  1. I push a commit to main
  2. GitLab CI builds a Docker image and tags it with the git SHA
  3. ArgoCD Image Updater detects the new image
  4. 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:

SettingValuePurpose
source.pathhelm/minoko-life-blogLocation of Helm chart in repo
source.helm.valueFilesvalues-prod.yamlProduction overrides
syncPolicy.automatedprune: true, selfHeal: trueAuto-sync and remove orphaned resources
syncOptionsCreateNamespace=trueCreate 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:

SettingValuePurpose
namePatternminoko-life-blogWhich ArgoCD Application to update
allowTagsregexp:^[a-f0-9]{7,8}$Only consider tags that look like git SHAs
updateStrategynewest-buildPick the most recently built image
pullSecretsecret:argocd/gitlab-registry-creds#credentialsRegistry authentication
manifestTargets.helmname: image.repository, tag: image.tagWhich Helm values to update
writeBackConfig.methodargocdUpdate 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 image
  • errors=0 - No authentication or connection issues
  • images_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:

  1. Write a post
  2. git add . && git commit -m "New post" && git push
  3. Wait ~5 minutes
  4. 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.