Convert a Tenant to a Pi Image

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:

VariableOn DeevnetOn your Pi
DEEVNET_TENANTyour tenantthe same
MQTT_HOSTmqtt.mobile.deevnet.net<hostname>.local
MQTT_PORT88838883
MQTT_CA_FILEdeevnet-root-ca.pem (Deevnet’s root)deevnet-kit-ca.pem (the card’s)
MQTT_USERNAME, MQTT_PASSWORDyour app’s broker accountthe same: you re-create it with the same password
LOG_ENDPOINTlog_endpointhttps://<hostname>.local:8427
LOG_INGEST_TOKEN, LOG_READ_TOKENlog_ingest_token, log_read_tokenthe card’s
LOG_SELECT_HEADERX-Deevnet-Partitionthe same
LOG_DEVICE_PARTITION<index>-2the same, if you keep the index
GRAFANA_URLdashboard_urlhttps://<hostname>.local:3000
GRAFANA_AUTHdashboard_username:dashboard_passwordthe card’s
GRAFANA_ORG_ID, TF_VAR_grafana_org_iddashboard_org_idthe card’s
GRAFANA_CA_CERTdeevnet-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.

  1. 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.
  2. Write the card, and leave it in the reader. The boot partition (bootfs) opens on your computer. README.txt there is this page’s short version, and stays on the card.
  3. On the boot partition:
    • Name your tenant in deevnet-kit.txt:

      tenant=bench1
      index=4
      

      Use your Deevnet tenant’s name and index (terraform output, or deevnet_tenant.this.index). With the same index, LOG_DEVICE_PARTITION doesn’t change either. Left empty, the tenant is pi with 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=US
      

      The card becomes bench1.local, and its certificate names it. Set a name at a meetup: two cards left unnamed both answer to raspberrypi.local. Wi-Fi needs all three lines; the Pi keeps its radio off until it has a country. The Wi-Fi password is removed from deevnet-kit.txt once 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 twice
      

      userconf.txt is 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 own openssl can’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 bootfs appears as a drive, say E:. Git for Windows’ openssl can 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 upSigning in
Password onlynothing moressh you@<pi>, then type the password
A key you already haveonce, after first boot: ssh-copy-id -i ~/.ssh/id_ed25519.pub you@<pi>no password
A new key, just for this Pissh-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/.der with the card’s (~/deevnet-kit/deevnet-kit-ca.pem; openssl x509 -in deevnet-kit-ca.pem -outform der -out deevnet-kit-ca.der for 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.

  1. On the Pi, make the key and the request:

    sudo deevnet-kit cert request
    

    It prints the request and keeps it in /etc/deevnet-kit/tls/server.csr; the new key waits beside the certificate it will replace.

  2. Have it signed, in your tenant’s Terraform. A deevnet_certificate with that request, name = "mqtt", names = ["logs", "grafana"] plus any of your app’s own, and sites listing where the Pi runs or is headed, such as ["mobile", "home"]. Apply, and copy fullchain_pem and deevnet-root-ca.pem to the Pi.

  3. Install it:

    sudo deevnet-kit cert install fullchain.pem deevnet-root-ca.pem
    

    It checks the certificate is for the waiting key, chains to the root given, and names mqtt, logs and grafana under <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, for logs.<tenant>.deevnet.net. deevnet-kit v0.15.0 leaves them on localhost, which the new certificate does not name, so Grafana’s log queries fail: there, run sudo systemctl restart deevnet-kit-dashboards after 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.

  1. 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.com lets your CA sign names in a domain of yours as well; .local names, localhost and private addresses it always may.

  2. Give every device and computer both roots. sudo deevnet-kit export ~/deevnet-kit now writes trust-bundle.pem, the root they trust now and yours, and kit.env names it. Put it on each device in place of the CA file it has.

  3. Switch the Pi.

    sudo deevnet-kit pki switch
    

    The 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.net and the rest: your CA may sign names under <tenant>.deevnet.net, so a device verifies the same name before and after.

  4. Once every device holds the bundle, take the Deevnet Root off them. export now writes owner-root-ca.pem alone: 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 DeevnetOn your Pi
Workload VMsYour 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-IOTYour own Wi-Fi
The device registryDevice names are checked for shape only
Terraform state and the API tokenNothing 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_password in /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.txt and ssh as 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 the curl in 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.

Page last modified: November 8, 2026