ghcr.io/home-assistant/home-assistant
validated pin current tags: 2026.7.1
Home Assistant — minimum Linux capabilities, derived by drop-test against HA's own default invocation (the deployment sets no cap_drop — its compose comment: "tighten further only after verifying HA still boots"). This profile trims the full Docker default cap set 14 -> 1. capabilities: cap_drop:[ALL] + cap_add:[DAC_OVERRIDE]. HA runs `python3 -m homeassistant` under s6 AS ROOT — there is no privilege drop and no chown in init, so no SETUID/SETGID/CHOWN startup step. The single cap HA CORE needs comes from WHERE it writes: the `/config` dir is a bind owned by the deploy user (non-root), and HA-as-root creating its DB / .storage / logs there needs DAC_OVERRIDE. Derived under a fresh, NON-root-owned /config, faithful to the deployment — against a root-owned /config (e.g. a named volume) HA needs NO caps at all (cap_add: []). See criteria/ghcr.io/home-assistant/home-assistant.md. SCOPE: this is HA *core* on a base config. Capabilities pulled in by specific integrations — NET_RAW (ICMP ping), device access (USB/Bluetooth/Zigbee), NET_ADMIN (some network integrations) — are NOT exercised by a base instance and are documented as extensions in the criteria doc. The per-deployment re-derivation against a real ./config is the faithful surface for an integration-heavy install. filesystem: read_only:true, tmpfs:[/run:exec]. HA's config, SQLite DB, and .storage live in /config (a persistent volume, never tmpfs). Under a read-only rootfs its s6-overlay supervisor needs /run writable AND EXECUTABLE — it writes then execs /run/s6/.../init, so a default (noexec) tmpfs fails; the exec-mounted tmpfs /run:exec is required. /tmp NOT required. Derived by drop-test.
Use it
services:
home-assistant:
image: ghcr.io/home-assistant/home-assistant:2026.7.1
cap_drop: [ALL]
cap_add: [DAC_OVERRIDE]
security_opt: ["no-new-privileges:true"]
read_only: true
tmpfs:
- /run:exec
This profile has multiple dimensions and is applied as a unit. Dimensions can interact — a capability can be required only because of a sibling read-only/tmpfs recommendation — so where they do, the minimum was derived under the sibling dimension's context. See the criteria doc and each dimension's recorded invocation below before applying them separately.
Dimension: capabilities
cap_drop: ALL + cap_add: DAC_OVERRIDE
| tool | container-sec-derive 0.7.0 |
|---|---|
| observer | drop-test |
| validated image | ghcr.io/home-assistant/home-assistant@sha256:f73512ba4fe0… |
| validated date | 2026-07-16 |
| confidence | moderate |
| validated via | drop-test, ci-smoke |
| workload | profiles/workloads/home-assistant.sh |
| workload sha256 | ec8142869c48… |
| ig version | v0.51.0 |
Feature coverage (ledger)
A drop-test proves the minimum for what the workload exercised. Privilege-relevant features it did not drive are listed honestly — using one may need more than this minimum.
| feature | evidence / reason | |
|---|---|---|
| driven | base install: web UI + onboarding owner-create | drives the real /config write path — the DAC_OVERRIDE need — end-to-end |
| not driven | hardware/USB/Zigbee serial integrations | need a --device mapping (a devices-dimension question); not exercised by the base-install workload |
| not driven | Bluetooth integrations | need D-Bus/host bus access; not exercised |
| not driven | network integrations (dhcp/scan class) | some need NET_ADMIN/NET_RAW; not exercised — the reason coverage grades partial |
Drop-test evidence
Each candidate removed in turn, the container restarted, and the workload re-verified.
| Removed | Verdict | Observed |
|---|---|---|
CHOWN | removable | correct: HA up, config written as root into a non-root /config, owner onboarding succeeded |
DAC_OVERRIDE | required | container exited: HA could not write its DB/.storage/logs into the non-root-owned /config |
FSETID | removable | correct: HA up, config written as root into a non-root /config, owner onboarding succeeded |
FOWNER | removable | correct: HA up, config written as root into a non-root /config, owner onboarding succeeded |
MKNOD | removable | correct: HA up, config written as root into a non-root /config, owner onboarding succeeded |
NET_RAW | removable | correct: HA up, config written as root into a non-root /config, owner onboarding succeeded |
SETGID | removable | correct: HA up, config written as root into a non-root /config, owner onboarding succeeded |
SETUID | removable | correct: HA up, config written as root into a non-root /config, owner onboarding succeeded |
SETFCAP | removable | correct: HA up, config written as root into a non-root /config, owner onboarding succeeded |
SETPCAP | removable | correct: HA up, config written as root into a non-root /config, owner onboarding succeeded |
NET_BIND_SERVICE | removable | correct: HA up, config written as root into a non-root /config, owner onboarding succeeded |
SYS_CHROOT | removable | correct: HA up, config written as root into a non-root /config, owner onboarding succeeded |
KILL | removable | correct: HA up, config written as root into a non-root /config, owner onboarding succeeded |
AUDIT_WRITE | removable | correct: HA up, config written as root into a non-root /config, owner onboarding succeeded |
Recorded invocation (run_config)
The minimum is valid for this invocation; a different user:, volume state, or entrypoint can change it.
| security_opt | no-new-privileges:true |
|---|---|
| mounts | ./config:/config |
| env | TZ |
Workload script (profiles/workloads/home-assistant.sh)
#!/usr/bin/env bash
# Workload exerciser for the Home Assistant reference image.
#
# HA runs `python3 -m homeassistant` under s6 AS ROOT (no privilege drop). The one
# capability HA core exercises comes from writing into a non-root-owned /config
# bind, so this drives the real write path rather than mere liveness:
# - reach the onboarding API (HA only gets here after writing its DB + .storage +
# logs into /config as root);
# - complete owner onboarding (create the first user) -> HTTP 200 + an auth_code,
# a further real write to .storage/auth.
# Returns 0 if every step succeeds.
#
# SCOPE: HA *core* on a base config. Capabilities pulled in by specific
# integrations (NET_RAW for ICMP ping, device access for USB/Bluetooth/Zigbee,
# NET_ADMIN for some network integrations) are not exercised here — see the
# criteria doc.
#
# Required env: HACONTAINER (target container name or id). The container's /config
# must be a NON-root-owned bind (matching a real deployment) for DAC_OVERRIDE to be
# exercised.
set -euo pipefail
: "${HACONTAINER:?HACONTAINER must be set}"
C="${HACONTAINER}"
READY='import urllib.request as u,json,sys; d=json.load(u.urlopen("http://127.0.0.1:8123/api/onboarding",timeout=3)); sys.exit(0 if isinstance(d,list) and d and "step" in d[0] else 1)'
deadline=$((SECONDS + 60))
until docker exec "$C" python3 -c "$READY" 2>/dev/null; do
if (( SECONDS >= deadline )); then
echo "HA onboarding API never became ready in 60s" >&2
exit 1
fi
sleep 2
done
out="$(docker exec "$C" python3 -c 'import urllib.request as u,json; b=json.dumps({"client_id":"http://localhost:8123/","name":"csd","username":"csd","password":"csdprobe-pw-12345","language":"en"}).encode(); r=u.urlopen(u.Request("http://127.0.0.1:8123/api/onboarding/users",data=b,headers={"Content-Type":"application/json"}),timeout=15); d=json.load(r); print(r.status, "auth_code" in d)' 2>&1)"
if [ "$out" != "200 True" ]; then
echo "onboarding user-create failed (expected '200 True', got: ${out##*$'\n'})" >&2
exit 1
fi
Dimension: filesystem
read_only: true + tmpfs: /run:exec
| tool | container-sec-derive 0.7.0 |
|---|---|
| observer | drop-test |
| validated image | ghcr.io/home-assistant/home-assistant@sha256:f73512ba4fe0… |
| validated date | 2026-07-16 |
| confidence | high |
| validated via | drop-test, ci-smoke |
| workload | profiles/workloads/home-assistant.sh |
| workload sha256 | ec8142869c48… |
| ig version | v0.51.0 |
Drop-test evidence
Each candidate removed in turn, the container restarted, and the workload re-verified.
| Removed | Verdict | Observed |
|---|---|---|
/run:exec | required | container failed to start: s6-overlay-suexec: fatal: child failed with exit code 111 |
/tmp | removable | correct: HA up, config/DB/.storage written as root into a non-root /config, owner onboarding succeeded |
Recorded invocation (run_config)
The minimum is valid for this invocation; a different user:, volume state, or entrypoint can change it.
| env | TZ |
|---|
Workload script (profiles/workloads/home-assistant.sh)
#!/usr/bin/env bash
# Workload exerciser for the Home Assistant reference image.
#
# HA runs `python3 -m homeassistant` under s6 AS ROOT (no privilege drop). The one
# capability HA core exercises comes from writing into a non-root-owned /config
# bind, so this drives the real write path rather than mere liveness:
# - reach the onboarding API (HA only gets here after writing its DB + .storage +
# logs into /config as root);
# - complete owner onboarding (create the first user) -> HTTP 200 + an auth_code,
# a further real write to .storage/auth.
# Returns 0 if every step succeeds.
#
# SCOPE: HA *core* on a base config. Capabilities pulled in by specific
# integrations (NET_RAW for ICMP ping, device access for USB/Bluetooth/Zigbee,
# NET_ADMIN for some network integrations) are not exercised here — see the
# criteria doc.
#
# Required env: HACONTAINER (target container name or id). The container's /config
# must be a NON-root-owned bind (matching a real deployment) for DAC_OVERRIDE to be
# exercised.
set -euo pipefail
: "${HACONTAINER:?HACONTAINER must be set}"
C="${HACONTAINER}"
READY='import urllib.request as u,json,sys; d=json.load(u.urlopen("http://127.0.0.1:8123/api/onboarding",timeout=3)); sys.exit(0 if isinstance(d,list) and d and "step" in d[0] else 1)'
deadline=$((SECONDS + 60))
until docker exec "$C" python3 -c "$READY" 2>/dev/null; do
if (( SECONDS >= deadline )); then
echo "HA onboarding API never became ready in 60s" >&2
exit 1
fi
sleep 2
done
out="$(docker exec "$C" python3 -c 'import urllib.request as u,json; b=json.dumps({"client_id":"http://localhost:8123/","name":"csd","username":"csd","password":"csdprobe-pw-12345","language":"en"}).encode(); r=u.urlopen(u.Request("http://127.0.0.1:8123/api/onboarding/users",data=b,headers={"Content-Type":"application/json"}),timeout=15); d=json.load(r); print(r.status, "auth_code" in d)' 2>&1)"
if [ "$out" != "200 True" ]; then
echo "onboarding user-create failed (expected '200 True', got: ${out##*$'\n'})" >&2
exit 1
fi
Evidence & provenance
- Validation criteria — per-image scenarios and pass criteria
- Profile source in the repository
- Version history — every previously published pin, with the full profile as validated against it
Raw profile YAML
# Home Assistant — minimum Linux capabilities, derived by drop-test against HA's
# own default invocation (the deployment sets no cap_drop — its compose comment:
# "tighten further only after verifying HA still boots"). This profile trims the
# full Docker default cap set 14 -> 1.
#
# capabilities: cap_drop:[ALL] + cap_add:[DAC_OVERRIDE]. HA runs
# `python3 -m homeassistant` under s6 AS ROOT — there is no privilege drop and no
# chown in init, so no SETUID/SETGID/CHOWN startup step. The single cap HA CORE
# needs comes from WHERE it writes: the `/config` dir is a bind owned by the deploy
# user (non-root), and HA-as-root creating its DB / .storage / logs there needs
# DAC_OVERRIDE. Derived under a fresh, NON-root-owned /config, faithful to the
# deployment — against a root-owned /config (e.g. a named volume) HA needs NO caps
# at all (cap_add: []). See criteria/ghcr.io/home-assistant/home-assistant.md.
#
# SCOPE: this is HA *core* on a base config. Capabilities pulled in by specific
# integrations — NET_RAW (ICMP ping), device access (USB/Bluetooth/Zigbee),
# NET_ADMIN (some network integrations) — are NOT exercised by a base instance and
# are documented as extensions in the criteria doc. The per-deployment re-derivation
# against a real ./config is the faithful surface for an integration-heavy install.
# filesystem: read_only:true, tmpfs:[/run:exec]. HA's config, SQLite DB, and
# .storage live in /config (a persistent volume, never tmpfs). Under a read-only
# rootfs its s6-overlay supervisor needs /run writable AND EXECUTABLE — it writes
# then execs /run/s6/.../init, so a default (noexec) tmpfs fails; the exec-mounted
# tmpfs /run:exec is required. /tmp NOT required. Derived by drop-test.
#
schema_version: "1.6"
image: ghcr.io/home-assistant/home-assistant
reference_url: https://tmatens.github.io/container-security-profiles/profiles/ghcr.io/home-assistant/home-assistant.html
applies_to:
tags: ["2026.7.1"]
status: validated
dimensions:
capabilities:
cap_drop: [ALL]
cap_add: [DAC_OVERRIDE]
derivation:
tool: container-sec-derive
tool_version: "0.7.0"
sidecar_schema_version: "1.5"
observer: drop-test
validated_image: ghcr.io/home-assistant/home-assistant@sha256:f73512ba4fe06bb4d57636fe3578d0820cdec46f81e8f837ab59e451662ff3cb
validated_date: "2026-07-16"
duration_seconds: 101
confidence: moderate
workload: profiles/workloads/home-assistant.sh
workload_sha256: "ec8142869c48d0d292d9041eea84a2cbebc9256b39fdce1767fbf0aa461730f5"
validated_via: [drop-test, ci-smoke]
observation_backend:
ig_version: v0.51.0
gadgets: []
run_config:
user: ""
command: []
entrypoint: ""
network: ""
pid: ""
devices: []
security_opt: ["no-new-privileges:true"]
mounts: ["./config:/config"]
env: [TZ]
features:
- name: "base install: web UI + onboarding owner-create"
driven: true
why: "drives the real /config write path — the DAC_OVERRIDE need — end-to-end"
- name: "hardware/USB/Zigbee serial integrations"
driven: false
why: "need a --device mapping (a devices-dimension question); not exercised by the base-install workload"
- name: "Bluetooth integrations"
driven: false
why: "need D-Bus/host bus access; not exercised"
- name: "network integrations (dhcp/scan class)"
driven: false
why: "some need NET_ADMIN/NET_RAW; not exercised — the reason coverage grades partial"
drop_test:
checks:
- {removed: CHOWN, required: false, observed: "correct: HA up, config written as root into a non-root /config, owner onboarding succeeded"}
- {removed: DAC_OVERRIDE, required: true, observed: "container exited: HA could not write its DB/.storage/logs into the non-root-owned /config"}
- {removed: FSETID, required: false, observed: "correct: HA up, config written as root into a non-root /config, owner onboarding succeeded"}
- {removed: FOWNER, required: false, observed: "correct: HA up, config written as root into a non-root /config, owner onboarding succeeded"}
- {removed: MKNOD, required: false, observed: "correct: HA up, config written as root into a non-root /config, owner onboarding succeeded"}
- {removed: NET_RAW, required: false, observed: "correct: HA up, config written as root into a non-root /config, owner onboarding succeeded"}
- {removed: SETGID, required: false, observed: "correct: HA up, config written as root into a non-root /config, owner onboarding succeeded"}
- {removed: SETUID, required: false, observed: "correct: HA up, config written as root into a non-root /config, owner onboarding succeeded"}
- {removed: SETFCAP, required: false, observed: "correct: HA up, config written as root into a non-root /config, owner onboarding succeeded"}
- {removed: SETPCAP, required: false, observed: "correct: HA up, config written as root into a non-root /config, owner onboarding succeeded"}
- {removed: NET_BIND_SERVICE, required: false, observed: "correct: HA up, config written as root into a non-root /config, owner onboarding succeeded"}
- {removed: SYS_CHROOT, required: false, observed: "correct: HA up, config written as root into a non-root /config, owner onboarding succeeded"}
- {removed: KILL, required: false, observed: "correct: HA up, config written as root into a non-root /config, owner onboarding succeeded"}
- {removed: AUDIT_WRITE, required: false, observed: "correct: HA up, config written as root into a non-root /config, owner onboarding succeeded"}
filesystem:
read_only: true
tmpfs: [/run:exec]
derivation:
tool: container-sec-derive
tool_version: "0.7.0"
sidecar_schema_version: "1.5"
observer: drop-test
validated_image: ghcr.io/home-assistant/home-assistant@sha256:f73512ba4fe06bb4d57636fe3578d0820cdec46f81e8f837ab59e451662ff3cb
validated_date: "2026-07-16"
duration_seconds: 15
confidence: high
workload: profiles/workloads/home-assistant.sh
workload_sha256: "ec8142869c48d0d292d9041eea84a2cbebc9256b39fdce1767fbf0aa461730f5"
validated_via: [drop-test, ci-smoke]
observation_backend:
ig_version: v0.51.0
gadgets: []
run_config:
user: ""
command: []
entrypoint: ""
network: ""
pid: ""
devices: []
security_opt: []
mounts: []
env: [TZ]
drop_test:
checks:
- {removed: /run:exec, required: true, observed: "container failed to start: s6-overlay-suexec: fatal: child failed with exit code 111"}
- {removed: /tmp, required: false, observed: "correct: HA up, config/DB/.storage written as root into a non-root /config, owner onboarding succeeded"}