Skip to content
· 10 min read · 0 views

GitOps Deployment Patterns Every Team Should Know in 2026

Master GitOps deployment patterns including progressive delivery, blue-green deployments, and canary releases with ArgoCD and Flux. Production-ready examples included.

// table of contents (21 sections)

GitOps transformed how teams deploy to Kubernetes. But many stop at basic sync-and-pray deployments. Real production systems need sophisticated deployment patterns that minimize risk, enable rapid rollback, and provide clear visibility into what’s happening.

This post covers the GitOps patterns I use in production: progressive delivery, blue-green deployments, canary releases, and feature flags integrated with GitOps workflows.

GitOps Fundamentals

GitOps is not just “using Git for configs.” It is a set of principles:

  1. Declarative — Everything is defined declaratively in Git
  2. Versioned — Git history is the source of truth
  3. Automated — Changes are automatically applied
  4. Reconciled — Controllers continuously ensure desired state matches actual state

The key insight: Git is the single source of truth. Your cluster state is a derivative of Git state, not the other way around.

Core Tools

The two dominant GitOps operators:

ArgoCD — Application-centric, great UI, supports progressive delivery with Rollouts

Flux — Git-centric, lightweight, modular, integrates well with Git providers

Both work. The patterns in this post work with both, though syntax differs.

Pattern 1: Progressive Delivery

Progressive delivery gradually shifts traffic to new versions while monitoring health metrics. If problems appear, automatic rollback happens before users are affected.

ArgoCD Rollouts

# rollout.yaml
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: my-app
spec:
  replicas: 4
  strategy:
    canary:
      steps:
        - setWeight: 20
        - pause: {duration: 2m}
        - setWeight: 40
        - pause: {duration: 2m}
        - setWeight: 60
        - pause: {duration: 2m}
        - setWeight: 80
        - pause: {duration: 2m}
      analysis:
        templates:
          - templateName: success-rate
        startingStep: 2
        args:
          - name: service-name
            value: my-app
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      containers:
        - name: my-app
          image: my-app:v2.0.0
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 512Mi
          readinessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 15
            periodSeconds: 20

Analysis Template

Define what “healthy” means:

# analysis-template.yaml
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
  name: success-rate
spec:
  args:
    - name: service-name
  metrics:
    - name: success-rate
      interval: 60s
      count: 5
      successCondition: result[0] >= 0.99
      provider:
        prometheus:
          address: http://prometheus.monitoring.svc.cluster.local:9090
          query: |
            sum(rate(http_requests_total{service="{{args.service-name}}",status!~"5.."}[2m])) /
            sum(rate(http_requests_total{service="{{args.service-name}}"}[2m]))
    - name: latency-p99
      interval: 60s
      count: 5
      successCondition: result[0] <= 500
      provider:
        prometheus:
          address: http://prometheus.monitoring.svc.cluster.local:9090
          query: |
            histogram_quantile(0.99,
              sum(rate(http_request_duration_seconds_bucket{service="{{args.service-name}}"}[2m])) by (le)
            ) * 1000
    - name: error-rate
      interval: 60s
      count: 5
      successCondition: result[0] <= 0.01
      provider:
        prometheus:
          address: http://prometheus.monitoring.svc.cluster.local:9090
          query: |
            sum(rate(http_requests_total{service="{{args.service-name}}",status=~"5.."}[2m])) /
            sum(rate(http_requests_total{service="{{args.service-name}}"}[2m]))

This rollout:

  1. Starts with 20% traffic to new version
  2. Waits 2 minutes
  3. Runs analysis (checks success rate, latency, error rate)
  4. If healthy, continues to 40%, 60%, 80%
  5. Any analysis failure triggers automatic rollback

Pattern 2: Blue-Green Deployments

Blue-green maintains two identical environments. Traffic switches instantly from blue (current) to green (new) or vice versa.

# blue-green-rollout.yaml
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: my-app-bg
spec:
  replicas: 3
  strategy:
    blueGreen:
      activeService: my-app-active
      previewService: my-app-preview
      prePromotionAnalysis:
        templates:
          - templateName: pre-promotion-checks
      postPromotionAnalysis:
        templates:
          - templateName: post-promotion-checks
      autoPromotionEnabled: false
      scaleDownDelaySeconds: 600
  selector:
    matchLabels:
      app: my-app
  template:
    spec:
      containers:
        - name: my-app
          image: my-app:v2.0.0
          ports:
            - containerPort: 8080
          readinessProbe:
            httpGet:
              path: /health
              port: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: my-app-active
spec:
  selector:
    app: my-app
  ports:
    - port: 80
      targetPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: my-app-preview
spec:
  selector:
    app: my-app
  ports:
    - port: 80
      targetPort: 8080

Workflow

  1. New deployment creates pods in preview environment
  2. Pre-promotion analysis runs (smoke tests, integration tests)
  3. Manual promotion (via UI or CLI) switches traffic
  4. Post-promotion analysis monitors production health
  5. Old version stays scaled down for quick rollback

When to Use Blue-Green

  • Zero-downtime required — Instant cutover
  • Testing in production — Preview environment is real prod infra
  • Simple rollback — Switch service selector back
  • Resource available — Need 2x compute temporarily

Pattern 3: Canary with Traffic Splitting

For fine-grained control, use service mesh traffic splitting:

# canary-with-istio.yaml
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: my-app-canary
spec:
  replicas: 4
  strategy:
    canary:
      trafficRouting:
        istio:
          virtualService:
            name: my-app
          destinationRule:
            name: my-app
            canarySubsetName: canary
            stableSubsetName: stable
      steps:
        - setWeight: 5
        - pause: {duration: 5m}
        - setWeight: 10
        - pause: {duration: 5m}
        - setWeight: 25
        - pause: {duration: 5m}
        - setWeight: 50
        - pause: {duration: 5m}
  selector:
    matchLabels:
      app: my-app
  template:
    spec:
      containers:
        - name: my-app
          image: my-app:v2.0.0
---
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: my-app
spec:
  hosts:
    - my-app
  http:
    - route:
        - destination:
            host: my-app
            subset: stable
          weight: 100
        - destination:
            host: my-app
            subset: canary
          weight: 0
---
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: my-app
spec:
  host: my-app
  subsets:
    - name: stable
      labels:
        app: my-app
    - name: canary
      labels:
        app: my-app

Flux Equivalent with Flagger

Flux uses Flagger for progressive delivery:

# flagger-canary.yaml
apiVersion: flagger.app/v1beta1
kind: Canary
metadata:
  name: my-app
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-app
  progressDeadlineSeconds: 600
  service:
    port: 8080
    targetPort: 8080
    trafficPolicy:
      tls:
        mode: DISABLE
  analysis:
    interval: 1m
    threshold: 5
    maxWeight: 50
    stepWeight: 10
    metrics:
      - name: request-success-rate
        thresholdRange:
          min: 99
        interval: 1m
      - name: request-duration
        thresholdRange:
          max: 500
        interval: 1m
    webhooks:
      - name: acceptance-test
        type: pre-rollout
        url: http://flagger-loadtester/
        timeout: 5m
        metadata:
          type: bash
          cmd: "curl -sd 'test' http://my-app-canary:8080/health"

Pattern 4: Feature Flags with GitOps

Combine GitOps with feature flag management:

# feature-flag-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: feature-flags
  namespace: my-app
data:
  flags.yaml: |
    features:
      new-dashboard:
        enabled: false
        rolloutPercentage: 0
      advanced-search:
        enabled: true
        rolloutPercentage: 100
      beta-api:
        enabled: true
        rolloutPercentage: 10
        whitelist:
          - user-123
          - user-456
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  template:
    spec:
      containers:
        - name: my-app
          volumeMounts:
            - name: feature-flags
              mountPath: /etc/feature-flags
              readOnly: true
      volumes:
        - name: feature-flags
          configMap:
            name: feature-flags

Application Integration

// main.go
package main

import (
    "os"
    "gopkg.in/yaml.v3"
)

type FeatureFlags struct {
    Features map[string]struct {
        Enabled          bool     `yaml:"enabled"`
        RolloutPercentage int     `yaml:"rolloutPercentage"`
        Whitelist        []string `yaml:"whitelist"`
    } `yaml:"features"`
}

func loadFeatureFlags(path string) (*FeatureFlags, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    
    var flags FeatureFlags
    if err := yaml.Unmarshal(data, &flags); err != nil {
        return nil, err
    }
    
    return &flags, nil
}

func (f *FeatureFlags) IsEnabled(feature string, userID string) bool {
    feat, exists := f.Features[feature]
    if !exists {
        return false
    }
    
    if !feat.Enabled {
        return false
    }
    
    // Check whitelist
    for _, id := range feat.Whitelist {
        if id == userID {
            return true
        }
    }
    
    // Check rollout percentage
    if feat.RolloutPercentage >= 100 {
        return true
    }
    
    hash := hashUserID(userID + feature)
    return hash%100 < feat.RolloutPercentage
}

func hashUserID(s string) int {
    h := 0
    for _, c := range s {
        h = 31*h + int(c)
    }
    return h % 100
}

GitOps Workflow for Feature Flags

  1. Developer updates flags.yaml in Git
  2. ArgoCD/Flux syncs ConfigMap to cluster
  3. Application picks up changes (with file watch or restart)
  4. Gradual rollout by increasing percentage in Git
  5. Rollback by reverting Git commit

Pattern 5: Multi-Cluster Deployments

For organizations with multiple clusters (dev, staging, prod):

# application-set.yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: my-app
spec:
  generators:
    - list:
        elements:
          - cluster: dev
            url: https://dev-cluster.example.com
            branch: develop
          - cluster: staging
            url: https://staging-cluster.example.com
            branch: main
          - cluster: prod
            url: https://prod-cluster.example.com
            branch: main
  template:
    metadata:
      name: 'my-app-{{cluster}}'
    spec:
      project: default
      source:
        repoURL: https://github.com/my-org/my-app.git
        targetRevision: '{{branch}}'
        path: k8s/overlays/{{cluster}}
      destination:
        server: '{{url}}'
        namespace: my-app
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true

Kustomize Overlays

k8s/
├── base/
│   ├── deployment.yaml
│   ├── service.yaml
│   └── kustomization.yaml
└── overlays/
    ├── dev/
    │   ├── kustomization.yaml
    │   └── patches/
    │       └── deployment-replicas.yaml
    ├── staging/
    │   ├── kustomization.yaml
    │   └── patches/
    │       └── deployment-replicas.yaml
    └── prod/
        ├── kustomization.yaml
        └── patches/
            ├── deployment-replicas.yaml
            └── deployment-resources.yaml
# k8s/overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - ../../base

patches:
  - path: patches/deployment-replicas.yaml
  - path: patches/deployment-resources.yaml

configMapGenerator:
  - name: app-config
    literals:
      - ENV=production
      - LOG_LEVEL=warn
# k8s/overlays/prod/patches/deployment-replicas.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  replicas: 5

Pattern 6: Image Automation

Automatically update images when new tags are pushed:

# flux-image-automation.yaml
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageRepository
metadata:
  name: my-app
spec:
  image: docker.io/my-org/my-app
  interval: 1m
---
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImagePolicy
metadata:
  name: my-app
spec:
  imageRepositoryRef:
    name: my-app
  policy:
    semver:
      range: '>=1.0.0 <2.0.0'
---
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageUpdateAutomation
metadata:
  name: my-app
spec:
  interval: 1m
  sourceRef:
    kind: GitRepository
    name: flux-system
  git:
    checkout:
      ref:
        branch: main
    commit:
      author:
        email: flux@example.com
        name: Flux
      messageTemplate: |
        Automated image update
        
        Image: {{ .Image.Repository }}:{{ .Image.Tag }}
    push:
      branch: main
  update:
    strategy: Setters
    path: ./k8s

With setters in deployment:

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  template:
    spec:
      containers:
        - name: my-app
          image: docker.io/my-org/my-app:1.0.0 # {"$imagepolicy": "flux-system:my-app"}

Pattern 7: Secrets Management with GitOps

Never commit secrets to Git. Use external secret management:

# external-secret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: my-app-secrets
spec:
  refreshInterval: 1h
  secretStoreRef:
    kind: ClusterSecretStore
    name: vault-backend
  target:
    name: my-app-secrets
    creationPolicy: Owner
  data:
    - secretKey: database-url
      remoteRef:
        key: secret/data/my-app
        property: database-url
    - secretKey: api-key
      remoteRef:
        key: secret/data/my-app
        property: api-key
---
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: vault-backend
spec:
  provider:
    vault:
      server: 'https://vault.example.com'
      path: 'secret'
      version: 'v2'
      auth:
        kubernetes:
          mountPath: 'kubernetes'
          role: 'my-app'

The External Secrets Operator syncs secrets from Vault/AWS Secrets Manager/etc. into Kubernetes secrets. Git contains only the ExternalSecret definition, never the actual secret values.

Pattern 8: Drift Detection and Remediation

GitOps controllers continuously reconcile state. But sometimes manual changes slip through:

# argocd-project.yaml
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: production
spec:
  clusterResourceWhitelist:
    - group: ''
      kind: Namespace
  destinations:
    - namespace: 'prod-*'
      server: https://prod-cluster.example.com
  sourceRepos:
    - 'https://github.com/my-org/*'
  syncWindows:
    - kind: allow
      schedule: '0 9 * * 1-5'
      duration: 8h
      applications:
        - '*'
  namespaceResourceBlacklist:
    - group: ''
      kind: ResourceQuota
    - group: ''
      kind: LimitRange

Drift Notifications

# argocd-notifications.yaml
apiVersion: argoproj.io/v1alpha1
kind: NotificationsConfiguration
metadata:
  name: argocd-notifications-cm
spec:
  triggers:
    - name: on-sync-failed
      enabled: true
      template: app-sync-failed
    - name: on-sync-status-unknown
      enabled: true
      template: app-sync-status-unknown
    - name: on-health-degraded
      enabled: true
      template: app-health-degraded
  templates:
    - name: app-sync-failed
      body: |
        Application {{.app.metadata.name}} sync failed.
        Error: {{.app.status.operationState.message}}
      slack:
        attachments: |
          [{
            "title": "{{.app.metadata.name}}",
            "color": "#f44336",
            "fields": [
              {
                "title": "Sync Status",
                "value": "{{.app.status.sync.status}}",
                "short": true
              },
              {
                "title": "Health Status",
                "value": "{{.app.status.health.status}}",
                "short": true
              }
            ]
          }]
  services:
    slack:
      token: $slack-token

CI/CD Pipeline Integration

GitOps separates CI from CD:

  • CI — Build, test, push image, update Git manifest
  • CD — GitOps operator syncs from Git to cluster
# .github/workflows/ci.yaml
name: CI

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Build and push image
        run: |
          docker build -t my-org/my-app:${{ github.sha }} .
          docker push my-org/my-app:${{ github.sha }}
      
      - name: Update GitOps repo
        run: |
          git clone https://x-access-token:${{ secrets.GITOPS_TOKEN }}@github.com/my-org/gitops-config.git
          cd gitops-config
          sed -i "s|image: my-org/my-app:.*|image: my-org/my-app:${{ github.sha }}|" apps/my-app/deployment.yaml
          git config user.name "CI Bot"
          git config user.email "ci@example.com"
          git add .
          git commit -m "Update my-app to ${{ github.sha }}"
          git push

Key Takeaways

  1. Progressive delivery — Shift traffic gradually with automated analysis
  2. Blue-green — Instant cutover with easy rollback for zero-downtime deployments
  3. Canary with traffic splitting — Fine-grained control with service mesh integration
  4. Feature flags in Git — Declarative feature management with Git as source of truth
  5. Multi-cluster — ApplicationSets or Flux Kustomizations for environment promotion
  6. Image automation — Automatic image updates without manual manifest editing
  7. External secrets — Never commit secrets; sync from external stores
  8. Drift detection — Continuous reconciliation prevents configuration drift

GitOps is not just about deploying YAML files. It is about building a robust deployment pipeline that handles the realities of production: gradual rollouts, automatic rollback, multi-environment management, and security.

The patterns in this post have saved me countless hours and prevented numerous production incidents. Start with basic deployments, then progressively adopt more sophisticated patterns as your needs grow.

GitOps transforms deployment from a manual process into a declarative, version-controlled, automated workflow. It is not just a tool — it is a discipline.

You might also like

Enjoyed This Post?

Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.

Discussion