Structuring Production Nerves Applications with Umbrella Apps

Most Nerves projects start as a single Mix project — a handful of modules, maybe a GenServer or two, one `mix.exs`. That’s the right way to start, and it stays fine for a long time. It stops being fine once the device grows a graphical UI, its own firmware update logic, and a few different hardware interfaces all running at once. At that point, a bug in your UI rendering code shouldn’t be able to take GPIO control down with it, and “how the device boots and recovers” needs to be a different concern from “what the device does once it’s running.” A single flat application makes both of those hard to guarantee.

 

This post covers the general case for splitting a growing Nerves application into separate OTP applications, the trade-offs involved in how you do that, and the practical mechanics — dependency boundaries, release configuration, boot resilience. Throughout, we’ll use a real four-app umbrella (`pp_core_fw`, `pp_core`, `pp_scenic`, `pp_scenic_touch`) as a concrete example of the pattern, not as the subject of the post — the goal is a structure you can apply to your own project, whatever apps it ends up with.

Why Split a Growing Nerves App?

The core reason to split at all is crash isolation. Each OTP application is its own supervision tree, so a crash in one doesn’t propagate into the others by default. In a single flat application, everything shares one supervision tree — which means a render bug in your UI layer and a bus timeout on a GPIO line are, as far as the VM is concerned, the same kind of problem, restarted (or not) by the same logic.

 

Splitting by responsibility fixes that, and the boundaries usually fall out of a fairly small set of concerns most non-trivial Nerves devices end up with:
  • A release/boot layer — the thing that assembles the firmware, runs update hooks, and hands off to everything else. This should be able to survive a bad deploy elsewhere in the system.
  • A hardware interface layer — GPIO, SPI, UART, I2C. Bus errors and device timeouts here are common and shouldn’t be able to crash anything above them.
  • A UI or rendering layer, if the device has a screen — rendering bugs, font loading failures, and driver quirks are common enough here that you don’t want them taking hardware control down with them.
  • Input layers, if input is decoupled from rendering — a touch driver failing shouldn’t stop the display from drawing, and vice versa.

 

As a concrete example, here’s how `pp_nerves` maps those same four concerns onto four umbrella apps:
App Responsibility Why Isolate?
pp_core_fw
Release assembly, firmware update (fwup) hooks, boot sequencing
The release entry point should never crash the actual application logic. fwup runs in a separate phase.
pp_core
Raw hardware interfaces: GPIO, SPI, UART via `circuits_*` libs
Hardware interface crashes (bus errors, device timeouts) should not crash the UI.
pp_scenic
cenic viewport, framebuffer driver, scenes
Graphics rendering is fragile — a render bug or font loading failure should not kill hardware control.
pp_scenic_touch
Touch input driver for Scenic
Input driver runs independently of the display driver.
Your own project’s split doesn’t need to match this exactly — a device with no display might just have a release layer and a hardware layer; one with multiple sensor subsystems might split hardware further. The principle is the same regardless of how many apps you end up with: draw boundaries around things that fail for different reasons and on different timescales.
 

Dependency Boundaries Follow the Same Logic

Once apps are split, it’s worth keeping each one’s dependencies scoped to only what it actually needs — not because Mix requires it, but because letting one app’s dependency tree leak into another defeats some of the point of splitting in the first place. A hardware-interface app pulling in your UI framework’s transitive dependencies, or vice versa, means compile times balloon and version conflicts become harder to isolate.
 
In `pp_nerves`, the hardware app declares just its `circuits_*` deps:
				
					# pp_nerves_app/apps/pp_core/mix.exs
defp deps do
  [
    {:circuits_gpio, "~> 2.1"},
    {:circuits_spi,  "~> 2.0"},
    {:circuits_uart, "~> 1.5"}
  ]
end
				
			
while the UI app declares its own Scenic and rendering deps, entirely separately:
				
					# pp_nerves_app/apps/pp_scenic/mix.exs
defp deps do
  [
    {:elixir_make, "~> 0.7.7", override: true, runtime: false},
    {:nerves, "~> 1.11.1 or ~> 1.12", runtime: false},
    {:shoehorn, "~> 0.9", targets: @all_targets},
    {:nerves_runtime, "~> 0.13.9"},
    {:nerves_pack, "~> 0.7", targets: @all_targets},
    {:toolshed, "~> 0.4", targets: @all_targets},
    {:scenic, "~> 0.11.2"},
    {:chisel, "~> 0.2"},
    {:pp_scenic_touch, in_umbrella: true}
  ]
end
				
			
Neither app needs to know the other’s dependency tree exists.
 

Firmware Update Logic Deserves Its Own App, Specifically

Of all the splits above, the release/boot layer is worth calling out on its own, because getting it wrong has the worst failure mode. Firmware update hooks, release configuration, and boot-time initialization need to run in a context that can safely restart the device or fall back to a recovery partition — without depending on your business logic or UI having started successfully first. If update logic lives in the same supervision tree as your UI, a bad UI deploy can take your recovery path down with it, which is exactly the scenario you’re trying to protect against. In `pp_nerves`, this is `pp_core_fw`’s entire job, and nothing else.
 

Umbrella Apps vs. Poncho Projects

Once you’ve decided to split, there are two common ways to structure the split in Mix, and it’s worth choosing deliberately rather than defaulting to the one that looks more familiar.
Umbrella apps (Mix’s built-in `apps_path` structure) give every sub-app a shared `mix.lock`, a shared `_build`, and a shared dependency resolution. That’s convenient — one lockfile, one build cache, one version of every shared dependency — but it also means a host-only app and a target-only app in the same umbrella are still resolved together. If one app’s dependency conflicts with another app’s target-specific needs, or you want to version a hardware-facing app independently from a UI app, the shared resolution gets in the way.
Poncho projects — separate, independent Mix projects tied together with plain path dependencies rather than a shared `apps/` tree — sidestep this by giving each project its own independent `mix.lock` and `_build`, at the cost of a bit more wiring to connect them.
Neither is universally “more correct” — it’s a trade-off between convenience and independence. `pp_nerves` uses an umbrella, and for a project where all four apps are tightly coupled to the same firmware image and released together, that’s a reasonable call — the shared build cache and single lockfile are a feature here, not a bug. If your apps are less tightly coupled, or you expect to develop and version them independently, a poncho layout is worth evaluating before you commit to umbrella just because it’s the more familiar pattern.
 
 

Wiring the Apps Together: Dependencies and the Release

However you split things, you need two things to actually work: the apps need to depend on each other correctly, and the release needs to know which ones to start.
 

The Dependency Graph

The umbrella root’s `mix.exs` is typically minimal — no application of its own, just enough to declare shared, umbrella-wide dependencies:
				
					defmodule PpNervesApp.MixProject do
  use Mix.Project

  def project do
    [
      apps_path: "apps",
      version: "0.1.0",
      start_permanent: Mix.env() == :prod,
      deps: deps()
    ]
  end

  defp deps do
    [
      {:pp_custom_bbb,
       path: "../pp_custom_bbb",
       override: true,
       runtime: false,
       targets: :pp_custom_bbb}
    ]
  end
end
				
			
Individual apps then depend on each other with `in_umbrella: true`, forming a directed graph rooted at whichever app is your release entry point. In `pp_nerves`, that graph looks like:
				
					pp_core_fw
  ├── pp_core        (in_umbrella: true)
  ├── pp_scenic      (in_umbrella: true)
  │     └── pp_scenic_touch (in_umbrella: true)
  ├── pp_test        (in_umbrella: true)
  └── pp_custom_bbb  (path dependency)
				
			
The general rule worth keeping in mind: the release entry point depends on everything that needs to start, but apps further down the graph don’t need to know about the entry point at all — dependencies should point toward the release root, never away from it.
 

The Release’s Application List Decides What Actually Boots

Depending on an app in `mix.exs` isn’t the same as that app starting at boot — the release configuration decides that explicitly, via an `applications:` list marking each one `:permanent`, `:transient`, or similar. This is useful precisely because it decouples “what’s compiled into the release” from “what starts automatically,” which is what lets you ship diagnostic or optional apps without them running unless something else pulls them in. `pp_nerves`’s release config, in `pp_core_fw`, looks like this:
				
					# pp_nerves_app/apps/pp_core_fw/mix.exs
defp release(_) do
  [
    overwrite: true,
    cookie: "#{@app}_cookie",
    include_erts: &Nerves.Release.erts/0,
    steps: [&Nerves.Release.init/1, :assemble],
    strip_beams: Mix.env() == :prod or [keep: ["Docs"]],
    applications: [
      runtime_tools: :permanent,
      shoehorn: :permanent,
      toolshed: :permanent,
      pp_core_fw: :permanent,
      pp_core: :permanent,
      pp_scenic: :permanent,
      pp_test: :permanent
    ]
  ]
end
				
			
Notice `pp_scenic_touch` isn’t listed — it’s started by `pp_scenic` as a transitive dependency when touch is enabled, rather than by the release directly. That’s a useful general pattern: the release list should name the apps that boot the system, and let those apps own starting their own sub-dependencies, rather than flattening everything into one long list.
 

Boot Resilience: Why a Supervisor Tree Alone Isn’t Enough

Crash isolation between your own applications is only half the story. By default, if any application in an Erlang/OTP release fails to start, the VM halts — and on a headless embedded device with no monitor or keyboard, a halted VM means a device you now have to physically retrieve to fix.

 

This is what `shoehorn` is for, and it’s arguably the single most important piece of “production-ready” in this whole pattern. Shoehorn starts a minimal, resilient set of applications first — enough to get networking and remote access up — and starts the rest of your application stack as a separate, supervised step after that. If your UI or business-logic app fails to boot because of a bad release, shoehorn’s job is to make sure you still have a device reachable over SSH to diagnose and fix it, rather than a bricked board.

 

One consequence of relying on shoehorn worth knowing: your release entry point app often doesn’t need its own `mod:` application callback at all. In `pp_nerves`, `pp_core_fw` deliberately omits `mod: {PpCoreFw.Application, []}`:
				
					# pp_nerves_app/apps/pp_core_fw/mix.exs
def application do
  [
    # Match pp_core: no application callback — shoehorn starts :pp_core_fw as a
    # plain OTP app; pp_scenic and other release apps start from the release list.
    extra_applications: [:logger, :runtime_tools]
  ]
end
				
			
That app becomes a container rather than a runtime supervisor — its job is to assemble the release and get out of the way, with the real supervision trees living in the apps that actually do runtime work.
 

Configuration: Keeping Each App’s Settings Scoped

The same isolation principle that applies to code and dependencies applies to configuration — each app should own its own config, rather than everything living in one global file where it’s unclear which app a given setting even affects.
 

A Root Config That Just Imports

The umbrella root’s config typically does nothing but import each app’s own config file, in order:
				
					import Config

import_config "../apps/pp_core_fw/config/config.exs"
import_config "../apps/pp_core/config/config.exs"
import_config "../apps/pp_scenic/config/config.exs"
import_config "../apps/pp_scenic/config/scenic.exs"
				
			
This keeps the root file as a table of contents rather than a place settings accumulate.
 

Splitting Config by Target vs. Host

A common need in any Nerves app, umbrella or not, is behaving differently depending on whether you’re compiling for the host (for development, e.g. the Scenic simulator) or for target hardware. `pp_core`’s config handles that split cleanly:
 
				
					import Config

Application.ensure_all_started(:nerves_bootstrap)

config :nerves, :firmware, rootfs_overlay: "rootfs_overlay"
config :nerves, source_date_epoch: "1775644021"

if Mix.target() == :host do
  import_config "host.exs"
else
  import_config "target.exs"
end

import_config "gpio.exs"
import_config "uart.exs"
				
			
The general pattern — one file that branches on `Mix.target()` and imports the rest — is worth reusing regardless of how many apps you have, since almost every Nerves project needs some form of this split somewhere.
 

Config Scoped to a Single App’s Concerns

Settings that are genuinely specific to one app’s behavior — which scene to boot into, touch calibration parameters, and so on — belong in that app’s own config, not the root:
				
					# pp_nerves_app/apps/pp_scenic/config/scenic.exs
config :pp_scenic, target: Mix.target()

config :scenic, :assets, module: ScenicDev.Assets

# Calculator demo as root scene
config :pp_scenic, :use_calculator_demo, true

# Touch config
config :pp_scenic, :touch_device_substring, "ADS7846"
config :pp_scenic, :touch_pointercal_path, "/data/pointercal"
config :pp_scenic, :touch_linear_raw_max, 4095
				
			
Anyone reading `pp_scenic`’s config directory doesn’t need to go hunting through a shared root file to understand what the UI app depends on.
 

Runtime Config, for Anything That Shouldn’t Be Baked Into the Image

Secrets and dynamically-discovered values — Wi-Fi credentials, device-specific IDs — generally don’t belong compiled into a firmware image at all. The standard mechanism for this in any Nerves project is `config/runtime.exs` at the root, which runs at boot time, after the release is unpacked but before any application starts. `pp_nerves` doesn’t use this yet, but it’s worth setting up early rather than retrofitting once secrets start accumulating in compile-time config by habit.
 

Setting This Up for a New Project

Scaffolding

Mix’s umbrella generator isn’t Nerves-aware, and Nerves’ generator doesn’t produce an umbrella on its own, so in practice you combine the two. Start with a plain Mix umbrella:
				
					mix new my_project --umbrella
cd my_project/apps
				
			
Then generate each sub-app the way that fits what it does. For an app that needs to boot on target hardware — your firmware entry point, or anything with a direct Nerves dependency — use the Nerves generator inside `apps/`:
				
					mix nerves.new pp_core_fw
				
			
For apps that are pure Elixir/OTP with no direct Nerves dependency — business logic, or a hardware-abstraction layer that just wraps `circuits_*` — a plain supervised Mix app is enough:
				
					mix new pp_core --sup

				
			
There’s no built-in “convert an existing single-app project to an umbrella” command. Migrating an existing app means restructuring manually: create the `apps/` tree, move your existing `lib/`, `config/`, and `mix.exs` into a sub-app directory, and adjust paths in the umbrella root to match.
 

The Shared Build Convention

Whichever apps you generate, each one’s `mix.exs` points back at the umbrella root for its build artifacts:
				
					# apps/my_app/mix.exs
defmodule MyApp.Core.MixProject do
  use Mix.Project

  def project do
    [
      app: :my_app_core,
      version: "0.1.0",
      build_path: "../../_build",
      config_path: "../../config/config.exs",
      deps_path: "../../deps",
      lockfile: "../../mix.lock",
      elixir: "~> 1.15",
      start_permanent: Mix.env() == :prod,
      deps: deps()
    ]
  end

  defp deps do
    [
      {:circuits_gpio, "~> 2.1"},
      {:other_dep, "~> 1.0"}
    ]
  end
end
				
			
This is the mechanical expression of the trade-off from section 2: all apps sharing `_build`, `deps`, and `mix.lock` at the umbrella root is what makes umbrella convenient, and it’s exactly what a poncho layout gives up in exchange for independence.
 

Building, Running, and Developing Day to Day

Building and Flashing

Regardless of how many apps are in the umbrella, the build and deploy commands don’t change:
				
					cd pp_nerves_app
mix firmware   # compiles every app, applies root config, produces one firmware image
mix burn       # flashes to SD card or eMMC
				
			
The release entry point app (configured via its `releases:` setting) is what determines the final assembled image — everything else is a dependency of it, directly or transitively.
 

Boot Order in Practice

At boot, shoehorn starts apps in the order the general pattern from section 3.3 implies — a minimal resilient layer first, then the rest of the stack:
  1. `runtime_tools` and `logger` (OTP built-ins)
  2. The release container app (`pp_core_fw`)
  3. The hardware interface app (`pp_core`)
  4. The UI app (`pp_scenic`), whose supervisor starts the Scenic viewport and framebuffer driver:
				
					def start(_type, _args) do
  target = Application.fetch_env!(:pp_scenic, :target)

  if target != :host do
    load_framebuffer_driver()
  end

  children = [
    {Scenic.ViewPort, main_viewport_config()}
  ]

  Supervisor.start_link(children, strategy: :one_for_one, name: PpScenic.Supervisor)
end
				
			

Developing Without Hardware

Because hardware access is guarded rather than assumed, apps in this kind of split can usually be developed and tested entirely on a host machine, without touching a board:
				
					cd pp_nerves_app/apps/pp_scenic
mix scenic.run
				
			
				
					if target != :host do
  load_framebuffer_driver()
end
				
			
That single guard is what lets the exact same code path run in Scenic’s host simulator and on real hardware — a pattern worth building into any app in the split that touches hardware, not just the UI layer.
 

Testing Per App

One of the more immediate payoffs of splitting is that testing stops meaning “run the whole firmware and see what happens.” Each app has its own test directory and can be tested in isolation:
				
					cd pp_nerves_app/apps/pp_core
mix test

cd pp_nerves_app/apps/pp_scenic
mix test
				
			
The shared `_build` and `deps` directories mean compilation is still cached across the whole umbrella — changing a module in one app doesn’t force a recompile of another unless it actually depends on what changed.
 

Takeaways

The structural decisions here all trace back to one idea: separate “what can go wrong” from “what it takes down with it.” Hardware I/O, UI rendering, and firmware recovery fail for different reasons and on different timescales, and putting each in its own OTP application — with something like shoehorn guaranteeing you a reachable device even when one of them fails outright — is what makes that separation actually hold up once a device is in the field rather than on your desk.

 

None of this requires four apps specifically, or an umbrella specifically — those are just how one project happened to draw the lines. What’s worth carrying into your own project is the reasoning: split along crash and failure boundaries, keep the release/recovery layer independent of everything else, scope dependencies and config to the app that actually needs them, and decide deliberately between umbrella and poncho based on how independently your apps actually need to be versioned — not by default.
Facebook
Twitter
LinkedIn
Email

Recent Posts

2 Responses

Leave a Reply

Your email address will not be published. Required fields are marked *

Table Header Table Header Table Header Table Header
Content
Content
Content
Content