Skip to main content

Zero-Downtime Deployments with K3s and ArgoCD - A Practical Guide

15 min read
Set up GitOps with ArgoCD

We install K3s and ArgoCD with rolling updates and health checks so releases ship from Git.

A hands-on guide to achieving zero-downtime deployments using K3s and ArgoCD, covering GitOps workflows, rolling update strategies, health checks, and complete YAML manifests.

Introduction

Zero-downtime deployments are no longer a luxury reserved for large engineering organizations. With K3s (a lightweight Kubernetes distribution) and ArgoCD (a GitOps continuous delivery tool), even small teams can ship code multiple times a day without dropping a single request. This guide walks through the full setup from a bare server to a working GitOps pipeline.

Prerequisites

  • A Linux server (Ubuntu 22.04 or 24.04) with at least 2 CPU cores and 4 GB RAM
  • A Git repository for Kubernetes manifests
  • A container registry (Docker Hub, GitHub Container Registry, or a private registry)
  • kubectl installed on the local machine

1. Install K3s

K3s installs in under 60 seconds:

curl -sfL https://get.k3s.io | sh -s - \
  --disable traefik \
  --write-kubeconfig-mode 644

# Verify the cluster is running
sudo kubectl get nodes

We disable Traefik because we will use our own ingress controller (or Cloudflare Tunnel) later.

Copy the kubeconfig for remote access:

cat /etc/rancher/k3s/k3s.yaml

Replace 127.0.0.1 with the server's public IP and save it as ~/.kube/config on the local machine.

2. Install ArgoCD

kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

# Wait for all pods to be ready
kubectl -n argocd rollout status deployment argocd-server

# Get the initial admin password
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d

Expose the ArgoCD UI (for initial setup only; in production, use an ingress or port-forward):

kubectl -n argocd port-forward svc/argocd-server 8080:443

3. Structure the GitOps Repository

We use a simple directory layout:

k8s-manifests/
  base/
    deployment.yaml
    service.yaml
    hpa.yaml
  overlays/
    production/
      kustomization.yaml
      patch-replicas.yaml

base/deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  labels:
    app: myapp
spec:
  replicas: 3
  revisionHistoryLimit: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: myapp
          image: ghcr.io/myorg/myapp:latest
          ports:
            - containerPort: 3000
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 512Mi
          readinessProbe:
            httpGet:
              path: /healthz
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /healthz
              port: 3000
            initialDelaySeconds: 15
            periodSeconds: 20
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 5"]

Key settings for zero downtime:

  • maxUnavailable: 0 ensures no pod is terminated before a new one is ready.
  • readinessProbe tells Kubernetes when the new pod is ready to receive traffic.
  • preStop lifecycle hook adds a 5-second delay before SIGTERM, giving the ingress controller time to remove the pod from the load balancer.

base/service.yaml

apiVersion: v1
kind: Service
metadata:
  name: myapp
spec:
  selector:
    app: myapp
  ports:
    - port: 80
      targetPort: 3000
  type: ClusterIP

base/hpa.yaml

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: myapp
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: myapp
  minReplicas: 3
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

4. Create the ArgoCD Application

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: myapp
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/myorg/k8s-manifests.git
    targetRevision: main
    path: overlays/production
  destination:
    server: https://kubernetes.default.svc
    namespace: production
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true

Apply it:

kubectl apply -f argocd-app.yaml

Now whenever we push a change to the main branch of k8s-manifests, ArgoCD detects the drift and syncs the cluster to match the desired state.

5. The Deployment Workflow

  1. Build a new container image in CI and push it with a unique tag (e.g., the Git SHA).
  2. Update the image tag in deployment.yaml and push to the manifest repo.
  3. ArgoCD detects the change and begins a rolling update.
  4. Kubernetes creates new pods with the updated image.
  5. Readiness probes confirm the new pods are healthy.
  6. Old pods are gracefully drained and terminated.
  7. Zero requests are dropped throughout the process.

6. Testing Zero Downtime

We can verify zero downtime with a simple load test during a deployment:

# In one terminal, run a continuous request loop
while true; do
  curl -s -o /dev/null -w "%{http_code}\n" http://myapp.example.com/healthz
  sleep 0.1
done

# In another terminal, trigger a deployment
kubectl set image deployment/myapp myapp=ghcr.io/myorg/myapp:v2.0.0

Every response should return 200. If we see 502 or 503 errors, we need to check the readiness probe configuration and the preStop hook.

K3s gives us a production-grade Kubernetes cluster in under a minute. ArgoCD gives us a GitOps workflow that keeps the cluster in sync with our repository. Together, they provide a deployment pipeline where shipping new code is as simple as merging a pull request, and users never notice a thing.

Frequently asked questions

How do I install K3s without Traefik?

Run the K3s install script with --disable traefik and --write-kubeconfig-mode 644, then confirm the node with sudo kubectl get nodes. Disabling Traefik leaves room for a different ingress controller or Cloudflare Tunnel. For remote kubectl access, copy /etc/rancher/k3s/k3s.yaml, replace 127.0.0.1 with the server's public IP and save the file as ~/.kube/config on the local machine.

What server specs do I need for K3s and ArgoCD?

A Linux server with Ubuntu 22.04 or 24.04, at least 2 CPU cores and 4 GB of RAM is the baseline. The setup also needs a Git repository for the Kubernetes manifests, a container registry such as Docker Hub, GitHub Container Registry or a private one, and kubectl on the local machine. K3s itself installs in under 60 seconds.

Which rolling update settings give zero-downtime deployments in Kubernetes?

Use the RollingUpdate strategy with maxSurge set to 1 and maxUnavailable set to 0, so no pod is terminated before its replacement is ready. A readinessProbe, such as an HTTP check on /healthz every 10 seconds, tells Kubernetes when a new pod can take traffic. A preStop hook running sleep 5 adds a pause before SIGTERM so the ingress controller can remove the old pod from the load balancer first.

How do I install ArgoCD on a K3s cluster?

Create the argocd namespace and apply the upstream stable install.yaml manifest with kubectl apply -n argocd. Wait on kubectl -n argocd rollout status deployment argocd-server, then decode the initial admin password from the argocd-initial-admin-secret secret with base64 -d. For initial setup only, reach the UI with kubectl -n argocd port-forward svc/argocd-server 8080:443, and use an ingress or a port-forward in production.

How does a GitOps deployment with ArgoCD work?

CI builds a container image and pushes it with a unique tag such as the Git SHA, and the new tag is committed to deployment.yaml in the manifest repository. An ArgoCD Application that tracks the main branch and the overlays/production path detects the drift and syncs the cluster, which starts a rolling update. With automated sync and both prune and selfHeal enabled, shipping a release comes down to merging a pull request.

How do I autoscale an app on K3s?

Add a HorizontalPodAutoscaler from the autoscaling/v2 API that targets the Deployment and scales on CPU. An example configuration keeps between 3 and 10 replicas and adds pods when average CPU utilization goes above 70 percent. The matching Deployment requests 100m CPU and 128Mi of memory per pod, with limits of 500m and 512Mi.

How can I test that a Kubernetes rollout drops no requests?

A simple curl loop is enough. In one terminal, curl the app's /healthz endpoint every 0.1 seconds and print only the HTTP status code, then trigger a deployment from a second terminal with kubectl set image deployment/myapp myapp=ghcr.io/myorg/myapp:v2.0.0. Every response during the rollout should be 200.

What causes 502 or 503 errors during a Kubernetes rolling update?

Errors like 502 or 503 during a rollout point to the readiness probe configuration or the preStop hook. The readiness probe decides when a new pod gets traffic, and the preStop delay gives the ingress controller time to drop a terminating pod before SIGTERM arrives. If either one is missing or misconfigured, requests can reach a pod that is not ready yet or is already shutting down.

Read how we handle it in Cloud & Server Management.