Build a real app
Walk through a complete app, the way experienced app developers set one up. Services with S6 overlay, options with Bashio, translations, documentation, CI, and releases.
Your first app shows how little an app needs. A real app needs more: services that start in the right order and stop the container when they crash, options with validation, logs with levels, documentation, linting, images for every architecture, and releases.
The best way to learn that is to read a real one. hassio-addons/app-example is the example app of Home Assistant Community Apps, a project that maintains a set of community apps, and Bashio. It shows a random quote in the log every few seconds. That is not useful by itself, but everything around it is set up the way a maintained app should be. This tutorial walks through it, file by file.
Open the repository next to this page. You do not need to build anything to follow along, but you can install the example to see it run, and copy it as the start of your own app.
The example app is published in the Home Assistant Community Apps repository. To install it:
- Add the repository: under Settings > Apps, open the App store, and in the top-right menu, select Repositories. Add
https://github.com/hassio-addons/repository. - Find Example in the store, and select Install.
- Select Start, then open the Log tab.
You see a banner with the name and version of the app and your system, then a quote every five seconds, and every ten seconds a line from a second service. Change Seconds between quotes on the Configuration tab, restart the app, and watch the log.
Home Assistant Community Apps gives every app its own Git repository. A separate repository, the one you just added, lists the released apps for users. The app repository looks like this, leaving out a few files for GitHub like the issue template and code of conduct:
app-example/
├── .github/
│ ├── workflows/
│ │ ├── ci.yaml
│ │ ├── deploy.yaml
│ │ ├── labels.yaml
│ │ ├── pr-labels.yaml
│ │ └── release-drafter.yaml
│ └── renovate.json
├── example/
│ ├── rootfs/
│ ├── translations/
│ │ └── en.yaml
│ ├── .README.j2
│ ├── build.yaml
│ ├── config.yaml
│ ├── Dockerfile
│ ├── DOCS.md
│ ├── icon.png
│ └── logo.png
├── .yamllint
├── LICENSE.md
└── README.mdThe app itself is the example folder. Everything outside it is for the people who work on the app: workflows, linter settings, and the license.
You do not have to split your apps like this. You can also keep all your apps in one repository, with a repository.yaml at the top, so users add that repository directly. home-assistant/apps-example is set up that way. Create and share a repository explains that setup. The example folder works the same way in both.
example/config.yaml tells the Supervisor what the app is and what the user can set:
---
name: Example
version: dev
slug: example
description: Example app by Home Assistant Community Apps
url: https://github.com/hassio-addons/app-example
init: false
arch:
- aarch64
- amd64
options:
log_level: info
seconds_between_quotes: 5
schema:
log_level: list(trace|debug|info|notice|warning|error|fatal)
seconds_between_quotes: int(1,120)A few things to notice:
version: devis a placeholder. When a release is published, the tooling of Home Assistant Community Apps writes the real version, and theimageto pull, into the copy of this file in the app repository users add. In your own repository, put the real version here, and addimageonce you publish images. See Publish your app.init: falseis required with the base images, because S6 overlay must be the first process in the container. Run services with S6 overlay explains why.- The schema does the validation for you.
int(1,120)means the Supervisor refuses anything outside 1 to 120, so the script never has to check.list(...)shows a dropdown. log_levelis a common convention: users turn on debug logging themselves when something goes wrong, without a new version of your app.
Every key is in the app configuration reference. Options and their types are in Add options.
example/Dockerfile, shortened:
ARG BUILD_FROM=ghcr.io/hassio-addons/base:21.0.8
# hadolint ignore=DL3006
FROM ${BUILD_FROM}
# Copy root filesystem
COPY rootfs /
# Setup base
RUN apk add --no-cache \
coreutils=9.11-r0 \
wget=1.25.0-r3
# Build arguments
ARG BUILD_ARCH
ARG BUILD_VERSION
# Labels
LABEL \
io.hass.arch="${BUILD_ARCH}" \
io.hass.version=${BUILD_VERSION}The full file adds more build arguments and labels: a name, description, build date, Git commit, and the Open Container Initiative labels like org.opencontainers.image.source. The build workflow fills those in; the Supervisor only passes BUILD_ARCH, BUILD_VERSION, and, with a build.yaml, BUILD_FROM and any build arguments you list under args: in that file. It also sets io.hass.type="app". The legacy value of that label is addon. See labels.
What to take from it:
- The base image. The example starts from the base image of Home Assistant Community Apps. Like the Home Assistant base images, it is Alpine Linux with S6 overlay, Bashio, and Tempio. It adds a startup banner in the log and sets the Bashio log level from a
log_leveloption. You can use either. build.yaml. The example also has abuild.yamlwith the base image per architecture. Its build workflow readsbuild_fromfrom that file. The Supervisor still reads it, but logs that it is deprecated. In your own app, put the base image in theDockerfilewith a default forBUILD_FROM, as on the first line here, and leavebuild.yamlout. See build.yaml.- Pinned versions. The base image and every Alpine package have an exact version. Builds are repeatable, and Renovate opens a pull request when a new version is out. The
.github/renovate.jsonof the example has the rules that find these versions in theDockerfile. COPY rootfs /. All the files of the app live inrootfs, in the place they go in the image. NoCMDand noENTRYPOINT: S6 overlay starts what is inrootfs.- hadolint. The comment
# hadolint ignore=DL3006silences one rule of the Dockerfile linter that runs in CI, because the base image tag comes from an argument.
example/rootfs/etc/s6-overlay/ has three services:
| Service | Type | Depends on | What it does |
|---|---|---|---|
init-example |
oneshot | base |
Runs once at startup and logs a greeting. In a real app, this is where you write configuration files. |
example1 |
longrun | init-example |
Runs /usr/bin/example1.sh, the quote loop. |
example2 |
longrun | example1 |
Runs /usr/bin/example2.sh, a second loop. |
The run script of a longrun logs a line, then hands over to the program with exec:
#!/command/with-contenv bashio
# shellcheck shell=bash
bashio::log.info "Starting Example1..."
exec /usr/bin/example1.shEach longrun has a finish script that stops the whole container when the program crashes, so the Supervisor sees the failure. All three are in the user bundle, through empty files in user-bundles.d/user/contents.d/.
How the folders, the type, up, and finish files, and the dependencies work is in Run services with S6 overlay. Read that guide next to the example; it uses this exact layout.
The quote loop in rootfs/usr/bin/example1.sh reads its option once, at startup:
#!/command/with-contenv bashio
# shellcheck shell=bash
main() {
local sleep
bashio::log.trace "${FUNCNAME[0]}"
sleep=$(bashio::config 'seconds_between_quotes')
bashio::log.info "Seconds between each quotes is set to: ${sleep}"
while true; do
display_quote
sleep "${sleep}"
done
}
main "$@"bashio::config gets the options from the Supervisor API, which is why the script starts with with-contenv: that gives it the token it needs. Options only change when the user saves them and restarts the app, so reading them once is enough.
Other functions you use for options:
# Is an optional option set?
if bashio::config.has_value 'username'; then
bashio::log.info "Logging in as $(bashio::config 'username')"
fi
# Stop with a clear message when a required option is empty
bashio::config.require 'password'
# A boolean option
if bashio::config.true 'ssl'; then
bashio::config.require.ssl
fiAll of them are in the Bashio options reference.
The example logs at different levels. bashio::log.info for normal events, bashio::log.notice when it falls back to an offline quote, and bashio::log.trace at the start of every function, for when someone needs to see everything.
The user picks the level with the log_level option. The community base image applies it before your services start, so bashio::log.debug and bashio::log.trace stay quiet unless the user asks for them. On a Home Assistant base image, set it yourself at the start of your first service:
if bashio::config.has_value 'log_level'; then
bashio::log.level "$(bashio::config 'log_level')"
fiOne more thing the example shows. Bashio stops the script when a command fails. The example fetches a quote with grep, and grep fails when it finds nothing. So the example adds || true, with a comment that says why:
# The page might not contain a quote at all, in which case grep exits
# non-zero. Bashio runs with errexit (and inherit_errexit), so swallow
# that here and let the caller deal with an empty quote instead.
quote=$(grep -e "<dt>" -e "</dd>" <<< "${html}" | ...) || trueLogging, log levels, and error handling are in the Bashio logging reference.
What users see of the app, apart from the log:
translations/en.yamlgives the options a readable name and description on the Configuration tab. Without it, the user seesseconds_between_quotes.DOCS.mdis the Documentation tab: how to install the app, every option, and where to get help.icon.pngandlogo.pngshow up in the app store and on the app page..README.j2is a template for theREADME.mdof the app in the repository users add. The release tooling of Home Assistant Community Apps renders it, with the version and, for the beta and edge channels, a warning. If you keep your apps in one repository, write a plainREADME.mdinstead.
The translation file of the example:
---
configuration:
log_level:
name: Log level
description: The amount of logging the app should do.
seconds_between_quotes:
name: Seconds between quotes
description: >-
The number of seconds the app waits until showing a new quote in the
app log.Present your app covers each of these files.
Every pull request runs .github/workflows/ci.yaml. It calls a shared workflow from hassio-addons/workflows, pinned to a commit. That workflow:
- Reads the app information from
config.yamlandbuild.yaml: the slug, the architectures, and the base image. - Runs the linters, each as its own job:
- The app linter, frenck/action-app-linter, which validates
config.yamland warns about defaults you can leave out. - Hadolint for the
Dockerfile. - ShellCheck for the scripts, including the
runandfinishfiles of the S6 services. - yamllint, with the rules in
.yamllint, andjqfor every JSON file. - Prettier, for JSON, JavaScript, Markdown, and YAML.
- zizmor, for security problems in the workflows themselves.
- The app linter, frenck/action-app-linter, which validates
- Reviews the dependencies that the pull request changes.
- Builds the image for every architecture, each on a runner of that architecture, without pushing it.
The # shellcheck shell=bash line in every script is for ShellCheck: it does not know the bashio interpreter, and that line tells it to check the script as Bash.
The other workflows keep the repository tidy: labels.yaml syncs the labels of the repository with a shared list, pr-labels.yaml checks that every pull request has one of the labels that describe a change, like bugfix or new-feature, and release-drafter.yaml keeps a draft of the next release notes up to date.
The README of hassio-addons/workflows says that these workflows are subject to change and not versioned, and that using them in other projects is possible but not recommended. For your own repository, copy the lint jobs you want, and build and publish with the builder actions of Home Assistant.
.github/workflows/deploy.yaml runs on every push to main, and when a release is published on GitHub. It runs the same checks as CI first, then builds the image for every architecture, pushes it to the GitHub Container Registry, and combines the images into one multi-architecture image, like ghcr.io/hassio-addons/example.
Which version and channel that becomes depends on what started it:
| Trigger | Version | Channel |
|---|---|---|
Push to main |
The short Git commit, like 6b6b1f1 |
edge |
| Pre-release on GitHub | The tag, lowercase and without a leading v |
beta |
| Release on GitHub | The tag, lowercase and without a leading v |
stable |
A stable release goes to the beta channel as well. After the build, the workflow tells the matching app repository of Home Assistant Community Apps about the new version. Its tooling then copies the app files, writes version and image into config.yaml, renders the README, and commits that. Users of that channel get the update.
So a release is a GitHub release. Release Drafter keeps the draft of the notes, and publishing it with a tag like v1.2.0 starts the build. The version follows Semantic Versioning, as the DOCS.md of the example explains to users: major for breaking changes, minor for new features, patch for fixes.
That release flow is specific to Home Assistant Community Apps. The rules behind it apply to every app, and are in Publish your app: publish the image before users can see the new version, and never reuse a version.
To start your own app from the example:
- Copy the
examplefolder into your repository, and rename it after your app. - In
config.yaml, changename,slug,description,url, and the options. Setversionto a real version. - In the
Dockerfile, pick your base image: keep the community base image, or use a Home Assistant base image. Put it inARG BUILD_FROM, and removebuild.yaml. - Install your software in the
Dockerfile, and replace the services inrootfs/etc/s6-overlay/with your own. Keep thefinishscripts. - Update
translations/en.yaml,DOCS.md, the icon, and the logo. - Add workflows: the lint jobs you want from the example, and the builder actions to build and publish your images.
- Test it: copy the folder into
local_appson your Home Assistant, or use the devcontainer from Test your app.
- How apps work: what the Supervisor does with your app, from the repository to a running container. Read it next.
- Run services with S6 overlay: everything about the services in
rootfs. - Bashio reference: every function, from options to the Supervisor API.
- Secure your app: add an AppArmor profile, and ask for as little access as possible.
- Add a web interface with ingress: show your app inside Home Assistant.