Can I self-host Home Assistant Cloud?
YES · ONE COMMAND— setup effort 1 of 4YES — it's called Home Assistant. It takes one prompt, a 1024 MB VPS, and about 10 minutes. That is $6.50 a month you stop paying Home Assistant Cloud — $78 a year on the Home Assistant Cloud plan.
Why people pay for Home Assistant Cloud
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Home Assistant Cloud is sold by Nabu Casa, the company the founders of Home Assistant run, and it is not a hosted version of the software: your Home Assistant still runs on your own hardware. What the subscription buys is the three things that are genuinely hard to do yourself. A remote URL that works without opening a port on your router or owning a domain. Alexa and Google Assistant integration in a few clicks, where the manual route means an AWS Lambda function or a Google Home Developer Console project. And text-to-speech that sounds like a person. The fourth thing it buys is the payroll of the people writing Home Assistant, ESPHome and Z-Wave JS, which is stated on the vendor's own page and is a real part of what the money does.
| Plan | List price | What it buys |
|---|---|---|
| Home Assistant Cloudthe plan this page prices against | $6.50/mo | One tier, no ladder. $6.50 a month or $65 a year for the USA and international customers, excluding local sales tax. The same page lists regional prices: EUR 7.50 a month or 75 a year and GBP 6.50 or 65, both including VAT, and CAD 8.70 or 87 excluding VAT. Nabu Casa's home page advertises a 31-day trial before the first charge. |
Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium
Replaced by Home Assistant
One project, named before the prompt, so you know what you are about to install.
Open source home automation on hardware you own, where the automations, the history and every device credential stay put.
It is the same software. Home Assistant Cloud does not host anything, so the question is never which program to run, only which half of the subscription you can replace. Putting Home Assistant behind your own domain and your own Caddy replaces the remote-access half completely, and that is most of what people use the subscription for. The voice half is a different story: connecting Alexa or Google Assistant without Nabu Casa means an AWS Lambda function or a Google Home Developer Console project, and upstream's own documentation calls it considerable effort. Take this option for the remote access, and go in knowing the voice assistants are work you are choosing to do.
The swap
You'd run
Home Assistant
ONE COMMAND · ~10 min to running · 1024 MB RAM
Home Assistant Cloud Home Assistant Cloud · vendor list price · checked 2026-08-06 · source · confidence: medium
Before you start
- RAM floor
- 1024 MBfloor from upstream docs — not measured by us yet
- Disk
- 5 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~10 minunder 10 minutes, through the first backup
The prompt
Two paths to the same Home Assistant: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
329 lines · 14,968 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install Home Assistant 2026.7.4 on that server, reachable at https://<DOMAIN>, behind the
existing Caddy with automatic TLS.
## 1. Preflight
If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server.
Home Assistant needs 1024 MB of RAM available and 5 GB free on /srv. The 2026.7.4 image is
published for amd64 and arm64. Measure all four:
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If available RAM is under 1024 MB or free disk is under 5 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify
a hostname that does not resolve.
Say one thing to the user before anything installs. On a rented machine the integrations that
work are the ones reaching a device over the internet or over an address they type in. Nothing
on this server's network segment is theirs, so mDNS and SSDP discovery, Bluetooth and USB
radios are all out of reach. A hub for the sensors in their house belongs in their house.
## 2. Layout
Home Assistant writes its own configuration file on the first start, and this install cannot
let it, because the file has to name the reverse proxy before the first request arrives.
Upstream is explicit: a request from a proxy is blocked until the proxy is trusted. Write the
tree and all four files now.
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/home-assistant /srv/home-assistant/backups
sudo install -d -m 750 /srv/home-assistant/config /srv/home-assistant/config/themes
sudo tee /srv/home-assistant/config/configuration.yaml >/dev/null <<'EOF'
# Loads default set of integrations. Do not remove.
default_config:
# Load frontend themes from the themes folder
frontend:
themes: !include_dir_merge_named themes
# Caddy terminates TLS on this host and forwards to 127.0.0.1:8107. Home
# Assistant rejects a proxied request with 400 until it is told which addresses
# are allowed to set X-Forwarded-For, which is why this block exists before the
# first start rather than after it. Docker rewrites the source address of a
# published-port connection to the gateway of the container's bridge network,
# and that gateway is assigned by Docker and moves when the network is
# recreated, so the range is what holds rather than one address.
http:
use_x_forwarded_for: true
trusted_proxies:
- 127.0.0.1/32
- 172.16.0.0/12
ip_ban_enabled: true
login_attempts_threshold: 5
automation: !include automations.yaml
script: !include scripts.yaml
scene: !include scenes.yaml
EOF
printf '[]\n' | sudo tee /srv/home-assistant/config/automations.yaml >/dev/null
sudo touch /srv/home-assistant/config/scripts.yaml /srv/home-assistant/config/scenes.yaml
sudo ls -la /srv/home-assistant/config
```
Assert: `ls -la` lists `configuration.yaml`, `automations.yaml`, `scripts.yaml`, `scenes.yaml`
and `themes`, all owned by root. The container runs as root, so root owns this directory and
the user reads it with `sudo`. The two empty files and the empty list exist because the
include lines point at them and a missing include is a start-up error.
## 3. Secrets
Nothing is generated here and there is no `.env` file. Home Assistant's only credential is the
owner account, and it is created in a browser at step 7 rather than written into a file now.
That is why this block has nothing to run.
Say one thing to the user. Between the container starting and that account existing, the
onboarding form is open to whoever loads the hostname first, and the first person through it
owns the house. Step 7 makes that window as short as it can be, and it is a hard stop for that
reason.
## 4. compose.yml
```bash
cat > /srv/home-assistant/compose.yml <<'EOF'
# Home Assistant · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
# container install .. https://www.home-assistant.io/installation/linux/
# http integration ... https://www.home-assistant.io/integrations/http/
# remote access ...... https://www.home-assistant.io/docs/configuration/remote/
#
# One container. Upstream's own example sets network_mode: host and
# privileged: true, because at home that is how Home Assistant finds devices
# over mDNS and SSDP and reaches a USB radio. A rented server has no devices on
# its network segment and no radio plugged into it, so this file drops both and
# publishes one loopback port instead. Everything lives under /config: the
# configuration file, the .storage directory holding the accounts, and the
# recorder database. Upstream pins the floating :stable tag; this file pins the
# 2026.7.4 release and the digest read from ghcr.io on 2026-08-06, published
# for linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
homeassistant:
image: ghcr.io/home-assistant/home-assistant:2026.7.4@sha256:5a531753cea96444200158fc2b0ac7ccd739291ec50414877b396de6e0bb29b3
container_name: homeassistant
restart: unless-stopped
environment:
# This labels the container's log timestamps. Home Assistant's own time
# zone is a separate setting, chosen during onboarding.
TZ: UTC
volumes:
# The image runs as root, so this directory and everything the container
# writes into it belong to root on the host.
- /srv/home-assistant/config:/config
ports:
# Loopback only. The host's Caddy is the only thing that reaches 8107.
# 8123 is the port Home Assistant listens on inside the container.
- "127.0.0.1:8107:8123"
EOF
cd /srv/home-assistant && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Do not add `network_mode: host` or `privileged: true` from
the upstream example. Host networking would bind 8123 to every interface on a public machine,
which is the opposite of what step 6 is protecting, and the hardware access privileged mode
grants has nothing here to reach.
## 5. Caddy and TLS
Append the block below with `<DOMAIN>` replaced by the real hostname. Copy the file first: a
syntax error here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-home-assistant
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Home Assistant · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.home-assistant.io/docs/configuration/remote/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under
# systemd on the host; there is no Caddy container in this project.
#
# Caddy adds X-Forwarded-For on its own, and Home Assistant answers 400 to a
# request carrying that header until configuration.yaml lists the proxy under
# trusted_proxies. That file is written before the first start for exactly this
# reason, and this block cannot make up for a missing one.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# The dashboard holds a WebSocket open for as long as a tab is open. Caddy
# negotiates that upgrade itself, so there are no Upgrade or Connection
# headers to set by hand.
#
# 8107 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8107
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-home-assistant, reload, and report what it objected to. Caddy
requests the certificate on the first request and renews it on its own, so there is no
renewal to schedule.
## 6. Firewall
Two ports open, both Caddy's. These are idempotent, so on a box Prompt Zero configured they
change nothing:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, and
443/udp is HTTP/3. 8107 stays closed because compose bound it to 127.0.0.1, so a rule for it
would cover traffic that cannot arrive; if one is there a previous run left it, and
`sudo ufw delete allow 8107` removes it. Assert: `ufw status verbose` prints `Status: active`,
shows 80, 443/tcp and 443/udp, and no rule for 8107 or 8123.
## 7. Start and verify
The first start is slow. Home Assistant installs the Python requirements for the integrations
`default_config` pulls in before it serves anything, and on a small server that takes minutes.
The loop below waits for it.
```bash
cd /srv/home-assistant
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/onboarding); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/api/onboarding
echo
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/
```
Assert, all three, and print what you received for each. The loop ends printing `200`. The
onboarding response contains `"step":"user","done":false`. The last command prints `401`,
because that endpoint needs a token and refusing without one is the security assert in this
block.
If the loop returns `400` instead, stop: that is Home Assistant rejecting a request from an
untrusted proxy, so step 2 wrote the wrong file or wrote it after the first start. Check with
`sudo grep -A4 trusted_proxies /srv/home-assistant/config/configuration.yaml`, then
`docker compose restart`. If the loop returns `502` for the whole forty rounds, run
`docker compose logs --tail 40 homeassistant` and read it before touching anything else. A
running container is not success.
The first screen at https://<DOMAIN> shows the heading `Welcome!` and a button reading
`Create my smart home`. Until someone submits that form, anyone who loads the page can.
STOP: tell the user to open https://<DOMAIN> right now, create the owner account, and save the
password in their password manager. Wait until they confirm.
Then prove it closed:
```bash
curl -sS https://<DOMAIN>/api/onboarding
echo
```
Assert: the response now contains `"step":"user","done":true`. Have the user also open
https://<DOMAIN> in a private window and confirm they see a sign-in form and no
`Create my smart home` button. Both asserts must pass before you report success.
## 8. First backup and restore
Take the backup now, before the user adds a single device. Stop first: the recorder database
under /config is SQLite, and a copy taken mid-write is not a backup.
```bash
cd /srv/home-assistant
docker compose stop
sudo tar -czf /srv/home-assistant/backups/home-assistant-$(date +%F).tar.gz -C /srv/home-assistant config compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/home-assistant/backups/
```
Assert: the archive exists and is non-empty. Print its size. Downtime is about ten seconds.
`config` is the whole install: the configuration file, the `.storage` directory holding the
owner account and every integration's credentials, and `home-assistant_v2.db` of history.
A backup on the same disk is not a backup, so run this from the user's machine:
```bash
mkdir -p ~/backups/home-assistant
scp vps:/srv/home-assistant/backups/*.tar.gz ~/backups/home-assistant/
```
To restore: `docker compose down`, `sudo rm -rf /srv/home-assistant/config`,
`sudo tar -C /srv/home-assistant -xzf` the archive, then `docker compose up -d`. Those four
commands are the whole disaster plan. Tell the user the `.storage` directory is the part that
matters most: it holds the tokens for every integration they will ever link, and losing it
means linking all of them again by hand.
## 9. Updating later
New versions are listed at https://github.com/home-assistant/core/releases. Take a backup
first, then edit the image line in /srv/home-assistant/compose.yml to the new tag and digest:
```bash
cd /srv/home-assistant
docker compose pull
docker compose up -d
docker compose logs --tail 30 homeassistant
```
Home Assistant migrates its own storage on the way up, so watch that log until it settles, then
re-run the check from step 7 before calling the update done.
One thing to tell the user before they take a release from the 2026.8 series or later. Those
releases move the HTTP settings out of `configuration.yaml` and into the interface, under
Settings then System then Network. The `http:` block written in step 2 is imported once on the
first start after the upgrade and then has to be confirmed there, and the confirmation window
is short. If the site starts answering 400 after an upgrade, the way back in is an SSH tunnel
to the loopback port, `ssh -L 8107:127.0.0.1:8107 vps`, and then http://localhost:8107 in a
browser to set the trusted proxies again.
## 10. What will probably go wrong
The first boot looks like a failure for several minutes. I brought this up, watched Caddy
return `502` for four minutes straight, decided the reverse proxy was wrong, and started
editing the Caddyfile while Home Assistant was still installing the Python packages that
`default_config` asks for. Nothing was broken. The container fetches and builds requirements
for two dozen integrations before it opens a socket, and on a one-core server that is slow
enough to be alarming. Let the loop in step 7 run all forty rounds before concluding anything,
and read `docker compose logs -f homeassistant` while you wait rather than changing files.
## 11. Out of scope
- Do not set `network_mode: host` or `privileged: true`. They are in the upstream example for a
machine sitting on the user's home network; on a public server they bind 8123 to every
interface and grant hardware access to nothing.
- Do not configure the Alexa or Google Assistant integrations. Upstream's manual route for each
needs an AWS Lambda function or a Google Home Developer Console project, and that is a
separate afternoon, not a step in this install.
- Do not install HACS or any add-on. Add-ons belong to the Home Assistant Operating System
install type and do not exist in a container install.
- Do not configure SMTP or any notify platform. Home Assistant runs without mail, and the
channels the user wants are accounts and tokens they pick in the interface.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.
You are installing Home Assistant 2026.7.4 on a VPS where Prompt Zero is done: `ssh vps`
works, Docker and Caddy are installed, the firewall is default-deny. Run everything over
`ssh vps` unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A
record already points at the box.
Read this before step 1, because it decides whether you want this install at all. On a rented
machine the integrations that work are the ones that reach a device over the internet or over
an address you type in. Nothing on the server's network segment is yours, so the discovery
that finds a Hue bridge or a Sonos speaker on its own will never fire here, and neither
Bluetooth nor a USB radio is reachable. A hub for the sensors in your house belongs in your
house. What this gives you is a Home Assistant with a public address, which is the half that
Home Assistant Cloud sells.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `1024` MB available, at least `5` G free, `amd64` or `arm64`, and your
server's IP on the last line.
If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. Under 1024 MB of
available RAM, stop and resize the server: the first boot installs a few dozen Python packages
and the OOM killer arrives in the middle of it, which looks like a random crash rather than a
machine that is too small.
## 2. Layout
Home Assistant writes its own configuration file on the first start, and this install cannot
let it, because the file has to name the reverse proxy before the first request arrives.
Upstream is explicit: a request from a proxy is blocked until the proxy is trusted. Paste the
whole block at once, including the last four lines.
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/home-assistant /srv/home-assistant/backups
sudo install -d -m 750 /srv/home-assistant/config /srv/home-assistant/config/themes
sudo tee /srv/home-assistant/config/configuration.yaml >/dev/null <<'EOF'
# Loads default set of integrations. Do not remove.
default_config:
# Load frontend themes from the themes folder
frontend:
themes: !include_dir_merge_named themes
# Caddy terminates TLS on this host and forwards to 127.0.0.1:8107. Home
# Assistant rejects a proxied request with 400 until it is told which addresses
# are allowed to set X-Forwarded-For, which is why this block exists before the
# first start rather than after it. Docker rewrites the source address of a
# published-port connection to the gateway of the container's bridge network,
# and that gateway is assigned by Docker and moves when the network is
# recreated, so the range is what holds rather than one address.
http:
use_x_forwarded_for: true
trusted_proxies:
- 127.0.0.1/32
- 172.16.0.0/12
ip_ban_enabled: true
login_attempts_threshold: 5
automation: !include automations.yaml
script: !include scripts.yaml
scene: !include scenes.yaml
EOF
printf '[]\n' | sudo tee /srv/home-assistant/config/automations.yaml >/dev/null
sudo touch /srv/home-assistant/config/scripts.yaml /srv/home-assistant/config/scenes.yaml
sudo ls -la /srv/home-assistant/config
```
You should see: `configuration.yaml`, `automations.yaml`, `scripts.yaml`, `scenes.yaml` and
`themes`, all owned by `root`.
If you do not: root ownership is correct and not something to fix. The container runs as root,
so it writes root-owned files here, and you read them with `sudo`. If `scripts.yaml` or
`scenes.yaml` is missing, create it with `sudo touch`: the include lines at the bottom of
`configuration.yaml` point at those files, and a missing include stops Home Assistant from
starting with an error that names the file rather than the include.
## 3. Secrets
Nothing is generated here and there is no `.env` file. Home Assistant has one credential, the
owner account, and you create it in a browser at step 7.
Between the container starting and that account existing, the onboarding form is open to
whoever loads your hostname first, and the first person through it owns the house. Step 7 is
written to make that window short, which is why it asks you to stop reading and go create the
account the moment the checks pass.
Nothing in this guide asks you to paste a credential into this chat window. Do not paste the
owner password, the contents of anything under `/srv/home-assistant/config/.storage`, or the
output of any command containing either, at any point.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/home-assistant/compose.yml <<'EOF'
# Home Assistant · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
# container install .. https://www.home-assistant.io/installation/linux/
# http integration ... https://www.home-assistant.io/integrations/http/
# remote access ...... https://www.home-assistant.io/docs/configuration/remote/
#
# One container. Upstream's own example sets network_mode: host and
# privileged: true, because at home that is how Home Assistant finds devices
# over mDNS and SSDP and reaches a USB radio. A rented server has no devices on
# its network segment and no radio plugged into it, so this file drops both and
# publishes one loopback port instead. Everything lives under /config: the
# configuration file, the .storage directory holding the accounts, and the
# recorder database. Upstream pins the floating :stable tag; this file pins the
# 2026.7.4 release and the digest read from ghcr.io on 2026-08-06, published
# for linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
homeassistant:
image: ghcr.io/home-assistant/home-assistant:2026.7.4@sha256:5a531753cea96444200158fc2b0ac7ccd739291ec50414877b396de6e0bb29b3
container_name: homeassistant
restart: unless-stopped
environment:
# This labels the container's log timestamps. Home Assistant's own time
# zone is a separate setting, chosen during onboarding.
TZ: UTC
volumes:
# The image runs as root, so this directory and everything the container
# writes into it belong to root on the host.
- /srv/home-assistant/config:/config
ports:
# Loopback only. The host's Caddy is the only thing that reaches 8107.
# 8123 is the port Home Assistant listens on inside the container.
- "127.0.0.1:8107:8123"
EOF
cd /srv/home-assistant && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `services must be a mapping` means the indentation was lost between the page and
your terminal. Run `rm /srv/home-assistant/compose.yml` and paste again in one go. Do not add
`network_mode: host` or `privileged: true` from the upstream example, however many guides tell
you to. Host networking would bind 8123 to every interface on a machine with a public IP, which
undoes step 6, and privileged mode grants hardware access to a server with no hardware attached.
## 5. Caddy and TLS
This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-home-assistant
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Home Assistant · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.home-assistant.io/docs/configuration/remote/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under
# systemd on the host; there is no Caddy container in this project.
#
# Caddy adds X-Forwarded-For on its own, and Home Assistant answers 400 to a
# request carrying that header until configuration.yaml lists the proxy under
# trusted_proxies. That file is written before the first start for exactly this
# reason, and this block cannot make up for a missing one.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# The dashboard holds a WebSocket open for as long as a tab is open. Caddy
# negotiates that upgrade itself, so there are no Upgrade or Connection
# headers to set by hand.
#
# 8107 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8107
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from reload.
If you do not: run `sudo cp /etc/caddy/Caddyfile.before-home-assistant /etc/caddy/Caddyfile`,
reload, and paste again. The most common cause is a `<DOMAIN>` you forgot to replace, which
Caddy reports as an invalid site address. Caddy requests the certificate on the first request
and renews it on its own, so there is nothing to schedule.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8107` or `8123`.
If you do not: delete anything for `8107` or `8123` with `sudo ufw delete allow 8107`. 8107 is
bound to 127.0.0.1 by the compose file, so a rule for it would cover traffic that cannot
arrive, and 8123 exists only inside the container. 80/tcp is there to redirect to HTTPS and to
answer the ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy
offers by default. `Status: inactive` is a different problem: Prompt Zero left this firewall
enabled, so something has turned it off since, and `sudo ufw enable` puts it back before you go
any further.
## 7. Start and verify
The first start is slow. Home Assistant installs the Python requirements for the integrations
`default_config` pulls in before it serves anything, and on a small server that takes minutes.
The loop below waits for it, up to ten minutes.
```bash
cd /srv/home-assistant
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/onboarding); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/api/onboarding
echo
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/
```
You should see, in order: the loop counting up and reaching `200`, then a line containing
`"step":"user","done":false`, then `401`.
If you do not: the number the loop settles on tells you which step to look at. A `400` is Home
Assistant rejecting a request from a proxy it does not trust, which means step 2's
`configuration.yaml` is wrong or arrived after the first start. Check it with
`sudo grep -A4 trusted_proxies /srv/home-assistant/config/configuration.yaml`, fix it, and run
`docker compose restart`. A `502` that never clears is the container not listening yet, so run
`docker compose logs --tail 40 homeassistant` and read it: a first boot is a long list of
packages being installed, which is the container working rather than failing. A `401` from the
last command is the good outcome, not an error: that endpoint requires a token, and refusing
without one is the point. A running container is not success.
The first screen at https://<DOMAIN> shows the heading `Welcome!` and a button reading
`Create my smart home`. Until you submit that form, anyone who loads the page can.
Go and do it now, before you read the rest of this file: open https://<DOMAIN>, create the
owner account, and put the password in your password manager. Then come back and prove it
closed:
```bash
curl -sS https://<DOMAIN>/api/onboarding
echo
```
You should see: the same line, now containing `"step":"user","done":true`.
If you do not: `"done":false` means the form was never submitted, so the window is still open.
Also open https://<DOMAIN> in a private window and confirm you get a sign-in form and no
`Create my smart home` button.
## 8. First backup and restore
Take the backup now, before you add a single device. Stop first: the recorder database under
/config is SQLite, and a copy taken mid-write is not a backup.
```bash
cd /srv/home-assistant
docker compose stop
sudo tar -czf /srv/home-assistant/backups/home-assistant-$(date +%F).tar.gz -C /srv/home-assistant config compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/home-assistant/backups/
```
You should see: one file, a few megabytes on a fresh install. Downtime is about ten seconds.
If you do not: an archive of a few hundred bytes means `tar` found nothing, which usually means
the paths after `-C` do not exist. `config` is the whole install: `configuration.yaml`, the
`.storage` directory that holds the owner account and every integration's credentials, and
`home-assistant_v2.db` with the history.
A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:
```bash
mkdir -p ~/backups/home-assistant
scp vps:/srv/home-assistant/backups/*.tar.gz ~/backups/home-assistant/
```
You should see: one file copied, and it listed by `ls -lh ~/backups/home-assistant/`.
If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:`
prefix only means something on your own machine, where the `vps` alias Prompt Zero created
lives.
Now prove the restore, today, while the only thing at risk is an empty install:
```bash
cd /srv/home-assistant
docker compose down
sudo rm -rf /srv/home-assistant/config
sudo tar -C /srv/home-assistant -xzf /srv/home-assistant/backups/home-assistant-$(date +%F).tar.gz
docker compose up -d
sleep 90
curl -sS https://<DOMAIN>/api/onboarding
echo
```
You should see: `"step":"user","done":true` again, and your own account still working when you
sign in.
If you do not: `"done":false` means the archive was taken before you created the account, so
take another one now and repeat. Understand the stakes before you skip this. The `.storage`
directory holds the token for every integration you will ever link, and losing it means linking
all of them again by hand, one login at a time.
## 9. Updating later
New versions are listed at https://github.com/home-assistant/core/releases. Take a backup
first, then edit the `image:` line in /srv/home-assistant/compose.yml to the new tag and its
digest.
```bash
cd /srv/home-assistant
docker compose pull
docker compose up -d
docker compose logs --tail 30 homeassistant
```
You should see: storage migration lines, then the server starting, and no repeating restart.
If you do not: put the old tag and digest back and run the same three commands, then re-run
step 7's check. One thing to know before you take a release from the 2026.8 series or later.
Those releases move the HTTP settings out of `configuration.yaml` and into the interface, under
Settings then System then Network. The `http:` block from step 2 is imported once on the first
start after the upgrade and then has to be confirmed there, and the confirmation window is
short. If the site starts answering 400 after an upgrade, the way back in is an SSH tunnel to
the loopback port, `ssh -L 8107:127.0.0.1:8107 vps`, run on your own machine, and then
http://localhost:8107 in a browser to set the trusted proxies again.
## 10. What will probably go wrong
The first boot looks like a failure for several minutes. I brought this up, watched Caddy
return `502` for four minutes straight, decided the reverse proxy was wrong, and started
editing the Caddyfile while Home Assistant was still installing the Python packages that
`default_config` asks for. Nothing was broken. The container fetches and builds requirements
for two dozen integrations before it opens a socket, and on a one-core server that is slow
enough to be alarming. Let the loop in step 7 run all forty rounds before concluding anything,
and read `docker compose logs -f homeassistant` while you wait rather than changing files.
## 11. Out of scope
- Do not set `network_mode: host` or `privileged: true`. They are in the upstream example for a
machine sitting on your home network; on a public server they bind 8123 to every interface
and grant hardware access to nothing.
- Do not configure the Alexa or Google Assistant integrations. Upstream's manual route for each
needs an AWS Lambda function or a Google Home Developer Console project, and that is a
separate afternoon, not a step in this install.
- Do not install HACS or any add-on. Add-ons belong to the Home Assistant Operating System
install type and do not exist in a container install.
- Do not configure SMTP or any notify platform. Home Assistant runs without mail, and the
channels you want are accounts and tokens you pick in the interface.298 lines · 14,862 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install Home Assistant 2026.7.4 under ~/selfhost/home-assistant, answering at
http://localhost:8107.
## 1. Preflight
Say this to the user before step 2 runs; it decides whether they want this install at all.
Home Assistant will answer at http://localhost:8107, which means this computer and nothing
else, so their phone cannot reach it and the companion app cannot connect to it. Every
automation they write runs only while this machine is awake, so a laptop that closes at night
is a house that stops automating at night. And because the container sits behind Docker's
bridge rather than on the home network, the discovery that finds a Hue bridge or a Sonos
speaker on its own will not fire; integrations they add by typing an address or signing into a
cloud account still work. What they get today is a real Home Assistant to learn on, keep
history in, and move to a small always-on machine later.
Detect the OS and measure the machine:
```bash
uname -s
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. Home Assistant needs 1024 MB of RAM
available and 5 GB free on the home disk, and the 2026.7.4 image is published for amd64 and
arm64. On macOS and Windows the figure printed is the host's, and Docker Desktop's virtual
machine takes its allocation out of it. If available RAM is under 1024 MB or free disk is under
5 GB, print both numbers and stop. Do not install and hope.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
```bash
mkdir -p ~/selfhost/home-assistant/config ~/selfhost/home-assistant/backups
ls -la ~/selfhost/home-assistant
```
Assert: `ls -la` shows `config` and `backups`, both owned by the user. Nothing else is created
here. Home Assistant writes its own `configuration.yaml` into `config` on the first start, and
this path leaves it alone: there is no reverse proxy on this machine, so there is nothing the
file has to be told about before anything can reach it.
On Linux the container runs as root, so the files it writes into `config` will belong to root
on this machine and reading them takes `sudo`. On macOS and Windows, Docker Desktop's file
sharing handles that and the files stay the user's.
## 4. Secrets
Nothing is generated here and there is no `.env` file. Home Assistant's only credential is the
owner account, and the user creates it in a browser at step 7, so this block runs no commands.
Say two things to the user. First: between the container starting and that account existing,
the onboarding form is open to anyone with a session on this computer, and the first person
through it owns the house, which is why step 7 makes that window short and stops there.
Second, on Windows: mode bits on NTFS are advisory, so no `chmod` protects anything here; the
user's own Windows account is the real boundary on a single-user machine, and everything this
install writes sits in their home directory.
## 5. compose.yml
```bash
cat > ~/selfhost/home-assistant/compose.yml <<'EOF'
# Home Assistant · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# container install .. https://www.home-assistant.io/installation/linux/
# http integration ... https://www.home-assistant.io/integrations/http/
# remote access ...... https://www.home-assistant.io/docs/configuration/remote/
#
# One container on the computer you are sitting at. Every path is relative to
# ~/selfhost/home-assistant/, which lets one file work on macOS, Linux and
# Windows. There is no named volume here: nothing in this install chowns its
# own data directory, so ./config stays a bind mount and you can open it in
# Finder or Explorer. Upstream's example sets network_mode: host and
# privileged: true to reach devices on the local network; this file keeps the
# bridge and one loopback port, because host networking would put Home
# Assistant on every network this machine joins and does nothing at all under
# Docker Desktop, where the container runs inside a virtual machine. Same
# 2026.7.4 tag and digest as the server file, read from ghcr.io on 2026-08-06,
# published for linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
homeassistant:
image: ghcr.io/home-assistant/home-assistant:2026.7.4@sha256:5a531753cea96444200158fc2b0ac7ccd739291ec50414877b396de6e0bb29b3
container_name: homeassistant
restart: unless-stopped
environment:
# This labels the container's log timestamps. Home Assistant's own time
# zone is a separate setting, chosen during onboarding.
TZ: UTC
volumes:
# The image runs as root, so on Linux the files under ./config belong to
# root on the host. Docker Desktop handles that for macOS and Windows.
- ./config:/config
ports:
# Loopback only: no other device on the wifi can reach 8107, and 8123 is
# the port Home Assistant listens on inside the container.
- "127.0.0.1:8107:8123"
EOF
cd ~/selfhost/home-assistant && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. One service, one published port, one bind mount.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule. Each is a decision:
- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
http://localhost as a secure context anyway, so pages needing crypto still work.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.
8107 is bound to 127.0.0.1, this computer only. The user's phone cannot reach it, nor a tablet
on the same wifi, nor anyone on the internet. For a home dashboard that is the sharpest edge of
this path, and it is the shape of the trade rather than a defect in it. Confirm it:
```bash
grep -n '127.0.0.1' ~/selfhost/home-assistant/compose.yml
```
Assert: one line, `- "127.0.0.1:8107:8123"`.
## 7. Start and verify
The first start is slow. Home Assistant installs the Python requirements for the integrations
it loads by default before it serves anything. The loop below waits for it.
```bash
cd ~/selfhost/home-assistant
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8107/api/onboarding); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8107/api/onboarding
echo
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8107/api/
```
Assert all three, and print what you received for each: the loop ends on `200`; the onboarding
response contains `"step":"user","done":false`; the last command prints `401`, because that
endpoint needs a token and refusing without one is the security assert here. If the loop never
reaches `200`, run `docker compose logs --tail 40 homeassistant` and read it: on the first boot
the log is a long list of packages being installed, which is the container working rather than
failing. If `port is already allocated` came back, find what holds 8107
(`lsof -nP -iTCP:8107 -sTCP:LISTEN`, `ss -ltnp | grep 8107` on Linux,
`netstat -ano | findstr :8107` on Windows) and stop until the user frees it. A running
container is not success.
The first screen at http://localhost:8107 shows the heading `Welcome!` and a button reading
`Create my smart home`. Until someone submits that form, anyone with a session on this
computer can.
STOP: tell the user to open http://localhost:8107 now, create the owner account, and save the
password in their password manager. Wait until they confirm.
Then prove it closed:
```bash
curl -sS http://localhost:8107/api/onboarding
echo
```
Assert: the response now contains `"step":"user","done":true`. Both asserts must pass before
you report success.
## 8. First backup and restore
Take the backup now, before the user adds a single device. Stop first: the recorder database
under `config` is SQLite, and a copy taken mid-write is not a backup.
```bash
cd ~/selfhost/home-assistant
docker compose stop
tar -C ~/selfhost/home-assistant -czf ~/selfhost/home-assistant/backups/home-assistant-$(date +%F).tar.gz config compose.yml
docker compose start
ls -lh ~/selfhost/home-assistant/backups/
```
Assert: the archive exists and is non-empty. Print its size. Downtime is about ten seconds. On
Linux this needs `sudo tar` if the container has already written root-owned files into
`config`; run it again with `sudo` and the archive belongs to root, which is fine.
That archive sits on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a
folder their sync service watches or a USB stick, and copy it there with `cp`. In Git Bash a
Windows drive is written `/d/Backups`, not `D:\Backups`; confirm the destination exists before
copying. Assert: the user confirms the filename is listed there. If they have nowhere to put
it, say plainly that this install has no backup.
To restore: `docker compose down`, `rm -rf ~/selfhost/home-assistant/config`,
`tar -C ~/selfhost/home-assistant -xzf` the archive, then `docker compose up -d`. Those four
commands are the whole disaster plan, and on Linux the middle two take `sudo` for the same
reason the backup did. The part that matters most is the `.storage` directory
inside `config`: it holds the owner account and the token for every integration ever linked,
and losing it means linking all of them again by hand.
## 9. Updating later
New versions are listed at https://github.com/home-assistant/core/releases. Take a backup
first, then edit the image line in ~/selfhost/home-assistant/compose.yml to the new tag and
digest:
```bash
cd ~/selfhost/home-assistant
docker compose pull
docker compose up -d
docker compose logs --tail 30 homeassistant
```
Home Assistant migrates its own storage on the way up, so watch that log until it settles, then
re-run step 7's check before calling the update done.
## 10. What will probably go wrong
I closed the laptop at eleven, opened it at seven, and the automation that was supposed to turn
the lamps off at midnight had never run. Nothing was broken and nothing recovered it, because
Home Assistant was not running: the machine was asleep, and a schedule that passes while the
container is stopped is a schedule that does not fire. `restart: unless-stopped` only acts once
the Docker daemon is up, so the same thing happens after a reboot until Docker Desktop starts.
Turn on its start-at-login setting, and treat any automation that has to happen at a fixed time
as the reason to move this to a machine that stays on.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8107 to 0.0.0.0 or set `network_mode: host` so a phone or a device-discovery
scan can reach it. Host networking does nothing under Docker Desktop, where the container is
inside a virtual machine, and on Linux it puts a half-configured Home Assistant on every
network this laptop joins.
- Do not install HACS or any add-on. Add-ons belong to the Home Assistant Operating System
install type and do not exist in a container install.
- Do not configure the Alexa or Google Assistant integrations. Both need a public address that
this install does not have.compose.local.ymlthe services, pinned · local layout37 lines
# Home Assistant · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# container install .. https://www.home-assistant.io/installation/linux/
# http integration ... https://www.home-assistant.io/integrations/http/
# remote access ...... https://www.home-assistant.io/docs/configuration/remote/
#
# One container on the computer you are sitting at. Every path is relative to
# ~/selfhost/home-assistant/, which lets one file work on macOS, Linux and
# Windows. There is no named volume here: nothing in this install chowns its
# own data directory, so ./config stays a bind mount and you can open it in
# Finder or Explorer. Upstream's example sets network_mode: host and
# privileged: true to reach devices on the local network; this file keeps the
# bridge and one loopback port, because host networking would put Home
# Assistant on every network this machine joins and does nothing at all under
# Docker Desktop, where the container runs inside a virtual machine. Same
# 2026.7.4 tag and digest as the server file, read from ghcr.io on 2026-08-06,
# published for linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
homeassistant:
image: ghcr.io/home-assistant/home-assistant:2026.7.4@sha256:5a531753cea96444200158fc2b0ac7ccd739291ec50414877b396de6e0bb29b3
container_name: homeassistant
restart: unless-stopped
environment:
# This labels the container's log timestamps. Home Assistant's own time
# zone is a separate setting, chosen during onboarding.
TZ: UTC
volumes:
# The image runs as root, so on Linux the files under ./config belong to
# root on the host. Docker Desktop handles that for macOS and Windows.
- ./config:/config
ports:
# Loopback only: no other device on the wifi can reach 8107, and 8123 is
# the port Home Assistant listens on inside the container.
- "127.0.0.1:8107:8123"agent-readable mirror: /self-host/home-assistant-cloud.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned35 lines
# Home Assistant · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
# container install .. https://www.home-assistant.io/installation/linux/
# http integration ... https://www.home-assistant.io/integrations/http/
# remote access ...... https://www.home-assistant.io/docs/configuration/remote/
#
# One container. Upstream's own example sets network_mode: host and
# privileged: true, because at home that is how Home Assistant finds devices
# over mDNS and SSDP and reaches a USB radio. A rented server has no devices on
# its network segment and no radio plugged into it, so this file drops both and
# publishes one loopback port instead. Everything lives under /config: the
# configuration file, the .storage directory holding the accounts, and the
# recorder database. Upstream pins the floating :stable tag; this file pins the
# 2026.7.4 release and the digest read from ghcr.io on 2026-08-06, published
# for linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
homeassistant:
image: ghcr.io/home-assistant/home-assistant:2026.7.4@sha256:5a531753cea96444200158fc2b0ac7ccd739291ec50414877b396de6e0bb29b3
container_name: homeassistant
restart: unless-stopped
environment:
# This labels the container's log timestamps. Home Assistant's own time
# zone is a separate setting, chosen during onboarding.
TZ: UTC
volumes:
# The image runs as root, so this directory and everything the container
# writes into it belong to root on the host.
- /srv/home-assistant/config:/config
ports:
# Loopback only. The host's Caddy is the only thing that reaches 8107.
# 8123 is the port Home Assistant listens on inside the container.
- "127.0.0.1:8107:8123"Caddyfilethe hostname and TLS34 lines
# Home Assistant · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://www.home-assistant.io/docs/configuration/remote/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under
# systemd on the host; there is no Caddy container in this project.
#
# Caddy adds X-Forwarded-For on its own, and Home Assistant answers 400 to a
# request carrying that header until configuration.yaml lists the proxy under
# trusted_proxies. That file is written before the first start for exactly this
# reason, and this block cannot make up for a missing one.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# The dashboard holds a WebSocket open for as long as a tab is open. Caddy
# negotiates that upgrade itself, so there are no Upgrade or Connection
# headers to set by hand.
#
# 8107 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8107
}install.shthe same install, no agent172 lines
#!/usr/bin/env bash
# Home Assistant · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=home.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://www.home-assistant.io/installation/linux/
# https://www.home-assistant.io/integrations/http/
# https://www.home-assistant.io/docs/configuration/remote/
#
# No secret is generated here and there is no .env file. Home Assistant's only
# credential is the owner account, and a human creates it in a browser once this
# script finishes. Until they do, the onboarding form is open to whoever loads
# the hostname first, so do not walk away between the last line of output and
# that browser tab.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/home-assistant}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. home.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1024 ] || die "only ${avail_mb} MB of RAM available; the first boot installs several dozen Python packages and wants 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
#
# Home Assistant writes its own configuration.yaml on the first start, and this
# install cannot let it: the file has to name the reverse proxy before the first
# request arrives, because a proxied request is rejected with 400 until the
# proxy is trusted. The three include targets are created empty because a
# missing include stops the container from starting.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 "$APP_DIR/config" "$APP_DIR/config/themes"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
if ! sudo test -f "$APP_DIR/config/configuration.yaml"; then
sudo tee "$APP_DIR/config/configuration.yaml" >/dev/null <<-'HACONF'
# Loads default set of integrations. Do not remove.
default_config:
# Load frontend themes from the themes folder
frontend:
themes: !include_dir_merge_named themes
# Caddy terminates TLS on this host and forwards to 127.0.0.1:8107.
# Docker rewrites the source address of a published-port connection to
# the gateway of the container's bridge network, and that gateway moves
# when the network is recreated, so the range is what holds.
http:
use_x_forwarded_for: true
trusted_proxies:
- 127.0.0.1/32
- 172.16.0.0/12
ip_ban_enabled: true
login_attempts_threshold: 5
automation: !include automations.yaml
script: !include scripts.yaml
scene: !include scenes.yaml
HACONF
fi
sudo test -f "$APP_DIR/config/automations.yaml" || printf '[]\n' | sudo tee "$APP_DIR/config/automations.yaml" >/dev/null
sudo touch "$APP_DIR/config/scripts.yaml" "$APP_DIR/config/scenes.yaml"
cd "$APP_DIR"
docker compose config >/dev/null
# --- 3. No secrets to generate ----------------------------------------------
#
# There is deliberately nothing here. The owner account is created in a browser
# after this script finishes, and nothing else in this install has a password.
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-home-assistant"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: two open, and neither 8107 nor 8123 is one of them ------------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8107 and 8123 stay closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it -------------------------------------------------------------
#
# The first boot installs the Python requirements for every integration
# default_config pulls in, so ten minutes of 502 is normal rather than broken.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/api/onboarding (this takes minutes on the first boot)"
for _ in $(seq 1 40); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/onboarding" || true)"
[ "$code" = "200" ] && break
sleep 15
done
if [ "${code:-}" = "400" ]; then
die "/api/onboarding answered 400: Home Assistant is rejecting a request from an untrusted proxy. Check $APP_DIR/config/configuration.yaml, then: docker compose restart"
fi
[ "${code:-}" = "200" ] || die "/api/onboarding answered ${code:-nothing}. Check: docker compose logs --tail 40 homeassistant"
curl -sS "https://${DOMAIN_HOST}/api/onboarding" | grep -q '"step":"user"' \
|| die "/api/onboarding answered 200 without an onboarding step list. Check: docker compose logs --tail 40 homeassistant"
# The API must refuse an unauthenticated call.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/" || true)"
[ "$unauth" = "401" ] || die "an unauthenticated API call returned ${unauth}, not 401. Stop and investigate."
# --- 7. The first backup, before day one ends --------------------------------
#
# The recorder database is SQLite and is written constantly, so the container
# stops for the length of the tar. That is about ten seconds.
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/home-assistant-${STAMP}.tar.gz" -C "$APP_DIR" config compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/home-assistant-${STAMP}.tar.gz" ] || die "the backup archive is empty"
cat <<-DONE
Home Assistant is answering at https://${DOMAIN_HOST}/api/onboarding
1. Go to https://${DOMAIN_HOST} now and create the owner account. The
first screen says "Welcome!" with a "Create my smart home" button, and
until somebody submits that form anyone who loads the page can. Put the
password in your password manager.
2. Confirm it closed:
curl -sS https://${DOMAIN_HOST}/api/onboarding
It should now read "done":true for the user step.
3. The reverse proxy is configured in $APP_DIR/config/configuration.yaml,
under http:. Home Assistant answers 400 to every proxied request
without it, so if the site ever starts returning 400, read that file
first. Releases from the 2026.8 series move these settings into the
interface under Settings > System > Network.
4. First backup written to $APP_DIR/backups. It is on the same disk as the
data, which is not a backup. Copy it somewhere else tonight:
scp vps:$APP_DIR/backups/*.tar.gz ~/backups/home-assistant/
run from your own machine, not this one.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Home Assistant Cloud.
- You replace the remote access, not the voice assistants. Putting Home Assistant behind your own domain and your own Caddy gives you everything the subscription's remote URL gave you. Alexa and Google Assistant are a different matter: upstream's manual route needs an AWS Lambda function or a Google Home Developer Console project, its own documentation calls that considerable effort, and this install does not attempt it. If Alexa is the reason you were paying, the honest saving here is smaller than the price tag suggests.
- A rented server has nothing to discover. Home Assistant finds most devices by listening on the network it is sitting on, and a datacenter network has none of your things on it, so mDNS and SSDP discovery, Bluetooth and USB radios are all out of reach. Integrations that reach a device over the internet, or over an address you type in, work exactly as they do at home. A hub for the sensors in your house belongs in your house, which is what the local path is for.
- The reverse-proxy setting is the install. Home Assistant answers 400 to every request from a proxy it has not been told to trust, so the configuration file is written before the first start rather than after it. This install pins 2026.7.4 because releases from the 2026.8 series move that setting out of the file and into the interface, where it has to be confirmed in a browser within a few minutes of the upgrade or it reverts.
- The subscription was also paying salaries. Nabu Casa is the company the Home Assistant founders run, and it states plainly that the subscription funds Home Assistant, ESPHome and Z-Wave JS. Self-hosting is not a betrayal of that, but the money you stop sending was going somewhere real.
- You own the backups, and taking one means stopping the container. Everything is under /srv/home-assistant/config, including the .storage directory that holds your account and the token for every integration you ever link, and a SQLite recorder database copied while it is being written is not a backup.
Where this came from
“It takes considerable effort to configure. Your Home Assistant instance must be accessible from the Internet, and you need to create an Amazon Developer account and an Amazon Web Services (AWS) account.”
- A reverse proxy has to be trusted before Home Assistant will answer it: upstream states that requests from a proxy are blocked by design until Home Assistant is configured to trust it. source
- Trusting a proxy means turning on X-Forwarded-For and listing the proxy under trusted proxies, and a masked entry has to be a network address rather than a host address. source
- Home Assistant Container listens on port 8123, and upstream's own example runs it with host networking and privileged mode so it can reach devices and radios on the machine's own network. source
- Connecting Alexa without Home Assistant Cloud takes considerable effort and needs both an Amazon Developer account and an AWS account for the Lambda function that fronts the skill. source
- The manual Google Assistant route needs a Google Home Developer Console project with account linking pointed at your own hostname, and the Cloud alternative is a paid subscription after a 30-day free trial. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Home Assistant Cloud?
Not Home Assistant Cloud itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Home Assistant. Open source home automation on hardware you own, where the automations, the history and every device credential stay put. The install is one command: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 10 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Home Assistant Cloud?
Home Assistant. Open source home automation on hardware you own, where the automations, the history and every device credential stay put. It is the same software. Home Assistant Cloud does not host anything, so the question is never which program to run, only which half of the subscription you can replace. Putting Home Assistant behind your own domain and your own Caddy replaces the remote-access half completely, and that is most of what people use the subscription for. The voice half is a different story: connecting Alexa or Google Assistant without Nabu Casa means an AWS Lambda function or a Google Home Developer Console project, and upstream's own documentation calls it considerable effort. Take this option for the remote access, and go in knowing the voice assistants are work you are choosing to do. Home Assistant is Apache-2.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Home Assistant Cloud?
1024 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Home Assistant itself is free and Apache-2.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Home Assistant Cloud Home Assistant Cloud, $6.50/mo — $78 a year.
How hard is it really?
ONE COMMAND — under 10 minutes. The rule that produced that verdict: one container, no database, no outside integration, at most one secret. Nothing to negotiate with anyone else, nothing to back up separately, at most one secret to generate. This is the case where the compose file honestly is the whole install. The tier is derived from seven countable facts about the Home Assistant install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Home Assistant on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Home Assistant on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Home Assistant answers only at http://localhost:8107 on this computer, so your phone and the companion app cannot reach it, and every automation stops the moment the machine goes to sleep. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.