Skip to content

GitOps & deploy

The image is in GHCR; now make Argo CD deliver it via GitOps. Add one Application in apps/ pointed at a workloads/<app>/ folder. Because the root app watches apps/, the new file is picked up on the next sync — no manual registration.

Work through the manifests below in order: namespace, ConfigMap, Rollout, Service, Ingress.

flowchart TD
  Root[root Application] --> App[apps/app.yaml]
  App --> WL[workloads/app/]
  WL --> NS[namespace]
  WL --> RO[Rollout]
  WL --> SVC[Service]
  WL --> ING[Ingress]
  ING -->|app.domain| Traefik[Traefik + cert-manager]

The Application that points Argo CD at the folder. selfHeal reverts manual drift; prune deletes resources you remove from Git; CreateNamespace=true makes the destination namespace if it’s missing.

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: <app>
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/<owner>/gitops.git
targetRevision: main
path: workloads/<app>
destination:
server: https://kubernetes.default.svc
namespace: <app>
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
workloads/<app>/
├── namespace.yaml
├── configmap.yaml # non-secret env (see Secrets for the rest)
├── rollout.yaml
├── service.yaml
└── ingress.yaml

Add postgres-cluster.yaml + migrations-job.yaml + scheduledbackup.yaml if the app needs a database — see Database & migrations. Add servicemonitor.yaml + analysistemplate.yaml for the canary gate — see Canary & metrics.

apiVersion: v1
kind: Namespace
metadata:
name: <app>

Non-secret env only. Secrets (DB_URL, client secrets) are injected from Secrets in the Rollout, never here.

apiVersion: v1
kind: ConfigMap
metadata:
name: <app>-config
namespace: <app>
data:
HTTP_ADDR: ":8080"
LOG_LEVEL: "info"
LOG_FORMAT: "json"
APP_ENV: "staging"
# ...app-specific non-secret config...

A drop-in replacement for a Deployment that adds canary steps (Argo Rollouts provides the Rollout kind). Pin the image to the sha-<short> tag from CI — bumping this tag is the deploy trigger. Env splits into non-secret (envFrom the ConfigMap) and secret (secretKeyRef).

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: <app>
namespace: <app>
spec:
replicas: 2
revisionHistoryLimit: 3
selector:
matchLabels:
app: <app>
template:
metadata:
labels:
app: <app>
annotations:
<domain>/redeploy: "init" # bump this value to trigger a canary
spec:
containers:
- name: <app>
image: ghcr.io/<owner>/<app>:sha-XXXXXXX
ports:
- name: http
containerPort: 8080
envFrom:
- configMapRef:
name: <app>-config
env:
- name: DB_URL
valueFrom:
secretKeyRef:
name: <app>-pg-app # CNPG auto-generated; see Database & migrations
key: uri
readinessProbe:
httpGet: { path: /readyz, port: http }
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
livenessProbe:
httpGet: { path: /healthz, port: http }
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { memory: 128Mi }
strategy:
canary:
steps:
- setWeight: 25
- pause: { duration: 60 }
- setWeight: 50
- pause: { duration: 60 }
- setWeight: 75
- pause: { duration: 60 }

The analysis block that gates these steps on a Prometheus metric is added in Canary & metrics. Without it the canary just steps on a timer.

One Service in front of the Rollout selects both stable and canary pods (sufficient for a replica-based canary). The app: <app> label lets the ServiceMonitor select it later.

apiVersion: v1
kind: Service
metadata:
name: <app>
namespace: <app>
labels:
app: <app> # so the ServiceMonitor can select this Service
spec:
selector:
app: <app>
ports:
- name: http
port: 80
targetPort: http

Public HTTPS via the Ingress handled by Traefik, with cert-manager issuing the cert from the prod ClusterIssuer (letsencrypt-prod). The DNS A record (<app>.<domain> → node public IP) must exist before the cert can issue (HTTP-01 challenge).

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: <app>
namespace: <app>
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
ingressClassName: traefik
tls:
- hosts: [<app>.<domain>]
secretName: <app>-tls
rules:
- host: <app>.<domain>
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: <app>
port:
number: 80

If the app has a database, set it up in Database & migrations. Otherwise jump to Canary & metrics to wire the success-rate gate.