Convert a Tenant to a Raspberry Pi Image #
This page converts a tenant into a standalone Raspberry Pi image: your app and the services it
uses move onto a Pi of your own, and your devices keep working with it, with no Deevnet behind
them. The Pi is flashed from the image factory’s pi-backend
image and runs your tenant’s back-end services itself, so it can go anywhere: home, a classroom, a
project that outlives the site.
on Deevnet on your Pi
ββββββββββ ββββββββββ
devices ββTLS 8883βββΆ mqtt.mobile.deevnet.net devices ββTLS 8883βββΆ your Pi (Mosquitto)
app ββTLS 8427βββΆ Deevnet log store app ββTLS 8427βββΆ your Pi (VictoriaLogs)
you ββTLS 3000βββΆ Deevnet Grafana you ββTLS 3000βββΆ your Pi (Grafana)
<tenant>/β¦ <tenant>/log/<device> <tenant>/β¦ <tenant>/log/<device> β unchanged
The Pi keeps the app contract: the same ports, the same topic prefix, the same reserved log
level, the same ingest and read tokens with the same partition header, and a Grafana organization
with the same three data source UIDs. What changes is the host
name, the CA and the secrets. Nothing on the Pi belongs to Deevnet. It has no Deevnet account,
key or route, and its CA and tokens are generated on the card the first time it boots.
A Pi can instead take a certificate from Deevnet, and answer to your tenant’s own names under the Deevnet Root. That is a Deevnet Pi, and it is how a Pi runs at a Deevnet production site.
The one rule: configure from the environment #
The move takes one file only if your app never hard-codes a Deevnet name. Read every endpoint from the environment, under these names:
| Variable | On Deevnet | On your Pi |
|---|---|---|
DEEVNET_TENANT | your tenant | the same |
MQTT_HOST | mqtt.mobile.deevnet.net | <hostname>.local |
MQTT_PORT | 8883 | 8883 |
MQTT_CA_FILE | deevnet-root-ca.pem (Deevnet’s root) | deevnet-kit-ca.pem (the card’s) |
MQTT_USERNAME, MQTT_PASSWORD | your app’s broker account | the same: you re-create it with the same password |
LOG_ENDPOINT | log_endpoint | https://<hostname>.local:8427 |
LOG_INGEST_TOKEN, LOG_READ_TOKEN | log_ingest_token, log_read_token | the card’s |
LOG_SELECT_HEADER | X-Deevnet-Partition | the same |
LOG_DEVICE_PARTITION | <index>-2 | the same, if you keep the index |
GRAFANA_URL | dashboard_url | https://<hostname>.local:3000 |
GRAFANA_AUTH | dashboard_username:dashboard_password | the card’s |
GRAFANA_ORG_ID, TF_VAR_grafana_org_id | dashboard_org_id | the card’s |
GRAFANA_CA_CERT | deevnet-root-ca.pem (Deevnet’s root) | deevnet-kit-ca.pem (the card’s) |
The GRAFANA_* names are the ones the Terraform grafana provider reads by itself, and
TF_VAR_grafana_org_id feeds the org_id every resource must carry
(
Dashboards). The dashboards’ data
sources have the same UIDs on both, so the same dashboard code applies to both.
While you are still on Deevnet, have Terraform write the file for you:
output "kit_env" {
sensitive = true
value = <<-EOT
DEEVNET_TENANT=${deevnet_tenant.this.name}
MQTT_HOST=mqtt.mobile.deevnet.net
MQTT_PORT=8883
MQTT_CA_FILE=deevnet-root-ca.pem
MQTT_USERNAME=${deevnet_iot_broker_account.backend.username}
MQTT_PASSWORD=${deevnet_iot_broker_account.backend.password}
LOG_ENDPOINT=${deevnet_tenant.this.log_endpoint}
LOG_INGEST_TOKEN=${deevnet_tenant.this.log_ingest_token}
LOG_READ_TOKEN=${deevnet_tenant.this.log_read_token}
LOG_SELECT_HEADER=${deevnet_tenant.this.log_select_header}
LOG_DEVICE_PARTITION=${deevnet_tenant.this.index}-2
GRAFANA_URL=${deevnet_tenant.this.dashboard_url}
GRAFANA_AUTH=${deevnet_tenant.this.dashboard_username}:${deevnet_tenant.this.dashboard_password}
GRAFANA_ORG_ID=${deevnet_tenant.this.dashboard_org_id}
TF_VAR_grafana_org_id=${deevnet_tenant.this.dashboard_org_id}
GRAFANA_CA_CERT=deevnet-root-ca.pem
EOT
}
terraform output -raw kit_env > kit.env
deevnet_iot_broker_account.backend is your app’s own broker account (the walkthrough’s backend), a workload account with no
device (
Devices and MQTT). Your app logs in
with it on Deevnet, and on the Pi you re-create it with the same password (step 3), so those two
lines are the same on both.
If the app runs with that file on Deevnet, the Pi’s kit.env runs it on the Pi.
Before you convert, save terraform output -json flash: it holds your devices’ broker
passwords. Keep those passwords on the Pi and nothing needs reflashing but the host name and the CA.
1. Flash the card #
You need a Raspberry Pi 3, 4, 5 or Zero 2 W (64-bit; a Pi 4 or 5 if you want dashboards), a
card of 8 GB or more, and the Pi image tools in
Before You Start. At the site, the
image and Pi Imager are in the
tenant downloads pi/
and tools/; check the image against its .sha256 before flashing.
- In Raspberry Pi Imager choose Use custom and pick
raspios-bookworm-mobile-pi-backend.img.xz(from the tenant downloads’pi/). Imager 2.x offers no OS customization for a custom image, so skip it: the boot partition does the same job in step 3. - Write the card, and leave it in the reader. The boot partition (
bootfs) opens on your computer.README.txtthere is this page’s short version, and stays on the card. - On the boot partition:
Name your tenant in
deevnet-kit.txt:tenant=bench1 index=4Use your Deevnet tenant’s name and index (
terraform output, ordeevnet_tenant.this.index). With the same index,LOG_DEVICE_PARTITIONdoesn’t change either. Left empty, the tenant ispiwith index 1.Name the card, and give it Wi-Fi if it won’t be on a cable, in the same file:
hostname=bench1 wifi_ssid=YourNetwork wifi_psk=YourPassword wifi_country=USThe card becomes
bench1.local, and its certificate names it. Set a name at a meetup: two cards left unnamed both answer toraspberrypi.local. Wi-Fi needs all three lines; the Pi keeps its radio off until it has a country. The Wi-Fi password is removed fromdeevnet-kit.txtonce it has been applied, because anyone holding the card can read that partition.Create your login, and turn on SSH:
cd /Volumes/bootfs # macOS; on Linux, wherever bootfs mounted touch ssh # enables SSH on first boot echo "you:$(openssl passwd -6)" > userconf.txt # asks for the password twiceuserconf.txtis one line,username:password-hash. Raspberry Pi OS creates that user on first boot and deletes the file. The username is yours to choose, and the image has no user of its own. macOS’s ownopensslcan’t make that hash (LibreSSL has no-6). Use Homebrew’s (brew install openssl, then$(brew --prefix openssl)/bin/openssl passwd -6), or run the command on any Linux machine and paste the result.On Windows the card’s
bootfsappears as a drive, sayE:. Git for Windows’opensslcan make the hash. In PowerShell, write the file as ASCII, because 5.1’s default is UTF-16:New-Item E:\ssh -ItemType File # enables SSH on first boot $h = & 'C:\Program Files\Git\usr\bin\openssl.exe' passwd -6 # asks for the password twice Set-Content -Encoding Ascii -NoNewline E:\userconf.txt "you:$h`n"
How you sign in: your choice #
Every option works from the same userconf.txt user. Pick whichever you like:
| Set up | Signing in | |
|---|---|---|
| Password only | nothing more | ssh you@<pi>, then type the password |
| A key you already have | once, after first boot: ssh-copy-id -i ~/.ssh/id_ed25519.pub you@<pi> | no password |
| A new key, just for this Pi | ssh-keygen -t ed25519 -f ~/.ssh/my-pi, then ssh-copy-id -i ~/.ssh/my-pi.pub you@<pi> | ssh -i ~/.ssh/my-pi you@<pi> |
ssh-copy-id asks for the password once, and installs the public key for that user on the Pi.
The name at the end of a public key is only a label, so a key made as alice works for a Pi user
called bench1. Password sign-in stays on in every case. Turning it off
(PasswordAuthentication no) is your call once a key works.
If your version of Imager does offer OS customization for the image, you can use it instead of
userconf.txt, ssh and the Wi-Fi lines. Both routes end the same way.
2. First boot #
Boot the Pi where it will live, on a cable or with the Wi-Fi lines above (this is your own Pi on
your own network; Deevnet’s tenant networks themselves are Wi-Fi only). First boot creates your
user, grows the filesystem and reboots once. Then deevnet-kit runs once. It sets the host
name and joins the Wi-Fi, then generates the card’s CA and certificate (naming that host name and
address), the log tokens, the bridge’s credentials and Grafana’s
secrets, and starts the broker, the log store, the bridge and Grafana. Grafana’s first start takes a
minute or two; then deevnet-kit dashboards creates your organization. Until it has, kit.env
carries no GRAFANA_* lines.
The card is <hostname>.local from a computer on the same network (raspberrypi.local if you left
hostname= empty), or find its address on your router’s client list. Each card makes its own SSH
host keys on first boot, so your computer will ask you to accept it once.
ssh you@<pi>
sudo deevnet-kit status
sudo deevnet-kit selftest # the card, end to end: every check ok?
sudo deevnet-kit export ~/deevnet-kit # kit.env + deevnet-kit-ca.pem, the Pi's side of the table above
The self-test runs by itself after every boot, and status shows the last result. It checks:
- every service;
- TLS on 8883, 8427 and 3000;
- a device log line sent over MQTT (through a throwaway account) and read back;
- an app log line sent and read back;
- your Grafana login, data sources and dashboard, including the device line read back through Grafana.
If a check fails, it names it. Each run leaves two log lines marked deevnet-kit selftest.
If the Pi’s address changes after first boot, for example because your router later gives it a
different lease, the certificate no longer names the address your devices dial. status says so.
Run sudo deevnet-kit regen-certs. It reissues the certificate from the same card CA, so no device
needs a new CA. On a
Deevnet Pi, regen-certs refuses: its
certificate names no address, and is renewed from Deevnet. On a network where the Pi’s MAC has a
DHCP reservation, the first boot can still
land on a pool address if an older OS on the same Pi holds the reserved lease. It moves once that
lease expires.
Copy ~/deevnet-kit/ to your computer. That is everything your app needs.
3. Recreate your broker accounts #
On Deevnet your deevnet_iot_broker_account resources made the accounts. On the Pi,
deevnet-kit account does, and it applies the same rules: patterns are relative to your tenant,
a device account may publish only its own log/<device>, and a device account may not read the
log space.
For each account in your Terraform, pass the same name, device and patterns, and keep its
password: put it on the first line of a file and pass --password-file, so it stays out of your
shell history. For devices the password is in flash; for your app it is MQTT_PASSWORD in your
Deevnet kit.env.
sudo deevnet-kit account add pico-1 --device pico-1 \
--publish sensors/pico-1/telemetry --publish log/pico-1 \
--subscribe sensors/pico-1/command \
--password-file pico-1.pw
sudo deevnet-kit account add backend \
--subscribe 'sensors/+/telemetry' --publish 'sensors/+/command' \
--password-file backend.pw
Without a password a new one is generated and printed once. The username is
<tenant>-<name>, as on Deevnet. deevnet-kit account list and deevnet-kit account rm NAME do
the rest. Removing an account disconnects any client still using it.
4. Repoint the devices #
Two lines change in the firmware from the walkthrough:
- The broker. Use the Pi’s address. A Pico W cannot resolve
bench1.local. Give the Pi a DHCP reservation on your router so the address stays put. - The CA. Replace
deevnet-root-ca.pem/.derwith the card’s (~/deevnet-kit/deevnet-kit-ca.pem;openssl x509 -in deevnet-kit-ca.pem -outform der -out deevnet-kit-ca.derfor the Pico).
And the Wi-Fi: the Pi’s network instead of DVNTM-IOT. The username, the password and every topic
stay the same.
The certificate names the Pi’s addresses at the time it was issued. If the address changes,
deevnet-kit status says so; run sudo deevnet-kit regen-certs. That reissues the server
certificate from the same CA, so no device needs a new CA.
5. Run your app on the Pi #
Your app runs on the Pi as a container. Build it for the Pi on your computer, and copy the image across over SSH; no registry is involved:
podman build --platform linux/arm64 -t my-app .
podman save my-app | ssh you@bench1.local sudo podman load
On the Pi, export the app’s settings with its broker login. --app checks the password against
the account from step 3 before it writes anything, so kit.env can’t carry one the broker refuses.
Then install the example unit, which runs localhost/my-app:latest:
sudo deevnet-kit export /opt/my-app --app backend --password-file backend.pw
shred -u backend.pw
sudo cp /opt/deevnet-kit/examples/my-app.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now my-app
sudo journalctl -u my-app -f
The unit uses host networking and points the app’s broker, log store and Grafana at localhost,
because a container cannot resolve .local names. The certificate covers localhost. Podman on Bookworm is 4.3, which has no
quadlets, so it is a plain unit.
Or run the app on your computer with ~/deevnet-kit/kit.env; the Pi does not mind.
6. Bring your dashboards #
Your dashboards on Deevnet are Terraform (the
Dashboards page). Apply the same
configuration against the Pi, with the Pi’s kit.env in the environment:
set -a; . ~/deevnet-kit/kit.env; set +a
export GRAFANA_CA_CERT=~/deevnet-kit/deevnet-kit-ca.pem
terraform -chdir=dashboards init
terraform -chdir=dashboards apply
Use a separate state from your Deevnet one, as above (its own directory, or a workspace): the Pi is a different Grafana, and your Deevnet state would try to update objects that are not there. The data source UIDs are the same, so every panel finds its data. A dashboard you built only by clicking on Deevnet does not come along; export it into your repository first.
In a browser: https://<hostname>.local:3000, with the user and password from GRAFANA_AUTH.
Your organization opens with a “Start here” dashboard: a temperature graph from device log
lines, your device logs and your app logs. It is yours to change; the card never overwrites it.
7. Check it #
set -a; . ~/deevnet-kit/kit.env; set +a
mosquitto_sub -h "$MQTT_HOST" -p 8883 --cafile ~/deevnet-kit/deevnet-kit-ca.pem \
-u "$DEEVNET_TENANT-backend" -P '<backend password>' -t "$DEEVNET_TENANT/sensors/+/telemetry" -v
# Device log lines, as on Deevnet
curl -sS --cacert ~/deevnet-kit/deevnet-kit-ca.pem -H "Authorization: Bearer $LOG_READ_TOKEN" \
-H "$LOG_SELECT_HEADER: $LOG_DEVICE_PARTITION" \
"$LOG_ENDPOINT/select/logsql/query" --data-urlencode 'query=*'
8. Make it a Deevnet Pi #
A Deevnet Pi serves a certificate from Deevnet’s Tenant Service CA for your tenant’s names, and trusts the Deevnet Root ( ADR-0041 Β§2). Every card starts on its own CA, as above, and becomes a Deevnet Pi when a certificate is installed. The key is made on the Pi and never leaves it; only a signing request travels. Signing it is a Terraform apply, so it is done while you have the tenant at the Mobile Factory.
On the Pi, make the key and the request:
sudo deevnet-kit cert requestIt prints the request and keeps it in
/etc/deevnet-kit/tls/server.csr; the new key waits beside the certificate it will replace.Have it signed, in your tenant’s Terraform. A
deevnet_certificatewith that request,name = "mqtt",names = ["logs", "grafana"]plus any of your app’s own, andsiteslisting where the Pi runs or is headed, such as["mobile", "home"]. Apply, and copyfullchain_pemanddeevnet-root-ca.pemto the Pi.Install it:
sudo deevnet-kit cert install fullchain.pem deevnet-root-ca.pemIt checks the certificate is for the waiting key, chains to the root given, and names
mqtt,logsandgrafanaunder<tenant>.deevnet.net, before changing anything. Then it installs it, makes the root what every service on the Pi trusts, keeps the card’s own CA as/etc/deevnet-kit/tls/card-ca.pem, and restarts the broker, the log store, Grafana and the bridge. Then it writes Grafana’s log data sources again, forlogs.<tenant>.deevnet.net.deevnet-kitv0.15.0 leaves them onlocalhost, which the new certificate does not name, so Grafana’s log queries fail: there, runsudo systemctl restart deevnet-kit-dashboardsafter it.
The Pi then answers to mqtt.<tenant>.deevnet.net, logs.<tenant>.deevnet.net and
grafana.<tenant>.deevnet.net. Its own services dial those names, which it maps to itself in
/etc/hosts. Devices and computers dial them too, so the network the Pi runs on has to answer them
with the Pi’s address: at a Deevnet production site, its DNS does
(
ADR-0040 Β§1).
Run sudo deevnet-kit export ~/deevnet-kit again: kit.env names those hosts, and the CA is
deevnet-root-ca.pem. Devices then trust the Deevnet Root, as they do on Deevnet.
It is renewed within the year, with the same key or a new one. From sixty days before it
expires, a plan of your tenant shows the certificate renewing for the same key. Apply, copy the new
fullchain_pem to the Pi, and run sudo deevnet-kit cert install fullchain.pem deevnet-root-ca.pem with the root the Pi already trusts: it replaces the certificate and keeps the
key. For a new key, run cert request first and replace the certificate’s request with the new
one, then apply and install. deevnet-kit v0.15.0 takes only the second: it refuses a certificate
for the key it already serves.
9. A PKI of your own #
When you leave Deevnet, the Pi can move to a PKI of your own, with its devices connected throughout ( ADR-0041 Β§3, Β§4). It is two tiers: your root, on an encrypted USB stick you keep apart from the Pi, and an issuing CA on the Pi, which signs the Pi’s certificate and your devices’. Deevnet holds nothing of it and trusts none of it. A Pi that never became a Deevnet Pi takes the same path.
You need a USB stick you can erase, and two passphrases. One unlocks the stick, the other protects your root’s key on it. Write both down, with the root’s fingerprint the tool prints: they are the only way into your root.
Make your root and the Pi’s issuing CA. With the stick unplugged:
sudo deevnet-kit pki init --org "Your Name Home"It asks for the stick, offers to erase and encrypt it, makes your root on it and the issuing CA on the Pi, and closes the stick. Nothing the Pi serves changes.
--domain example.comlets your CA sign names in a domain of yours as well;.localnames,localhostand private addresses it always may.Give every device and computer both roots.
sudo deevnet-kit export ~/deevnet-kitnow writestrust-bundle.pem, the root they trust now and yours, andkit.envnames it. Put it on each device in place of the CA file it has.Switch the Pi.
sudo deevnet-kit pki switchThe Pi then serves a certificate from your CA, and everything on it trusts your root. Devices holding the bundle reconnect as the broker restarts, with nothing changed on them. A Deevnet Pi keeps its names,
mqtt.<tenant>.deevnet.netand the rest: your CA may sign names under<tenant>.deevnet.net, so a device verifies the same name before and after.Once every device holds the bundle, take the Deevnet Root off them.
exportnow writesowner-root-ca.pemalone: put it on each device in place of the bundle.
A device’s own certificate comes from the Pi. sudo deevnet-kit pki enroll DEVICE --out DIR
makes its key and certificate in DIR; with --csr FILE, it signs the request a device made
itself. The Pi renews its own certificate with sudo deevnet-kit regen-certs, with no stick.
A lost or broken card costs the issuing CA, never your root. Flash a new card, then with the stick:
sudo deevnet-kit pki issuing --org "Your Name Home" --domain <tenant>.deevnet.net
sudo deevnet-kit pki switch
--domain <tenant>.deevnet.net only for a Pi that was a Deevnet Pi. Recreate your broker accounts
with their passwords, as in step 3, and every device connects to the new card unchanged: it trusts
the same root.
What does not come along #
| On Deevnet | On your Pi |
|---|---|
| Workload VMs | Your app runs on the Pi or your computer |
<tenant>.mobile.deevnet.net DNS | <hostname>.local, and the Pi’s address for devices |
Wi-Fi keys on DVNTM-IOT | Your own Wi-Fi |
| The device registry | Device names are checked for shape only |
| Terraform state and the API token | Nothing to apply: deevnet-kit is the whole control plane. Only your dashboards are still Terraform, against the Pi’s Grafana |
Your Deevnet tenant is untouched by any of this. Destroy it with terraform destroy when you are done, or keep
prototyping there. The two do not share a secret.
Where it differs, for the careful #
- The broker is Mosquitto, not VerneMQ. The wire protocol, ports and topic rules are the same.
- A subscription outside your grant is refused in the SUBACK on Deevnet. On the Pi it is accepted and nothing is delivered. Either way no message crosses.
- Revocation is immediate on the Pi and takes effect on the next connect on Deevnet.
- You are Grafana’s admin on the Pi (
grafana_admin_passwordin/etc/deevnet-kit/kit.json). Your tenant login is still an Editor, so the same Terraform runs on both. - Memory. Grafana wants about 512 MB. On a 512 MB Zero 2 W, turn it off:
sudo systemctl disable --now grafana deevnet-kit-dashboards.
First real Pi, 2026-09-25. A Pi 4 flashed with this image, set up with
userconf.txtandsshas above, booted cleanly. The broker, log store and Grafana all came up on the card’s own certificate, and the self-test passed 14 of 15. The failure is Grafana’s “device log readable through Grafana” check: on real hardware Grafana reports the VictoriaLogs plugin as not registered, although it loads under emulation. The cause is being investigated; until it’s fixed, read device logs with thecurlin step 7. Earlier, under emulation, every check on this page passed.
The image is built by make pi-backend in deevnet-image-factory (docs/pi-backend.md), and
deevnet-kit lives in deevnet-provisioning-api beside the rules it applies.