Skip to content

GitOps & deploy

The image is in GHCR; now make Argo CD deliver it via GitOps. Copy workloads/penvoice/ (base + overlays/staging + overlays/prod) and add two entries to the list generator in apps/workloads.yaml. The root app already watches apps/.

Work through the manifests in base, then overlay-patch host, Infisical environmentSlug, namespace, and image tag.

flowchart TD
  Root[root Application] --> AS[apps/workloads.yaml]
  AS --> Stg[workloads/app/overlays/staging]
  AS --> Prod[workloads/app/overlays/prod]
  Stg --> RO[Rollout + Redis + Ingress]
  Prod --> RO2[Rollout + Redis + Ingress]

Add two list elements (env: staging / env: prod) for the new app. Generated Applications use path: workloads/<app>/overlays/<env> and namespace <app>-<env>.

workloads/<app>/
├── base/
│ ├── rollout.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── redis.yaml
│ └── infisical-*.yaml
└── overlays/{staging,prod}/
├── kustomization.yaml # namespace, images.newTag, patches
└── namespace.yaml

Add postgres-cluster.yaml + scheduledbackup.yaml if the app needs a database — see Database & migrations. Do not add ServiceMonitor / AnalysisTemplate until a metrics stack exists — Canary. The three infisical-*.yaml files are in Secrets — do that page first; the Rollout envFroms the Secret they produce.

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

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 — the overlay images.newTag is the deploy trigger. Almost all env comes via envFrom the Infisical-managed Secret; only values an operator mints (DB_URL) or that are coupled to this manifest (API_PORT) stay as literal/secretKeyRef entries — see Secrets for why.

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: <app>-api
namespace: <app>-<env>
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:
- secretRef:
name: <app>-api-kc # Infisical-managed; see Secrets
env:
- name: DB_URL
valueFrom:
secretKeyRef:
name: <app>-pg-app # CNPG auto-generated; see Database & migrations
key: uri
- name: API_PORT # coupled to containerPort below; stays a literal, not in Infisical
value: "8080"
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 }

Prod overlays keep these steps. Staging typically uses one replica and setWeight: 100. No analysis block until a metrics stack exists.

One Service in front of the Rollout selects both stable and canary pods.

apiVersion: v1
kind: Service
metadata:
name: <app>
namespace: <app>-<env>
labels:
app: <app>
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>-<env>
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. Canary shape: Canary.