Deploying to k0s with a shell script instead of CI/CD

Published: 2026-06-05

This site runs on a single-node k0s cluster. There's no GitHub Actions, no ArgoCD, no FluxCD. The deploy pipeline is one shell script — k0s-setup/06-deploy-site.sh — that you run from your laptop. Here's why that's fine and how it works.


The cluster

k0s is a single-binary Kubernetes distribution. The whole control plane and worker run on one VPS (2 vCPU, 4 GB RAM) in Prague. k0s was chosen over k3s or microk8s because it has a minimal footprint, no bundled extras, and manages itself cleanly. The setup script (01-prepare-vm.sh) installs it in about two minutes.

Two replicas of the site pod run on the single node. Not for high availability — so rollouts are zero-downtime: Kubernetes drains one pod before starting the replacement. This requires a PodDisruptionBudget:

yamlapiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: antonnovikov-site-pdb
spec:
  minAvailable: 1
  selector:
    matchLabels:
      app: antonnovikov-site

Self-hosted Docker registry

There's no DockerHub or GHCR involved. The cluster runs a plain Docker Registry v2 as a NodePort service on port 30500. Images are pushed directly to 91.184.248.13:30500/antonnovikov-site:<tag>.

The registry is unauthenticated on the internal port (firewalled, not exposed to the internet). Images never leave the VPS.


The deploy script

The script does four things in sequence:

1. Build the image

bashIMAGE_TAG="${IMAGE_TAG:-$(git rev-parse --short HEAD)}"
docker buildx build --platform linux/amd64 \
    -t "${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" --push .

The tag defaults to the short Git commit SHA. --push sends it straight to the registry in one step. For machines without buildx, it falls back to docker build + docker push.

Before the build, the service worker's cache name is stamped with the current Unix timestamp:

bashBUILD_TIME=$(date +%s)
sed -i.bak "s/self.__BUILD_TIME__/'${BUILD_TIME}'/g" assets/js/sw.js
# ...build happens...
git checkout assets/js/sw.js   # restore

This forces the SW to update its cache on every deploy without changing the JS file permanently in the repo.

2. Sync .env → Kubernetes Secret

bashkubectl create secret generic antonnovikov-site-secrets \
    --from-env-file=".env" \
    --dry-run=client -o yaml | kubectl apply -f -

The local .env file (not committed to the repo) is turned into a Kubernetes Secret on every deploy. --dry-run=client -o yaml | kubectl apply is the idiomatic way to upsert a Secret without kubectl complaining about an existing resource.

3. Apply manifests

bashfor manifest in middleware service ingressroute certificate deployment vpn-redirect; do
    envsubst < "${SCRIPT_DIR}/manifests/${manifest}.yaml" | kubectl apply -f -
done

Manifest files use ${VARIABLE} placeholders. envsubst fills them from the shell environment before piping to kubectl. The Deployment manifest references the image with the current tag:

yamlimage: ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}

Changing the tag is what triggers the rolling update. No kubectl set image, no patch — just re-applying the manifest with a new value.

4. Clean up old images

After the rollout completes, the script queries the registry's API for all tags and deletes any that aren't the current one:

bashTAGS=$(curl -s "http://${REGISTRY}/v2/${IMAGE_NAME}/tags/list" | jq -r '.tags[]?')
for tag in $TAGS; do
    if [ "$tag" != "$IMAGE_TAG" ]; then
        DIGEST=$(curl -s -I \
            -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
            "http://${REGISTRY}/v2/${IMAGE_NAME}/manifests/${tag}" \
            | grep -i "Docker-Content-Digest" | awk '{print $2}' | tr -d '\r')
        curl -s -X DELETE \
            "http://${REGISTRY}/v2/${IMAGE_NAME}/manifests/${DIGEST}" || true
    fi
done

Rollout verification

After applying the Deployment manifest, the script waits for the rollout:

bashkubectl rollout status deployment/antonnovikov-site --timeout=120s

This blocks until all pods are ready. If the new pod fails to start (bad image, crashloop, failed liveness probe), the script exits non-zero and the old pods keep running. Kubernetes' rolling update strategy ensures the old replica is only terminated after the new one passes readiness.


Why not CI/CD?

For a personal site with one contributor, a CI pipeline adds ceremony without much value:

  • It needs secrets stored in the CI system (same secrets I already have in .env).
  • It needs a runner with Docker and kubectl configured.
  • It adds a web UI to click through when something goes wrong.
  • It introduces a dependency on an external service (GitLab CI, GitHub Actions).

The shell script is ~120 lines, readable, runnable locally, and auditable. Running it takes 90 seconds. When it fails, the error is right there in the terminal.

The tradeoff: no automatic deploys on push. That's intentional. This site changes rarely, and "deploy on every commit" would mean accidental deploys during exploratory hacking.


Running the deploy

bash# Standard deploy (uses current git HEAD)
./k0s-setup/06-deploy-site.sh

# Deploy a specific tag
IMAGE_TAG=abc1234 ./k0s-setup/06-deploy-site.sh

# Dry run: just build and push, don't apply manifests
DRY_RUN=1 ./k0s-setup/06-deploy-site.sh

Prerequisites:

  • kubectl configured and pointing at the k0s cluster
  • Docker with buildx support and the insecure registry configured
  • .env file with site secrets

Comparison: shell script vs GitLab CI

Shell script GitLab CI
Trigger Manual (run from laptop) Automatic on push
Secrets Local .env CI variables
Visibility Terminal output CI job UI
Dependency kubectl + Docker locally CI runner
Debug Immediate in terminal Need to read job logs
Rollback Re-run with old tag Retry job or manual

For a team or multi-environment setup, CI/CD is the right call. For a personal site with one deployer, the shell script is genuinely simpler.


What can go wrong

kubectl set image returns success but the pod stays on the old image. Check the image pull policy: if imagePullPolicy: IfNotPresent and the old image is cached on the node, Kubernetes won't pull the new one. Use imagePullPolicy: Always or push with unique tags (not latest).

SSH key rejected on the VPS. The deploy script SSHes to the VPS to trigger kubectl. If the key isn't in authorized_keys, or the agent isn't running locally (ssh-add), the deploy fails. Keep a separate deploy key for the script distinct from your personal key.

Rollout times out. kubectl rollout status waits up to the configured timeout. If the new pod can't start (OOMKilled, ImagePullBackOff), the script hangs. Set --timeout=120s and handle the non-zero exit code.


Summary

  • The deploy script is kubectl set image + kubectl rollout status over SSH — nothing more
  • Single-node personal VPS doesn't need CI/CD; the shell script is auditable, debuggable, and requires no extra infrastructure
  • Docker image tags use the git short SHA so every deploy is uniquely identified and rollback is re-running the script with an older tag
  • Secrets are created once manually with kubectl create secret; the deploy script doesn't touch secrets