Multi-Environment K3s with Kustomize

Series: K3s on Raspberry Pi

  1. Build a K3s Cluster on Raspberry Pi
  2. Deploy a Go Service to K3s
  3. GitOps on K3s with ArgoCD
  4. Multi-Environment K3s with Kustomize ← this article

Continuation of GitOps on K3s with ArgoCD. ArgoCD is running and deploying the Go REST service from Git. Now split it into stage and prod environments.

The problem with duplicating YAML

The naive approach is to copy the projects/simple-rest-api/ folder twice - once for stage, once for prod - and change the namespace, replicas, and host in each copy. That works until you need to update a probe, add a label, or change resource limits. You make the change in one folder and forget the other. They drift.

Kustomize solves this with a base + overlay model: write the manifests once in common/, then write small patches per environment. Only the diff lives in stage/ and prod/.

1. Update the Go service to read env vars

The full updated service is at github.com/mk48/simple-http/tree/02-reading-env.

It adds two libraries:

"github.com/caarlos0/env/v11"   // parses env vars into a struct
"github.com/joho/godotenv"      // loads a .env file locally (ignored in prod)
type AppENV struct {
	Env              string `env:"ENV,notEmpty"`
	ConnectionString string `env:"CONNECTION_STR,notEmpty"`
}

func ReadAppENVs(log *slog.Logger) AppENV {
	cfg := AppENV{}

	err := godotenv.Load()
	if err != nil {
		// .env file won't exist in the cluster - that's fine, use system env vars
		log.Info("Error loading .env file. Using system's environment variables")
	}

	if err := env.Parse(&cfg); err != nil {
		log.Error("Error in parsing environment variables", "err", err)
		os.Exit(1)
	}

	return cfg
}

We can also expose the environment variable via a simple /env url. This is only for testing purpose, normally we should not expose.

//file: main.go
env := ReadAppENVs(e.Logger)

// just simple end point to expose the environment variables
e.GET("/env", func(c *echo.Context) error {
    return c.JSON(http.StatusOK, env) //don't send all if it contains secrets
})

Push to main - the existing ArgoCD setup deploys it automatically.

2. The new infra repo structure

The infra repo at github.com/mk48/k3s-infra changed in two places:

k3s-infra/
├── argocd/
│   ├── root-app.yaml            # unchanged - still the one manual apply
│   ├── ingress.yaml
│   └── apps/                    # ← became a Helm chart
│       ├── Chart.yaml
│       ├── values.yaml          # list of environments
│       └── templates/
│           └── simple-rest-api.yaml  # generates one ArgoCD App per env
└── projects/
    └── simple-rest-api/         # ← restructured with Kustomize
        ├── common/              # base manifests (shared across all envs)
        │   ├── deployment.yaml
        │   ├── service.yaml
        │   ├── ingress.yaml
        │   └── kustomization.yaml
        ├── stage/               # stage-specific patches
        │   ├── kustomization.yaml
        │   └── configmap.yaml
        └── prod/                # prod-specific patches
            ├── kustomization.yaml
            └── configmap.yaml

3. Kustomize - common base and overlays

common/ - write once

No namespace here. No environment-specific values. Just the canonical shape of the app.

common/deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-world
spec:
  replicas: 2
  selector:
    matchLabels:
      app: hello-world
  template:
    metadata:
      labels:
        app: hello-world
    spec:
      containers:
        - name: hello-world
          image: ghcr.io/mk48/simple-http:latest
          imagePullPolicy: IfNotPresent
          resources:
            requests:
              cpu: '2m'
              memory: '12Mi'
            limits:
              cpu: 100m
              memory: 64Mi
          ports:
            - containerPort: 8080
              name: web
              protocol: TCP
          readinessProbe:
            httpGet:
              path: /
              port: web
            periodSeconds: 30
          livenessProbe:
            httpGet:
              path: /
              port: web
            initialDelaySeconds: 3
            periodSeconds: 30

common/service.yaml

apiVersion: v1
kind: Service
metadata:
  name: hello-world-service
spec:
  selector:
    app: hello-world
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: 8080

common/ingress.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: hello-world-ingress
spec:
  ingressClassName: traefik
  rules:
    - host: hello.local
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: hello-world-service
                port:
                  number: 80

common/kustomization.yaml - declares which files belong to this base and adds a label to every resource:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

labels:
  - includeSelectors: true
    pairs:
      app: hello-world

resources:
  - deployment.yaml
  - ingress.yaml
  - service.yaml

stage/ - patch only what differs

Stage gets 1 replica, its own hostname, its own image tag, and its ConfigMap injected as env vars. Everything else inherits from common/.

stage/kustomization.yaml

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: pi-stage # sets namespace on every resource from common/

resources:
  - ../common # pull in the base
  - configmap.yaml # add the ConfigMap resource

patches:
  - target:
      kind: Ingress
      name: hello-world-ingress
    patch: |-
      - op: replace
        path: /spec/rules/0/host
        value: hello-stage.local
  - target:
      kind: Deployment
      name: hello-world
    patch: |-
      - op: replace
        path: /spec/replicas
        value: 1
  - target:
      kind: Deployment
      name: hello-world
    patch: |-
      - op: add
        path: /spec/template/spec/containers/0/envFrom
        value:
          - configMapRef:
              name: hello-world-config

images:
  - name: ghcr.io/mk48/simple-http
    newTag: '4' # pin to a specific image tag

stage/configmap.yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: hello-world-config
data:
  ENV: 'stage'
  CONNECTION_STR: 'some staging value'

prod/ - same pattern, different values

prod/kustomization.yaml

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: pi-prod

resources:
  - ../common
  - configmap.yaml

patches:
  - target:
      kind: Ingress
      name: hello-world-ingress
    patch: |-
      - op: replace
        path: /spec/rules/0/host
        value: hello.local
  - target:
      kind: Deployment
      name: hello-world
    patch: |-
      - op: add
        path: /spec/template/spec/containers/0/envFrom
        value:
          - configMapRef:
              name: hello-world-config

images:
  - name: ghcr.io/mk48/simple-http
    newTag: '4'

prod/configmap.yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: hello-world-config
data:
  ENV: 'prod'
  CONNECTION_STR: 'some production value'

Prod keeps 2 replicas (the default from common/) - no patch needed.

Preview the final manifests locally

Before pushing, verify what Kustomize will generate:

kubectl kustomize projects/simple-rest-api/stage
kubectl kustomize projects/simple-rest-api/prod

This outputs the fully rendered YAML exactly as ArgoCD will apply it - useful for catching mistakes without touching the cluster.

4. Helm in argocd/apps/ - one app definition, N environments

Previously argocd/apps/ held plain YAML files - one Application per app. Now it needs two Applications per app (stage + prod). Adding a third environment would mean another manual file.

Instead, argocd/apps/ became a Helm chart that loops over an environment list and generates one ArgoCD Application per entry.

argocd/apps/values.yaml - the only file you edit to add/remove environments:

clusters:
  stage:
    name: stage
  prod:
    name: prod

argocd/apps/Chart.yaml

apiVersion: v2
appVersion: '1.0'
description: Applications
name: applications
version: 0.1.0

argocd/apps/templates/simple-rest-api.yaml - generates one Application per entry in values.yaml:

{{- range $i, $cluster := .Values.clusters }}
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: simple-rest-api-{{ $cluster.name }}
  namespace: argocd
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  source:
    repoURL: https://github.com/mk48/k3s-infra
    targetRevision: main
    path: projects/simple-rest-api/{{ $cluster.name }}
  destination:
    server: https://kubernetes.default.svc
    namespace: pi-{{ $cluster.name }}
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
{{- end }}

The loop produces simple-rest-api-stage and simple-rest-api-prod from a single template. Adding a canary environment in the future means one line in values.yaml.

Since argocd/apps/ is now a Helm chart, the root app picks it up automatically - no changes needed to root-app.yaml.

5. Add the stage hostname

Add hello-stage.local to your Windows hosts file:

192.168.0.51  argocd.local
192.168.0.51  hello.local
192.168.0.51  hello-stage.local

6. Result

Push the infra repo changes. ArgoCD detects the updated argocd/apps/ chart, renders the two Applications, and syncs both environments.

sudo kubectl get applications -n argocd
# NAME                     SYNC STATUS   HEALTH STATUS
# root                     Synced        Healthy
# simple-rest-api-stage    Synced        Healthy
# simple-rest-api-prod     Synced        Healthy
sudo kubectl get pods -n pi-stage
# hello-world-xxx   1/1   Running   (1 replica)

sudo kubectl get pods -n pi-prod
# hello-world-xxx   1/1   Running
# hello-world-yyy   1/1   Running   (2 replicas)

We can see the ArgoCD has two applications deployed now. ArgoCD home page with stage and prod apps deployed

The stage has one replica Stage has one replica

Production has two replicas Prod has two replicas

curl http://hello-stage.local   # Hello, World from: hello-world-xxx (ENV=stage)
curl http://hello.local         # Hello, World from: hello-world-yyy (ENV=prod)

By visiting the /env url we can see the environment variables, we already exposed the endpoints in our Go program.