The Home Assistant instance I am rebuilding on a small Hetzner VM had been running for weeks, by then with real house logic on it, when I opened its backup settings during the migration and found zero backups. I have written before about what belongs in a backup of the whole host and about rebuilding after a loss. Those two posts cover what to back up and how to restore it. This post covers copying the Home Assistant backup to S3-compatible object storage outside AWS, and getting a notification when the latest local backup or the last completed run of the copy job is more than 30 hours old.
What was missing
The backup settings said automatic_backups_configured: false and listed no backups. The only thing that was saved was the whole VM, through Hetzner snapshots. My own setup notes for this VM already listed a config backup on Home Assistant level as open, with the remark that the VM snapshot does not replace it. I then moved real logic onto the instance without closing that item. I only found the gap because the migration made me read the settings page.
Object storage was already at hand. On the same day a media archive I run on the same machine had moved from Azure Blob Storage to Hetzner Object Storage, because the old upload logged in through the managed identity of an Azure VM, and a Hetzner VM has none. The bucket sits in the same project and at the same location as the VM. I created the bucket and the access keys in the Hetzner console. The hcloud CLI manages Storage Boxes and not Object Storage, and I did not script this step with an S3 tool.
Why the built-in aws_s3 integration rejects other providers
Home Assistant 2026.8 ships an integration called aws_s3 as a backup location, and its setup form has an endpoint URL field that can be filled in freely. That looked like the obvious solution. The config flow of the integration contains this check, quoted from its source: if not hostname.endswith("amazonaws.com"), the form returns invalid_endpoint_url. The field exists, and the validation behind it excludes every other S3-compatible provider.
So I went one level lower and split the job in two. Home Assistant writes its backup to the local disk, which it can do without any backup location integration, and a small script of my own handles the transport to the bucket.
The setup: a local encrypted backup and a separate copy job
Home Assistant now creates an encrypted automatic backup every day at 03:30 and keeps 7 of them locally. At 04:40 a systemd timer on the host starts the copy script inside the Home Assistant container. Running it there has a practical reason: boto3 is already part of the official Home Assistant container image, so the script needs no additional dependency and no second Python environment on the host.
# ha-backup-s3.timer
# runs after the automatic Home Assistant backup at 03:30
[Timer]
OnCalendar=*-*-* 04:40:00
Persistent=true
Unit=ha-backup-s3.service
# ha-backup-s3.service
# the script runs inside the HA container, boto3 is already there
[Service]
Type=oneshot
ExecStart=/usr/bin/docker exec homeassistant python /config/tools/ha_backup_s3.pyThe script itself deletes nothing in the cloud. Retention is a lifecycle rule on the bucket that removes objects after 180 days, so Hetzner does the deleting, for the Home Assistant backups and for that archive alike. Locally Home Assistant cleans up on its own and keeps its 7. The access keys are in the password manager and in a secrets file on the VM, and the script reads them at run time. How the container gets at that file and how the S3 client is configured is not part of the excerpt below.
How the copy is verified
The check is the one that archive job uses before it is allowed to delete a file locally, and I took it over unchanged. After the upload the script reads the object's metadata back and compares the size and the ETag with the local file. On Hetzner the ETag returned for a single PUT is the MD5 of the object. Other providers or encryption settings can behave differently, so check that before reusing the script. The script also checks that the SHA-256 it supplied during the upload is stored in the metadata. That last check confirms the metadata, and the stored content is only hashed again in the restore test further down. A backup that passed gets a receipt, and a file with a receipt is skipped on the next run.
# core of the copy job: upload, read back, compare, then write the receipt
for path in sorted(BACKUPS.glob("*.tar")):
stat = path.stat()
if receipts.get(path.name, {}).get("size") == stat.st_size:
continue # already copied and verified
md5, sha = digests(path) # one pass over the file, both hashes
key = f"ha-backups/{path.name}"
with path.open("rb") as body:
s3.put_object(Bucket=BUCKET, Key=key, Body=body,
ContentMD5=base64.b64encode(md5).decode(),
Metadata={"sha256": sha})
head = s3.head_object(Bucket=BUCKET, Key=key)
if (head["ContentLength"] != stat.st_size # size
or head["ETag"].strip('"') != md5.hex() # ETag = MD5 for a single PUT
or head["Metadata"].get("sha256") != sha): # SHA-256 from the object metadata
raise RuntimeError(f"verification failed for {key}")
receipts[path.name] = {"key": key, "size": stat.st_size, "sha256": sha}
write_json(RECEIPTS, receipts)The archive job, which uses the same bucket, also checks before every run that an anonymous request is rejected with a 403. The first Home Assistant backup was 163 MB and its upload took 2 seconds with the VM and the bucket in the same location.
Store the encryption key before setting it
I first saved the encryption key in the password manager and read it back from there, and only then set it in Home Assistant. With the opposite order there is a period in which Home Assistant already encrypts backups with a key that exists in exactly one place.
After that I checked whether the backup can be opened at all. Home Assistant has a websocket command for this, backup/can_decrypt_on_download. With the key from the password manager it answered OK, and with a deliberately wrong key it rejected the request. This test confirms that the key fits the backup. Eight days later I ran a restore test against the copy in the object storage. A script downloads the newest backup, compares its size and SHA-256 with the storage metadata, and decrypts and unpacks it with the key from the password manager. The backup had grown to 2.15 GB by then. The download took 9 seconds and unpacking 3,012 files took 61 seconds. All 32 files under .storage parsed as JSON, 23 of 23 integrations and 16 of 16 package files matched the running instance, and the SQLite quick check of the database passed. I did not start a fresh Home Assistant from that backup, so the last step of a real restore is still untested.
Monitoring the backup and the copy
The timer runs on the host and starts the script in the container, so neither is part of Home Assistant itself. If the timer stops firing or the script fails every night, nothing inside Home Assistant changes, and the local backups keep appearing. So the copy job writes a small status file with the time of its last completed run, a command_line sensor reads that file, and a binary sensor collects the reasons why something is wrong: Home Assistant backup older than 30 hours, S3 copy older than 30 hours, plus the corresponding checks for the archive job. When the sensor has been on for 15 minutes, an automation sends a push notification with the list of reasons. That automation is not in the YAML below. The status file records when the copy job last completed, so a run that finds nothing new counts as well. The age of the newest Home Assistant backup is checked separately by the first condition.
# the copy job writes a status file, Home Assistant reads it
command_line:
- sensor:
name: "Backup S3 status"
command: "cat /config/backups/.s3-status.json"
value_template: "{{ value_json.last_run_utc }}"
device_class: timestamp
scan_interval: 600
template:
# trigger-based: the reasons are computed once and used for state and attribute
- triggers:
- trigger: time_pattern
minutes: "/5"
- trigger: state
entity_id:
- sensor.backup_s3_status
- sensor.backup_last_successful_automatic_backup
variables:
reasons: >-
{% set r = namespace(l=[]) %}
{% set b = states('sensor.backup_last_successful_automatic_backup') | as_datetime %}
{% if b is none or (now() - b).total_seconds() > 30 * 3600 %}
{% set r.l = r.l + ['HA backup older than 30 h'] %}
{% endif %}
{% set s = states('sensor.backup_s3_status') | as_datetime %}
{% if s is none or (now() - s).total_seconds() > 30 * 3600 %}
{% set r.l = r.l + ['S3 copy older than 30 h'] %}
{% endif %}
{{ r.l }}
binary_sensor:
- name: "Backup problem"
device_class: problem
state: "{{ reasons | count > 0 }}"
attributes:
reasons: "{{ reasons }}"I tested it with a simulated clock, by evaluating the template through the template API with now() + timedelta(days=3) in place of now(). All age checks fired. The test also found a hole in one of the archive checks: a source that had never archived anything on the new instance had no last success, so its 72-hour check never applied. That is the kind of silent failure the sensor is meant to report. It now counts from the starting point of the archive. My first draft of the sensor had a second flaw. Its state read its own previous attribute through this.attributes and was always one update behind. The version above is trigger-based and computes the list once in a variable.
What went wrong so far
The largest failure so far came on 8 October, when the sensor reported that the S3 copy was older than 30 hours. The media archive was stored under /config, and in a container installation folders under /config cannot be excluded from a Home Assistant backup. The archive had grown to 4.2 GB, the backup had gone from 1.2 GB on 2 October to 4.5 GB, and the 38 GB disk was full. The copy script stopped at that file because it only uploads up to 4 GB with a single PUT, so newer backups were not copied either. I moved the archive to its own volume outside /config, and the next backup was 68 MB. The script now skips a file that is too large, copies the rest and reports it, a size check that is not in the Python excerpt above, and a new sensor warns when less than 6 GB is free. Both new checks feed the same problem sensor as reasons of their own, and neither is in the YAML excerpt above. The restore test described above ran before this change, on the larger backup.
On the archive job the first upload of a run was rejected twice with AccessDenied. The same file went through on the next run, and three further runs showed no error, so I could not reproduce it and I do not know the cause. The protection worked as intended: the file stayed on the local disk and was uploaded an hour later. The uploader now makes short retries on PUT and HEAD and logs the error code.
Before the first core update on the new instance I also triggered a fresh encrypted backup with the service backup.create_automatic and only then changed the image tag. The update and rollback procedure for the container has its own post.
Frequently asked questions
Can the built-in aws_s3 backup integration be used with a non-AWS S3 provider?
Not on Home Assistant 2026.8, the release I was running. The config flow rejects every endpoint whose hostname does not end in amazonaws.com.
How long are the backups kept, and what deletes them?
Home Assistant keeps the latest 7 automatic backups locally and removes older ones itself. In the bucket a lifecycle rule deletes the copies after 180 days.
How can I check that an encrypted backup opens without restoring it?
The websocket command backup/can_decrypt_on_download checks whether the key matches the backup. I also downloaded, decrypted and unpacked a copy to check its contents, but have not tested starting a new instance from it.



