The problem
You have a fleet of remote devices equipped with Kubernetes, for example Raspberry Pi devices with K3s, a lightweight Kubernetes distribution. You can have a central management cluster, GitOps, remote container registries reachable from your devices.
Keeping workloads updated is painful. Cloud-native delivery tools assume data-center grade connectivity – they fall apart when the network is intermittent, firewalled, or behind NAT. You need SSH access just to debug a failed rollout. You want to push a commit and have it land on the device and you don’t want to refactor your developer experience, based on Kubernetes, the standard Developer Platform..
Architecture of the solution
Two layers:
- OS layer with K3s: you flash your device with K3s and Mender pre-installed and the minimal configuration to connect to your Mender Server account. You want to update the OS or only K3s to a newer patched kubernetes version? Just create a new Mender rootfs artifact and send it to your device (infrequent)
- Application layer: you package your Kubernetes application into a Mender artifact. No Git server or Container registry involved. You just send your artifact to your fleet!
Embedding Mender Update Modules into your image builds is what makes this fly.
How does it work?
The linked repository contains a basic Kubernetes application, the required helper scripts and tooling to facilitate the K3s device onboarding. Because the deployment pipeline runs as a GitHub Actions workflow on your copy of the repo, start by forking it to your own GitHub account (https://github.com/oldgiova/mender-k3s-edge-demo → Fork), then clone your fork to your local development environment:
git clone https://github.com/<your-username>/mender-k3s-edge-demo.git
cd mender-k3s-edge-demo
Optionally you can create a Raspberry Pi bootable image equipped with K3s and Mender, ready to be flashed and delivered to your device:
curl -LO https://d4o6e0uccgv40.cloudfront.net/2025-10-01-raspios-lite/arm/2025-10-01-raspios-lite-raspberrypi4_trixie_64bit-mender-convert-5.2.1.img.xz
make customize-image \
INPUT=2025-10-01-raspios-lite-raspberrypi4_trixie_64bit-mender-convert-5.2.1.img.xz \
DEVICE_USER=edgy \
DEVICE_HOSTNAME=edge-01 \
MENDER_SERVER_URL=https://hosted.mender.io \
MENDER_TENANT_TOKEN=<your-token> \
K3S_VERSION=v1.35.0+k3s1 \
DEMO=true
Here MENDER_TENANT_TOKEN is your tenant token (in Hosted Mender under Settings → My organization). It is baked into the device’s /etc/mender/mender.conf and only lets the device connect to your tenant. It is not the same credential the CI pipeline uses to deploy – see “Two tokens, two jobs” below.
DEMO=true is optional: it bakes in mender-convert’s demo polling intervals (update and inventory every 5 s, retry every 30 s) instead of the slow production defaults, so deployments land within seconds while you are testing. Drop it for production images to avoid needless polling traffic.
The make customize-image calls a bash script which helps you to customize your Raspberry Pi with a firstboot configuration with K3s and Mender. Most importantly, it installs in the the Mender Update Module that will allow the K3s service to deploy your application to the Kubernetes node:
#!/bin/sh
set -e
STATE="$1"
FILES="$2"
CONTAINERD_SOCK=/run/k3s/containerd/containerd.sock
KUBECONFIG=/etc/rancher/k3s/k3s.yaml
case "$STATE" in
NeedsArtifactReboot)
echo "No"
;;
SupportsRollback)
echo "No"
;;
ArtifactInstall)
if [ -f "$FILES/files/image.tar" ]; then
ctr --address "$CONTAINERD_SOCK" --namespace k8s.io images import "$FILES/files/image.tar"
fi
kubectl --kubeconfig "$KUBECONFIG" apply -f "$FILES/files/manifests.yaml"
;;
esac
Flash it to an SD Card (change /dev/sdX to your actual device)
IMG=$(ls output/*-custom.img)
sudo dd if="$IMG" of=/dev/sdX bs=4M conv=fsync status=progress
Use a Raspberry Pi 4 with at least 4GB of RAM (8GB recommended) – running a K3s node together with your workloads on less is likely to hit memory pressure. Use a microSD card of at least 16GB: it holds two A/B OS copies plus the K3s data partition and its container images. On first boot the data partition (/data, where K3s keeps its state under /data/k3s) automatically grows to fill the whole SD card – mender-convert bakes this in by default, so there is no manual resize step regardless of card size.
When the edge device boots up, you can see it available on your Mender Server instance, and you can accept it to start interacting with it. (Bonus: if you have Mender Gateway with mTLS configuration, you can onboard it automatically and in a very secure way).
Configure GitHub Actions
The deployment pipeline runs in your fork and talks to your Mender server, so it needs credentials and a few coordinates. In your fork, go to Settings → Secrets and variables → Actions and add:
| Kind | Name | Value |
|---|---|---|
| Secret | MENDER_TOKEN |
A Personal Access Token from hosted Mender (Profile → Access tokens in the Mender UI) |
| Variable | MENDER_SERVER_URL |
Your Mender server, e.g. https://hosted.mender.io (or https://eu.hosted.mender.io) |
| Variable | MENDER_DEVICE_TYPE |
The device type, e.g. raspberrypi4_64 – must match the device type accepted in Mender |
| Variable | MENDER_DEVICE_GROUP |
The Mender device group to deploy to, e.g. edge-devices |
With those in place, every push to main will build the artifact and create the deployment automatically.
Two tokens, two jobs
Do not confuse the two Mender tokens this solution relies on. They authenticate different things, live in different places, and are not interchangeable:
| Token | Where to get it | Where it goes | What it authenticates |
|---|---|---|---|
| Tenant token | Hosted Mender → Settings → My organization | Baked into the device image via MENDER_TENANT_TOKEN (/etc/mender/mender.conf) |
The device enrolling into your tenant |
| Personal Access Token (PAT) | Hosted Mender → your profile → Personal access tokens | The GitHub Actions secret MENDER_TOKEN |
The CI pipeline calling the Mender management API (upload artifact, create deployment) |
The tenant token is shared by every device in your tenant and only grants enrolment – it gives no management access. The PAT carries your user’s permissions and can manage deployments, so keep it secret: store it only as a GitHub Actions secret, never bake it into an image or commit it. Both must also point at the same Mender region (hosted.mender.io vs eu.hosted.mender.io).
Now you can deploy your application: edit app/html/index.html and push to main. Watch GitHub Actions build the arm64 image, render the Kustomize overlay manifests, package the artifact, and trigger the deployment. The page updates on the device. No Mender UI interaction required. The operator never left their normal tools:
pi@edge-01:~$ sudo kubectl get pods -n edge-app -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
nginx-edge-56f877dc79-v9jvb 1/1 Running 0 3h43m 10.42.0.9 edge-01 <none> <none>
pi@edge-01:~$ sudo kubectl get pods -n edge-app -o yaml | grep image:
- image: nginx-edge:sha-b6851337a8b870e0c7d853071895a44f6a2bff61
Verify it’s deployed correctly by navigating your device IP address:
<h1>Hello from the edge — v1</h1>
Edit app/html/index.html and push v2:
The automation is creating a new Mender Deployment under the hood, targeting your edge device, with the application built in it - no remote registry required. When the Mender Deployment finishes, you can see the updated result:
pi@edge-01:~$ sudo kubectl get pods -n edge-app
NAME READY STATUS RESTARTS AGE
nginx-edge-7cb567b94c-rht2v 1/1 Running 0 37s
pi@edge-01:~$ sudo kubectl get pods -n edge-app -o yaml | grep image:
- image: nginx-edge:sha-c728e30224548f270bc2910b5b926db34dbf378e
From the browser you can see the demo message from the updated index.html:
<h1>Hello from the edge — v2</h1>
How does automation work under the hood?
When you push your app/html/index.html to main, a Github Action is building a new Artifact with a container OCI image and the Kubernetes manifests:
- name: Build OCI image (arm64) and export to tarball
run: |
mkdir -p payload
docker buildx build \
--platform linux/arm64 \
--output "type=docker,dest=payload/image.tar" \
--tag nginx-edge:sha-${{ github.sha }} \
app/
- name: Render kustomize manifests
run: |
cd k8s/overlays/edge
kustomize edit set image nginx-edge=nginx-edge:sha-${{ github.sha }}
cd ../../..
kustomize build k8s/overlays/edge/ > payload/manifests.yaml
Then the Github Action is creating a Mender artifact out of them:
- name: Create Mender artifact
run: |
mender-artifact write module-image \
--type k8s-workload \
--compatible-types "${{ vars.MENDER_DEVICE_TYPE }}" \
--artifact-name "${{ env.ARTIFACT_NAME }}" \
--file payload/image.tar \
--file payload/manifests.yaml \
-o "${{ env.ARTIFACT_NAME }}.mender"
The next step is to call the Mender Server APIs to deliver the new release to the Raspberry Pi device:
- name: Upload artifact to hosted Mender
run: |
status=$(curl -sS -X POST \
-H "Authorization: Bearer ${{ secrets.MENDER_TOKEN }}" \
-F "artifact=@${{ env.ARTIFACT_NAME }}.mender" \
-o response.txt -w "%{http_code}" \
"${{ vars.MENDER_SERVER_URL }}/api/management/v1/deployments/artifacts")
cat response.txt
test "$status" -lt 300 || test "$status" -eq 409
- name: Create deployment
run: |
status=$(curl -sS -X POST \
-H "Authorization: Bearer ${{ secrets.MENDER_TOKEN }}" \
-H "Content-Type: application/json" \
-o response.txt -w "%{http_code}" \
"${{ vars.MENDER_SERVER_URL }}/api/management/v1/deployments/deployments/group/${{ vars.MENDER_DEVICE_GROUP }}" \
-d '{
"name": "${{ env.ARTIFACT_NAME }}",
"artifact_name": "${{ env.ARTIFACT_NAME }}"
}')
cat response.txt
test "$status" -lt 400
That’s all the magic!
Observability: inventory and Device Connect
There are two bonus features that come for free. The inventory script reports the running image tag, replica count, and pod status directly in the Mender UI:
This is very useful for fleet-wide visibility without kubectl access. Device Connect provides a WebSocket shell for live debugging without VPN or exposed SSH. The image build pre-configures mender-connect.conf so this shell opens as your DEVICE_USER (here edgy) rather than root, so passwordless sudo and the k3s tooling behave just like they do over SSH:
Next steps
- Create a custom Update Module: the Update Module v3 protocol that the
k8s-workloadmodule in this guide implements, with the full list of states and exit codes. - Composable Updates: Independent Update Domains for OS, Applications, and Containers: The theory behind the OS-layer and application-layer split this guide uses, including provides/depends coordination.
- Mender Gateway: OTA Updates for Segregated Networks: The transport-side companion for edge fleets behind NAT, firewalls, or a single upstream link, including mTLS onboarding.
- Need help with a custom edge deployment? Contact the Mender professional services team to discuss your specific requirements.


