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:
kubectlconfigured and pointing at the k0s cluster- Docker with buildx support and the insecure registry configured
.envfile 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 statusover 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