DEV Community

david
david

Posted on • Originally published at woitzik.dev

k3s Backup Without the Complexity: Velero + Garage S3 on Longhorn

Originally published at woitzik.dev

Every Kubernetes cluster needs a backup strategy. For a homelab running on bare metal, the options are limited: etcd snapshots cover cluster state but not persistent volumes, and MinIO is the standard S3 target for Velero — but MinIO is large, opinionated, and overkill for a single-node homelab.

Garage is a lightweight, open-source S3-compatible object store written in Rust. The binary is ~50MB, the configuration is a single TOML file, and it works with any S3-compatible client including the Velero AWS plugin. It's a much better fit for a homelab than MinIO.

View the complete homelab infrastructure source on GitHub 🐙

The Architecture

Velero (daily backup at 03:00)
        │
        ├── Cluster resources → Garage S3 bucket (backup/velero)
        │   (Deployments, Services, ConfigMaps, Secrets, CRDs…)
        │
        └── Persistent volumes → Longhorn volume snapshots
                │
                └── Snapshots exported to Garage S3
Enter fullscreen mode Exit fullscreen mode

Both the Kubernetes API objects and the actual volume data land in Garage. A full restore gives you the cluster back exactly as it was.

Step 1: Deploy Garage on k3s

Garage needs two storage directories: one for metadata (small, fast) and one for object data (larger). Two separate Longhorn PVCs keep them on different volumes.

# kubernetes/apps/garage/pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: garage-data
  namespace: apps
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: longhorn
  resources:
    requests:
      storage: 10Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: garage-meta
  namespace: apps
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: longhorn
  resources:
    requests:
      storage: 2Gi
Enter fullscreen mode Exit fullscreen mode

The Garage configuration goes in a ConfigMap:

# kubernetes/apps/garage/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: garage-config
  namespace: apps
data:
  config.toml: |
    metadata_dir = "/var/lib/garage/meta"
    data_dir     = "/var/lib/garage/data"
    db_engine    = "lmdb"

    replication_factor = 1    # single node, no replication

    rpc_bind_addr   = "[::]:3901"
    rpc_public_addr = "127.0.0.1:3901"
    rpc_secret_file = "/etc/garage/secrets/rpc_secret"

    [s3_api]
    s3_region    = "homelab"
    api_bind_addr = "[::]:3900"
    root_domain  = ".s3.yourdomain.com"

    [admin]
    admin_bind_addr = "[::]:3903"
    admin_token_file = "/etc/garage/secrets/admin_token"
Enter fullscreen mode Exit fullscreen mode

replication_factor = 1 is correct for a single-node setup. Garage supports multi-node replication but there's no need for it here — Longhorn handles data redundancy at the storage layer.

Secrets (RPC secret and admin token) come from a Kubernetes Secret:

# kubernetes/apps/garage/secrets.yaml
apiVersion: v1
kind: Secret
metadata:
  name: garage-secrets
  namespace: apps
type: Opaque
stringData:
  rpc_secret: "<64-char-hex-string>"
  admin_token: "<random-token>"
Enter fullscreen mode Exit fullscreen mode

Generate them with openssl rand -hex 32.

The Deployment:

# kubernetes/apps/garage/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: garage
  namespace: apps
spec:
  replicas: 1
  selector:
    matchLabels:
      app: garage
  template:
    spec:
      containers:
        - name: garage
          image: dxflrs/garage:v2.3.0
          args: ["/garage", "server"]
          env:
            - name: GARAGE_CONFIG
              value: /etc/garage.toml
          ports:
            - name: s3
              containerPort: 3900
            - name: admin
              containerPort: 3903
          volumeMounts:
            - name: config
              mountPath: /etc/garage.toml
              subPath: config.toml
              readOnly: true
            - name: secrets
              mountPath: /etc/garage/secrets
              readOnly: true
            - name: data
              mountPath: /var/lib/garage/data
            - name: meta
              mountPath: /var/lib/garage/meta
      volumes:
        - name: config
          configMap:
            name: garage-config
        - name: secrets
          secret:
            secretName: garage-secrets
            defaultMode: 0600
        - name: data
          persistentVolumeClaim:
            claimName: garage-data
        - name: meta
          persistentVolumeClaim:
            claimName: garage-meta
---
apiVersion: v1
kind: Service
metadata:
  name: garage
  namespace: apps
spec:
  ports:
    - port: 3900
      targetPort: 3900
      name: s3
    - port: 3903
      targetPort: 3903
      name: admin
  selector:
    app: garage
Enter fullscreen mode Exit fullscreen mode

Step 2: Initialize the Garage Cluster

After the pod is running, Garage needs a one-time cluster initialization. Exec into the pod:

kubectl exec -it -n apps deploy/garage -- /garage status
Enter fullscreen mode Exit fullscreen mode

This gives you the node ID. Then apply the layout:

# Replace <node-id> with the ID from the status output
kubectl exec -it -n apps deploy/garage -- \
  /garage layout assign -z homelab -c 1G <node-id>

kubectl exec -it -n apps deploy/garage -- \
  /garage layout apply --version 1
Enter fullscreen mode Exit fullscreen mode

Create the Velero bucket and access credentials:

kubectl exec -it -n apps deploy/garage -- \
  /garage bucket create velero

kubectl exec -it -n apps deploy/garage -- \
  /garage key create velero-key

# Grant the key access to the bucket
kubectl exec -it -n apps deploy/garage -- \
  /garage bucket allow velero --read --write --owner --key velero-key
Enter fullscreen mode Exit fullscreen mode

Note the Key ID and Secret key output — you need them for Velero.

Step 3: Deploy Velero via ArgoCD

Velero is deployed as a Helm chart with the AWS plugin pointed at the Garage S3 endpoint:

# kubernetes/system/velero/app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: velero
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://vmware-tanzu.github.io/helm-charts
    targetRevision: 7.2.2
    chart: velero
    helm:
      values: |
        configuration:
          backupStorageLocation:
            - name: default
              provider: aws
              bucket: velero
              default: true
              config:
                region: homelab
                s3ForcePathStyle: true
                s3Url: http://garage.apps.svc.cluster.local:3900
          volumeSnapshotLocation:
            - name: default
              provider: aws
              config:
                region: homelab
        initContainers:
          - name: velero-plugin-for-aws
            image: velero/velero-plugin-for-aws:v1.10.1
            imagePullPolicy: IfNotPresent
            volumeMounts:
              - mountPath: /target
                name: plugins
        credentials:
          useSecret: true
          existingSecret: velero-s3-credentials
        snapshotsEnabled: true
        deployNodeAgent: true
  destination:
    server: https://kubernetes.default.svc
    namespace: velero
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
      - ServerSideApply=true
Enter fullscreen mode Exit fullscreen mode

Two important settings here:

s3ForcePathStyle: true — Garage uses path-style URLs (http://endpoint/bucket/key), not virtual-hosted style (http://bucket.endpoint/key). Without this flag, the AWS SDK generates requests that Garage rejects.

deployNodeAgent: true — The node agent runs as a DaemonSet and is required for Longhorn volume snapshots. Without it, Velero can back up Kubernetes objects but not the actual data in PVCs.

The credentials secret:

# kubernetes/system/velero/secrets.yaml
apiVersion: v1
kind: Secret
metadata:
  name: velero-s3-credentials
  namespace: velero
type: Opaque
stringData:
  cloud: |
    [default]
    aws_access_key_id = <your-garage-key-id>
    aws_secret_access_key = <your-garage-secret-key>
Enter fullscreen mode Exit fullscreen mode

Step 4: Daily Backup Schedule

# kubernetes/system/velero/schedule.yaml
apiVersion: velero.io/v1
kind: Schedule
metadata:
  name: daily-backup
  namespace: velero
spec:
  schedule: "0 3 * * *"        # 03:00 every night
  template:
    ttl: 720h0m0s              # keep backups for 30 days
    includedNamespaces:
      - "*"
    excludedNamespaces:
      - kube-system
      - kube-public
      - kube-node-lease
    storageLocation: default
    volumeSnapshotLocations:
      - default
Enter fullscreen mode Exit fullscreen mode

30 days of daily backups. The TTL means Velero automatically deletes backups older than 720 hours — no manual cleanup.

Verifying Backups

Check that backups are landing in Garage:

# List backups
kubectl get backups -n velero

# Describe a specific backup
kubectl describe backup -n velero daily-backup-<timestamp>

# Trigger a manual backup
velero backup create manual-test --include-namespaces apps
Enter fullscreen mode Exit fullscreen mode

To verify Garage is actually receiving the data, check the bucket size:

kubectl exec -it -n apps deploy/garage -- \
  /garage bucket info velero
Enter fullscreen mode Exit fullscreen mode

Restoring from Backup

# List available backups
velero backup get

# Restore everything
velero restore create --from-backup daily-backup-<timestamp>

# Restore a single namespace
velero restore create --from-backup daily-backup-<timestamp> \
  --include-namespaces apps
Enter fullscreen mode Exit fullscreen mode

Velero restores Kubernetes objects first, then triggers volume snapshot restores through the node agent. Pods come up pointing to their restored PVCs automatically.

Why Garage Over MinIO

Garage MinIO
Binary size ~50MB ~400MB
Memory (idle) ~20MB ~200MB+
Config Single TOML Env vars + web UI
S3 compatibility Full (path-style) Full
Cluster mode Optional Requires distributed setup

For a homelab Velero target, Garage does everything MinIO does at a fraction of the resource cost. The only thing you give up is the MinIO web console — but garage bucket info and garage key list give you everything you need from the CLI.


With this setup, a complete cluster rebuild from scratch — fresh k3s installation, ArgoCD, and a velero restore — takes under 30 minutes. That's the practical test for whether your backup strategy actually works.


The same backup-first mindset applies in enterprise Azure environments — where the equivalent is Azure Backup, geo-redundant storage, and immutable blob policies. If you're building the network foundation that those services sit on, the Enterprise Terraform Blueprints cover the Private Link and storage isolation layer.

Top comments (0)