Skip to Content

Using ArgoCD to Manage AI Model Deployments with GitOps

Using ArgoCD to Manage AI Model Deployments with GitOps
Using ArgoCD to Manage AI Model Deployments with GitOp

Models are not container images, and that one difference reshapes the whole pipeline. Here is the GitOps setup that accounts for it.

Highlights

  • GitOps for AI model deployments puts a pinned model reference in Git rather than the weights themselves, so review, audit, and rollback work exactly as they do for application code.
  • KServe reached CNCF Incubating status in September 2025, which makes the declarative InferenceService a reasonable foundation to build a pipeline on.
  • A model rollback is a one line revert in Git, but the pod still has to pull several gigabytes of weights, so keeping the previous version warm is what turns a fast revert into a fast recovery.
  • Sync waves order resources inside one Application, Argo Rollouts orders traffic inside a workload, and ApplicationSet progressive syncs order environments, and a model pipeline usually needs all three.
  • A readiness probe that only proves the server process is alive will happily route traffic to a model that never loaded, so probe the model rather than the port.
  • Promotion gates for models should include evaluation scores, not just HTTP health, because a model can be perfectly healthy and measurably worse.
  • ArgoCD self heal fights KServe status fields unless you configure ignoreDifferences, which is the single most common reason a model Application never reaches Synced.

Every engineering team that runs both microservices and models tends to have the same split personality. The services deploy through pull requests, automated reconciliation, and audit trails, while the models deploy because somebody ran a notebook cell or dropped a message in Slack asking for a restart. That gap is not a tooling shortage anymore. The CNCF moved KServe to Incubating status back in September 2025, ArgoCD has been production standard for years, and the pattern that joins them is well documented.

What makes model deployment genuinely different from application deployment is worth stating before any YAML. The deployable artifact is not a container image, it is a multi gigabyte blob of weights sitting in object storage, and that single fact changes what belongs in Git, how long a rollout takes, what a readiness probe should check, and what a rollback actually costs you. This tutorial builds the pipeline with those differences designed in rather than discovered later.

What GitOps for AI model deployments means

GitOps for AI model deployments is a delivery pattern where the desired state of every model serving workload, including which model version is live in which environment, exists as declarative manifests in Git, and a reconciler such as ArgoCD continuously applies that state to the cluster, so every model change arrives through a reviewed commit and every rollback is a revert.

The critical design choice sits inside that definition. Git holds a reference to the model, never the model itself, which means a versioned URI plus a content digest plus the serving configuration. Weights live in object storage or an OCI registry where they belong.

What actually lives in Git

This table is the whole mental model, and getting it wrong is how teams end up with a repository nobody can clone.

ArtifactLives in GitWhere it really lives
Model weights No Object storage or an OCI registry, referenced by URI and digest.
Model version and digest Yes A pinned reference in the manifest, reviewed on every change.
Serving config, replicas, resources Yes The InferenceService manifest per environment.
Autoscaling and GPU selection Yes Manifest fields and node selectors.
Evaluation thresholds for promotion Yes Policy files the promotion job reads.
Training data hash and code SHA Yes Manifest annotations, which is your lineage record.
Credentials for the model bucket No External Secrets Operator or Sealed Secrets, never a plain Secret.


That last row deserves emphasis because it is the one people get wrong under deadline pressure. A plain Kubernetes Secret committed to Git is base64, which is encoding rather than encryption, so it is a readable credential in your history forever.

The repository layout

Keep environments as overlays and the model reference as the only thing that changes between them.

ml-platform/
β”œβ”€β”€ base/
β”‚   β”œβ”€β”€ inferenceservice.yaml        # shape of the service, no version pinned
β”‚   └── kustomization.yaml
β”œβ”€β”€ overlays/
β”‚   β”œβ”€β”€ staging/
β”‚   β”‚   β”œβ”€β”€ kustomization.yaml
β”‚   β”‚   └── model-version.yaml       # patch: which model version is live here
β”‚   └── production/
β”‚       β”œβ”€β”€ kustomization.yaml
β”‚       └── model-version.yaml       # the file automation edits, humans review
└── applications/
    β”œβ”€β”€ staging.yaml                 # ArgoCD Application
    └── production.yaml

The point of isolating model-version.yaml is that it becomes the one file your promotion automation writes to, and therefore the one file a reviewer has to read carefully. A pull request that changes a single digest is a pull request people actually review, unlike a two hundred line manifest diff.

The InferenceService, pinned and annotated

Here is the serving manifest. Every field that looks like boilerplate is doing something specific for models.

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: recommender
  namespace: ml-production
  annotations:
    # Lineage. This is what makes an audit answerable a year from now.
    model.registry/version: "2.7.1"
    model.registry/training-data-sha256: "9f2c41a8b7e3d6f0c5a94b2e8d1f7c30"
    model.registry/training-code-sha: "a41f9c2"
    model.registry/eval-score: "0.9142"
    argocd.argoproj.io/sync-wave: "2"        # after warm up, before traffic shift
spec:
  predictor:
    minReplicas: 2
    maxReplicas: 8
    scaleTarget: 60
    model:
      modelFormat:
        name: sklearn
      # Pinned by digest, never by a floating tag such as :latest
      storageUri: "s3://ml-models/recommender/2.7.1"
      resources:
        requests:
          cpu: "2"
          memory: 8Gi
        limits:
          cpu: "4"
          memory: 12Gi
      readinessProbe:
        httpGet:
          path: /v1/models/recommender    # proves the MODEL loaded
          port: 8080
        initialDelaySeconds: 45           # weights take time to load
        periodSeconds: 10
        failureThreshold: 12
    # Canary: 10 percent of traffic to the new revision
    canaryTrafficPercent: 10

Three of those fields carry most of the safety, and each one exists because of a specific failure.

Pinning storageUri to an explicit version rather than a moving pointer means the manifest tells you exactly what is running. A floating tag reintroduces the snowflake problem you adopted GitOps to remove, because the cluster and the repository can silently disagree.

The readiness probe targets the model endpoint rather than a generic health port. A model server frequently accepts TCP connections and returns 200 on /healthz while the weights are still loading, so a naive probe marks the pod ready and Kubernetes routes real traffic to a model that cannot serve it. Probing the model path is the difference between a clean rollout and a burst of 503s.

Generous resource requests and a patient initialDelaySeconds reflect that model containers are heavy. Underprovisioned memory shows up as OOM kills during load rather than a clear error, which is a genuinely confusing failure to debug at speed.

Build the ArgoCD foundation this assumes

Everything above rests on knowing how ArgoCD reconciles, syncs, and reports health. The GitOps with ArgoCD course on KodeKloud covers GitOps principles, application health, sync strategies, declarative setup, and notifications across about six hours of hands on work, which maps directly onto the pipeline in this tutorial.

Course Β· Beginner

GitOps with ArgoCD

GitOps principles, architecture, sync strategies, application health, webhooks, and notifications. The reconciliation model this whole pipeline depends on.

GitOpsArgoCDHands on labs
Start the ArgoCD course →

The ArgoCD Application, and the gotcha that blocks most first attempts

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: recommender-production
  namespace: argocd
spec:
  project: ml-platform
  source:
    repoURL: https://github.com/acme/ml-platform
    targetRevision: main
    path: overlays/production
  destination:
    server: https://kubernetes.default.svc
    namespace: ml-production
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=false
  # THE GOTCHA: KServe writes to its own status and spec fields at runtime.
  # Without this, selfHeal sees drift on every reconcile and fights the
  # controller forever, so the Application never settles on Synced.
  ignoreDifferences:
    - group: serving.kserve.io
      kind: InferenceService
      jsonPointers:
        - /status
        - /spec/predictor/model/runtime

That ignoreDifferences block is the single most common reason a model Application sits in a permanent OutOfSync or Progressing state. KServe's controller mutates fields that Git does not specify, ArgoCD's self heal reads that as drift and reverts it, the controller writes again, and the two loop indefinitely. Left unfixed it also masks real drift, because everything looks broken all the time.

Ordering the rollout with sync waves

Model rollouts have a dependency order that application rollouts usually do not, and the three ordering mechanisms in the Argo ecosystem solve different scopes.

MechanismOrdersUse it for
Sync waves Resources inside one Application. Config and secrets first, warm up job second, serving resource third.
Argo Rollouts Traffic inside one workload. Canary steps with metric analysis and automated rollback.
ApplicationSet progressive syncs Applications across environments. Promoting dev to staging to production in sequence.


For a model, the useful wave order puts a warm up job ahead of anything that receives traffic.

apiVersion: batch/v1
kind: Job
metadata:
  name: recommender-warmup
  annotations:
    argocd.argoproj.io/sync-wave: "1"        # runs BEFORE the InferenceService
    argocd.argoproj.io/hook: PreSync
spec:
  backoffLimit: 2
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: warmup
          image: ghcr.io/acme/model-warmup:1.4.0
          args:
            - "--storage-uri=s3://ml-models/recommender/2.7.1"
            - "--verify-digest=sha256:9f2c41a8b7e3d6f0c5a94b2e8d1f7c30"
            - "--run-smoke-inference"        # one real prediction, not a ping

Verifying the digest before serving matters more for models than for images, because object storage has no immutability guarantee unless you configure one. Someone can overwrite 2.7.1 in a bucket, and without a digest check your pipeline will deploy different weights under the same version number and report success.

Promotion gates need evaluation scores, not just health checks

Here is where model delivery diverges most sharply from application delivery. An application either works or throws errors. A model can be perfectly healthy, return well formed responses within its latency budget, and still be worse than the version it replaced.

So the promotion gate has to read model quality, not just cluster health.

#!/usr/bin/env python3
"""Promotion gate. Runs in CI, writes to Git only if the model earns it."""
import json
import subprocess
import sys

MIN_EVAL_SCORE = 0.90        # absolute floor
MAX_REGRESSION = 0.005       # tolerated drop against the live model
MAX_P99_MS = 250

def gate(candidate: dict, live: dict) -> list[str]:
    """Return a list of reasons to block. Empty list means promote."""
    blocks = []
    if candidate["eval_score"] < MIN_EVAL_SCORE:
        blocks.append(f"eval {candidate['eval_score']:.4f} below floor {MIN_EVAL_SCORE}")
    regression = live["eval_score"] - candidate["eval_score"]
    if regression > MAX_REGRESSION:
        blocks.append(f"regression of {regression:.4f} against live model")
    if candidate["p99_latency_ms"] > MAX_P99_MS:
        blocks.append(f"p99 {candidate['p99_latency_ms']}ms over budget {MAX_P99_MS}ms")
    if candidate["training_data_sha256"] == live["training_data_sha256"]:
        blocks.append("training data unchanged, so this is not a new model")
    return blocks

def promote(version: str, digest: str) -> None:
    """Write the pinned reference and open a pull request. Never apply directly."""
    subprocess.run(["yq", "-i",
                    f'.spec.predictor.model.storageUri = "s3://ml-models/recommender/{version}"',
                    "overlays/production/model-version.yaml"], check=True)
    subprocess.run(["git", "checkout", "-b", f"promote-recommender-{version}"], check=True)
    subprocess.run(["git", "commit", "-am",
                    f"promote recommender {version} (digest {digest[:12]})"], check=True)
    subprocess.run(["gh", "pr", "create", "--fill"], check=True)

if __name__ == "__main__":
    candidate = json.load(open("candidate-metrics.json"))
    live = json.load(open("live-metrics.json"))
    reasons = gate(candidate, live)
    if reasons:
        print("PROMOTION BLOCKED:")
        for r in reasons:
            print(f"  - {r}")
        sys.exit(1)
    promote(candidate["version"], candidate["digest"])
    print(f"opened promotion PR for {candidate['version']}")

Notice that the script opens a pull request rather than applying anything. Automation writes to Git, ArgoCD writes to the cluster, and that separation is what keeps GitOps honest. The moment your CI system also has cluster credentials, you have two sources of truth and no reliable audit trail.

The training data hash check is a small guard with outsized value. A retraining job that silently reused yesterday's dataset produces a new version number with identical inputs, and catching that in the gate saves a confusing week of wondering why the metrics never moved.

Rollback is fast in Git and slow in the cluster

GitOps gives models a genuinely excellent rollback story, with one caveat worth planning around.

# Roll back the model version. ArgoCD reconciles within its sync interval.
git revert 4f2a1c9 && git push        # the promotion commit

# Or force reconciliation immediately
argocd app sync recommender-production

# Confirm which model version the cluster now believes is live
kubectl get inferenceservice recommender -n ml-production \
  -o jsonpath='{.spec.predictor.model.storageUri}'

The revert takes seconds and the reconcile takes seconds. Pulling several gigabytes of weights onto pods that no longer have them cached does not. If your previous model's pods have already been scaled to zero and their node caches evicted, a rollback can take minutes rather than seconds, which is exactly the wrong discovery to make during an incident.

Three mitigations handle it. Keep the previous revision warm rather than immediately scaling it to zero, which is precisely what a canary with a retained stable revision gives you. Cache model weights on nodes so a rollback rehydrates from local disk instead of object storage. And practice a rollback on purpose in staging, because a documented rollback procedure that nobody has executed is a hypothesis rather than a plan.

Practice model serving on Kubernetes

The InferenceService shape, canary traffic, and troubleshooting a model that will not become ready are all much clearer once you have deployed one yourself. The KServe Fundamentals course on KodeKloud covers deploying predictive and generative models, managing InferenceServices, and troubleshooting failures, and the KodeKloud playgrounds give you clusters where a broken rollout costs nothing.

Course Β· Beginner

KServe Fundamentals: Serving ML Models on Kubernetes

Deploy generative and predictive models, manage InferenceServices, and troubleshoot failures. The serving layer the manifests in this tutorial describe.

KServeMLOpsKubernetes
Explore KServe Fundamentals →

Fanning out across environments

Once one environment works, ApplicationSet removes the copy and paste.

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: recommender
  namespace: argocd
spec:
  # Progressive sync: staging must go Healthy before production begins.
  strategy:
    type: RollingSync
    rollingSync:
      steps:
        - matchExpressions:
            - key: env
              operator: In
              values: ["staging"]
        - matchExpressions:
            - key: env
              operator: In
              values: ["production"]
  generators:
    - list:
        elements:
          - env: staging
            namespace: ml-staging
            autoSync: "true"
          - env: production
            namespace: ml-production
            autoSync: "false"        # production waits for a human
  template:
    metadata:
      name: 'recommender-{{env}}'
      labels:
        env: '{{env}}'
    spec:
      project: ml-platform
      source:
        repoURL: https://github.com/acme/ml-platform
        targetRevision: main
        path: 'overlays/{{env}}'
      destination:
        server: https://kubernetes.default.svc
        namespace: '{{namespace}}'

Leaving production on manual sync is a deliberate choice rather than timidity. Automated sync everywhere means a merged pull request reaches production without anyone watching, and for a model whose quality you can only assess against live traffic, a person deciding when to press the button is worth the small loss in automation purity.

Where to start

  1. Move one model to a repository with the layout above and pin its version by digest, resisting any floating tag.
  2. Create the ArgoCD Application with ignoreDifferences on the KServe status fields from the beginning, so you never debug the self heal loop.
  3. Fix the readiness probe to hit the model endpoint and confirm a rollout no longer produces 503s during startup.
  4. Add the warm up job in sync wave one with a digest verification and a single smoke inference.
  5. Write the promotion gate with an evaluation floor and a regression tolerance, and have it open a pull request rather than apply anything.
  6. Practice a rollback in staging with a stopwatch, then decide whether to keep the previous revision warm.
  7. Only then fan out with an ApplicationSet, keeping production on manual sync until the staging path is boring.

Conclusion

The reason model deployments stay manual long after application deployments are automated is not that GitOps does not fit them. It is that a few details differ enough to break a naive copy of the application pipeline: the artifact lives outside Git, readiness means loaded rather than listening, the controller writes fields your manifest does not own, and quality is not visible in an HTTP status code.

Handle those four and the rest of GitOps works exactly as advertised. Every model change becomes a reviewable diff, every deployment carries its training data hash and code SHA as lineage, and every rollback is a revert your whole team already knows how to perform. Pick your least critical model this week and move it, because the pipeline is much easier to build before it is urgent.

Ready to Master GitOps and Model Serving?

This pipeline sits on two skills that are worth having independently. The GitOps with ArgoCD course on KodeKloud covers sync strategies, application health, and declarative delivery, KServe Fundamentals covers serving models on Kubernetes properly, and the KodeKloud playgrounds give you clusters to practice a real rollback on. Start with one today.

Playgrounds

KodeKloud Playgrounds

Disposable DevOps, Cloud, AI and Kubernetes environments where you can practise a real rollback with a stopwatch, then break the rollout again to see what happens.

DevOpsCloudAISandbox
Launch a playground →

FAQs

Q1: What do I need to know before putting model deployments on GitOps?

You need solid Kubernetes plus GitOps fundamentals rather than deep machine learning knowledge, because the hard parts here are delivery problems. Specifically, understand how a reconciler compares desired and live state, since that model explains both self heal and the drift fights you will hit with controller managed fields. Know Kustomize or Helm well enough to keep environment differences to a single patched file, because that is what makes promotion diffs reviewable. Be comfortable with readiness and liveness probes, resource requests, and how object storage credentials reach a pod. On the model side, you need to know what a model registry stores and how your team versions artifacts, but you do not need to train anything. For structured practice, the GitOps with ArgoCD course on KodeKloud covers the reconciliation and sync behavior this depends on, and KServe Fundamentals covers the serving layer.

Q2: Should the model weights go in Git?

No, and Git LFS is not the exception people hope it is. Model weights are frequently hundreds of megabytes to many gigabytes, they change with every retraining run, and Git stores history permanently, so committing them makes clones slow, storage costs climb, and your repository becomes unusable within months. What belongs in Git is a reference: the version string, a content digest such as a SHA256, and the serving configuration. Weights belong in object storage or an OCI registry, which are built for large immutable blobs and give you lifecycle policies and access controls. This split is also what makes the pull request useful, since a reviewer can meaningfully evaluate a one line change to a pinned digest and cannot meaningfully evaluate a binary diff. Verify the digest at deploy time so a bucket overwrite cannot silently change what a version means.

Q3: How is deploying a model different from deploying a normal application?

Four differences matter enough to change the pipeline. The artifact is external, so Git holds a pointer rather than the thing itself, and you need digest verification because your reference is only as trustworthy as the storage behind it. Startup is slow and heavy, since loading weights takes tens of seconds and a lot of memory, which means patient probe delays and generous resource requests rather than the tight settings you would use for a stateless service. Readiness means the model loaded, not that the port answers, and probing the wrong thing routes live traffic at a model that cannot serve. And quality is invisible to health checks, because a model can return well formed responses at good latency while being measurably worse than its predecessor, which is why model promotion gates need evaluation scores rather than just HTTP status codes.

Q4: Why does my ArgoCD Application for KServe never reach Synced?

Almost always because KServe's controller writes to fields your Git manifest does not specify, and self heal treats those writes as drift. The controller sets status, resolves runtime selections, and populates defaults, ArgoCD reverts them to match Git, the controller writes again, and the two loop against each other so the Application sits in a permanent OutOfSync or Progressing state. The fix is an ignoreDifferences block on the serving.kserve.io group covering /status and any spec fields the controller owns, such as the resolved runtime. Add it when you create the Application rather than after debugging, because the symptom is confusing and it also hides real drift while it persists. The same class of problem appears with any controller that mutates its own resources, so it is worth recognizing the pattern rather than memorizing this one case.

Q5: Can I automate promotion from a model registry all the way to production?

You can automate the mechanics fully while keeping a human at the decision point, and that is the configuration most teams settle on. The automation reads the registry for a new candidate version, runs the evaluation suite, applies your gates for absolute score, regression against the live model, and latency budget, then writes the pinned reference into the environment overlay and opens a pull request. That much needs no human. What benefits from a person is the merge into production, because model quality is partly a judgment about acceptable tradeoffs, and because live traffic reveals things your evaluation set does not. A practical setup runs staging on automated sync so candidates land there without ceremony, and leaves production on manual sync with the pull request as the decision record. Keep the automation writing only to Git and never to the cluster, since a CI system with cluster credentials gives you a second source of truth and breaks the audit trail GitOps exists to provide.

Q6: How does this compare to using a managed platform like SageMaker or Vertex AI?

Managed platforms handle more for you and give you less control over the delivery process, which is the real tradeoff rather than capability. SageMaker and Vertex AI provide model registries, endpoint management, and traffic splitting as a service, so a small team without Kubernetes expertise can ship models quickly and skip most of what this tutorial builds. The GitOps approach wins when you already run Kubernetes and want models delivered through the same reviewed, audited, revertible pipeline as everything else, when you need portability across clouds or on premises, or when compliance requires that every production change be a reviewable commit with lineage attached. Cost profiles differ too, since managed endpoints are convenient but you pay for that convenience, while self managed serving on existing cluster capacity is often cheaper at scale and more work at small scale. Many organizations run both, keeping experimentation on a managed platform and moving anything business critical onto the GitOps path.

Pramodh Kumar M Pramodh Kumar M

Subscribe to Newsletter

Join me on this exciting journey as we explore the boundless world of web design together.