catalog / ghcr.io/home-assistant/home-assistant

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

toolcontainer-sec-derive 0.7.0
observerdrop-test
validated imageghcr.io/home-assistant/home-assistant@sha256:f73512ba4fe0…
validated date2026-07-16
confidencemoderate
validated viadrop-test, ci-smoke
workloadprofiles/workloads/home-assistant.sh
workload sha256ec8142869c48…
ig versionv0.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.

featureevidence / reason
drivenbase install: web UI + onboarding owner-createdrives the real /config write path — the DAC_OVERRIDE need — end-to-end
not drivenhardware/USB/Zigbee serial integrationsneed a --device mapping (a devices-dimension question); not exercised by the base-install workload
not drivenBluetooth integrationsneed D-Bus/host bus access; not exercised
not drivennetwork 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.

RemovedVerdictObserved
CHOWNremovablecorrect: HA up, config written as root into a non-root /config, owner onboarding succeeded
DAC_OVERRIDErequiredcontainer exited: HA could not write its DB/.storage/logs into the non-root-owned /config
FSETIDremovablecorrect: HA up, config written as root into a non-root /config, owner onboarding succeeded
FOWNERremovablecorrect: HA up, config written as root into a non-root /config, owner onboarding succeeded
MKNODremovablecorrect: HA up, config written as root into a non-root /config, owner onboarding succeeded
NET_RAWremovablecorrect: HA up, config written as root into a non-root /config, owner onboarding succeeded
SETGIDremovablecorrect: HA up, config written as root into a non-root /config, owner onboarding succeeded
SETUIDremovablecorrect: HA up, config written as root into a non-root /config, owner onboarding succeeded
SETFCAPremovablecorrect: HA up, config written as root into a non-root /config, owner onboarding succeeded
SETPCAPremovablecorrect: HA up, config written as root into a non-root /config, owner onboarding succeeded
NET_BIND_SERVICEremovablecorrect: HA up, config written as root into a non-root /config, owner onboarding succeeded
SYS_CHROOTremovablecorrect: HA up, config written as root into a non-root /config, owner onboarding succeeded
KILLremovablecorrect: HA up, config written as root into a non-root /config, owner onboarding succeeded
AUDIT_WRITEremovablecorrect: 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_optno-new-privileges:true
mounts./config:/config
envTZ
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

toolcontainer-sec-derive 0.7.0
observerdrop-test
validated imageghcr.io/home-assistant/home-assistant@sha256:f73512ba4fe0…
validated date2026-07-16
confidencehigh
validated viadrop-test, ci-smoke
workloadprofiles/workloads/home-assistant.sh
workload sha256ec8142869c48…
ig versionv0.51.0

Drop-test evidence

Each candidate removed in turn, the container restarted, and the workload re-verified.

RemovedVerdictObserved
/run:execrequiredcontainer failed to start: s6-overlay-suexec: fatal: child failed with exit code 111
/tmpremovablecorrect: 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.

envTZ
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

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"}