My main Home Assistant dashboard — the default Overview — had grown to about 7,400 lines of JSON, roughly 232 KB, all of it living in a .storage file that no human is supposed to open. Every edit happened through the UI editor. Every mistake was unrecoverable except by re-editing. There was no diff, no history, no way to answer “what changed last Tuesday when the climate card broke.”
So I converted it to YAML mode and put it in git. The migration worked, and the dashboard is now something I can review, diff, and roll back like any other config. But three things about the process are documented either poorly or not at all, and one of them will break every custom card on every dashboard you have if you don’t know about it going in.
This is the full writeup: how storage dashboards are actually stored, how to get yours out, the two very different ways to run a YAML dashboard, and the traps.
Why bother — what YAML mode actually buys you
Storage mode is the default and it’s genuinely fine for most people. The UI editor is good these days. Here’s what pushed me over:
Git history. My dashboard config now has commit messages. When an update to a custom card breaks a view, I can git log -p ui-lovelace.yaml and see exactly what I changed and when. With storage mode, your only history is whatever backups happen to contain.
Reviewable changes. I stage dashboard edits the same way I stage automation edits: change the file, diff it, deploy it, reload. No more “edit live in the UI and hope.”
Bulk edits. Renaming an entity that appears in 40 cards is a sed one-liner in YAML. In the UI editor it’s 40 manual edits, or raw-config editing a 7,400-line JSON blob in a browser textarea — which is how you lose an afternoon.
The tradeoff is real, though: in YAML mode the UI editor is disabled for that dashboard. You edit files or you don’t edit. If you like tweaking cards from the couch on a tablet, stay in storage mode.
Where storage dashboards actually live
Everything the UI dashboard editor manages sits in .storage/ inside your config directory:
.storage/lovelace.lovelace— the default Overview dashboard.storage/lovelace_dashboards— the list of any additional dashboards you created in the UI (each one then gets its own.storage/lovelace.<something>file).storage/lovelace_resources— the registry of custom card resources (every HACS card you’ve installed is listed here)
That third file matters enormously later. Remember it.
The files are JSON with a wrapper. The dashboard config you actually care about is under data.config:
{
"version": 1,
"minor_version": 1,
"key": "lovelace.lovelace",
"data": {
"config": {
"views": [ ... ]
}
}
}
Getting the config out
.storage is not exposed in the File Editor add-on by default, and you should treat it as read-only regardless — Home Assistant owns those files. Reading is safe; writing to them while HA is running is how you corrupt state.
I pull it over the Samba add-on share, which exposes the whole config directory:
smbclient //homeassistant.local/config -U homeassistant \
-c "cd .storage; get lovelace.lovelace /tmp/lovelace.json"
If you don’t run Samba, the SSH add-on and cat works just as well. Either way, extract the config blob and convert it:
import json, yaml
with open("/tmp/lovelace.json") as f:
config = json.load(f)["data"]["config"]
with open("ui-lovelace.yaml", "w") as f:
yaml.dump(config, f, sort_keys=False, allow_unicode=True)
JSON is valid YAML, so technically you could paste the blob in as-is, but the round-trip through yaml.dump gives you something you can actually read and diff. sort_keys=False keeps the card order intact — don’t skip that.
Back up the original JSON into your repo before you touch anything else. The rollback story (below) depends on the storage file still being intact, but belt and suspenders.
The two architectures — and they behave differently
This is the first thing that’s under-documented. There are two ways to run a YAML dashboard, and they are not equivalent:
Option A: add a new YAML dashboard alongside storage mode.
lovelace:
dashboards:
yaml-main:
mode: yaml
title: Main
icon: mdi:view-dashboard
filename: dashboards/main.yaml
Top-level mode stays storage. Your existing Overview keeps working, the UI editor keeps working on it, and — critically — additional YAML dashboards still use the UI-managed resource registry. Custom cards just work. Two quirks: the dashboard key (yaml-main) must contain a hyphen — Home Assistant enforces this and check_config will actually catch it — and the filename is whatever you want.
Option B: replace the default dashboard by going full YAML.
lovelace:
mode: yaml
resources:
- url: /hacsfiles/lovelace-mushroom/mushroom.js
type: module
- url: /hacsfiles/Bubble-Card/bubble-card.js
type: module
# ... every single custom card you use
Top-level mode: yaml makes the main dashboard read from a fixed filename — ui-lovelace.yaml in the config root. You cannot rename or relocate it. Your old storage Overview isn’t deleted; it’s just superseded, sitting dormant in .storage (which is also your rollback path).
I went with Option B because I wanted one source of truth, not a storage dashboard and a YAML dashboard drifting apart. But Option B is also where the big trap lives.
The resources gotcha that breaks every dashboard at once
Here it is, the thing that cost me the most time and that the docs mention only in passing:
Setting top-level mode: yaml makes Home Assistant ignore the UI resource registry entirely — globally.
Not just for the YAML dashboard. For all dashboards. Every custom card resource you ever installed through HACS is registered in .storage/lovelace_resources, and the moment top-level mode is yaml, that registry stops being consulted. Every Mushroom card, every Bubble Card, every card-mod style — on every dashboard including untouched storage-mode ones — renders as a red “Custom element doesn’t exist” box.
The fix is to re-declare every resource in configuration.yaml under lovelace: resources: as shown above. My registry had 25 entries. The practical way to get them is to read them straight out of the registry file before you flip the switch:
smbclient //homeassistant.local/config -U homeassistant \
-c "cd .storage; get lovelace_resources /tmp/resources.json"
python3 -c "
import json
for r in json.load(open('/tmp/resources.json'))['data']['items']:
print(f\" - url: {r['url']}\n type: {r['type']}\")"
Paste the output under lovelace: resources: and you’re covered.
The ongoing cost: the Resources page in the UI is disabled from now on. When you install a new card through HACS, HACS can no longer register it for you — you add the /hacsfiles/... line to configuration.yaml by hand or the card doesn’t exist. It’s a small tax, but you pay it forever, and forgetting it is a guaranteed “why is my new card broken” ticket from yourself.
Bubble Card pop-ups: the format changed underneath the migration
If you use Bubble Card pop-ups, the config you export from storage mode is probably in the legacy format: a vertical-stack whose first child is the pop-up header, with the pop-up body as the remaining siblings:
# Legacy (pre-3.x style)
- type: vertical-stack
cards:
- type: custom:bubble-card
card_type: pop-up
hash: '#kitchen'
- type: custom:mushroom-light-card
entity: light.kitchen_main
- type: custom:mushroom-light-card
entity: light.kitchen_island
Bubble Card 3.x wants standalone pop-ups: the body cards move inside the pop-up’s own cards: array and the stack wrapper goes away:
# Standalone (current)
- type: custom:bubble-card
card_type: pop-up
hash: '#kitchen'
cards:
- type: custom:mushroom-light-card
entity: light.kitchen_main
- type: custom:mushroom-light-card
entity: light.kitchen_island
Since pop-ups are triggered by URL hash, their position in the view stops mattering — the pop-up can sit at the top level of the view wherever it lands. The transformation is mechanical and lossless, which means it’s scriptable: walk the views, find every vertical-stack whose first card is a bubble-card with card_type: pop-up, hoist the siblings into cards:, drop the wrapper. I did exactly that in a short Python script and verified it by round-tripping — dump the migrated YAML, reload it, and assert the structure matches what the transform said it should be. For roughly 7,400 lines, hand-editing this would have been an error factory.
check_config will not save you
Second under-documented fact: check_config does not validate dashboard YAML. It validates configuration.yaml, your automations, your lovelace: block syntax (it caught my missing hyphen in a dashboard key) — but the actual card configs in ui-lovelace.yaml? Not looked at. A typo’d card type, a malformed cards: list, an entity that doesn’t exist — all of it sails through:
curl -s -X POST -H "Authorization: Bearer $HA_TOKEN" \
http://homeassistant.local:8123/api/config/core/check_config
# {"result": "valid", "errors": null} ← means nothing for your cards
The good news is the blast radius is small: a broken card renders as a red error box in the browser; it never crashes Home Assistant itself. But it means your verification step is a visual one. My deploy loop ended up as:
- Edit YAML locally, commit.
- Push the file over Samba.
check_config(catches config-block mistakes only).- Mode changes or brand-new dashboards: full Home Assistant restart. Expect the frontend to throw 502/504 for ten seconds or so — that’s normal.
- Content-only edits to an existing YAML dashboard: no restart needed at all, just a hard refresh in the browser (Ctrl+Shift+R).
- Click through every view looking for red boxes.
Step 6 is not optional. It’s the only card-level validation that exists.
One more scripting footnote: if you validate configuration.yaml structurally in Python first (worth doing), plain yaml.safe_load will choke on Home Assistant’s !include tags. Register a catch-all multi-constructor and it parses fine:
yaml.SafeLoader.add_multi_constructor('!', lambda l, s, n: None)
Splitting the monolith with !include
One YAML feature you don’t get in storage mode: ui-lovelace.yaml doesn’t have to be one file. Home Assistant’s !include tags work inside dashboards, so a 7,400-line dashboard can become a directory of per-view files:
# ui-lovelace.yaml
title: Home
views:
- !include dashboards/views/overview.yaml
- !include dashboards/views/climate.yaml
- !include dashboards/views/security.yaml
- !include dashboards/views/energy.yaml
Each view file is then a self-contained top-level mapping (title:, path:, cards:), small enough to actually read. Git diffs get dramatically better too — a change to the climate view touches climate.yaml and nothing else, so the commit history tells you which part of the dashboard changed without opening the diff.
I did this a week after the migration and consider it part two of the same job. The one caution: !include composes at load time, so a YAML syntax error in any included view file takes out the whole dashboard (red error screen, not a crash). Since check_config won’t catch it — see below — keep the per-file edits small and reload after each.
Rollback
The nice property of this whole migration is that it’s non-destructive. The storage Overview is never deleted — top-level mode: yaml just supersedes it. If things go sideways:
- Set
lovelace: mode: storage(or delete the block) inconfiguration.yaml. - Restart.
- Your old UI-managed dashboard is back, exactly as you left it.
Plus the JSON backup in the repo, plus git history on the YAML. I never needed any of it, but knowing the escape hatch exists is what let me do the cutover on a weeknight instead of scheduling a maintenance window with the family.
What I’d do differently
I’d script the conversion end-to-end from the start. I began by hand-fixing Bubble Card pop-ups and switched to the script after the third one. With 7,400 lines, anything mechanical must be code.
I’d dump the resource registry before flipping the mode, not after. I flipped first, watched every dashboard break at once, and then went digging for the registry. Reading .storage/lovelace_resources takes thirty seconds when your dashboards still work and you’re calm.
I’d think harder about Option A. Full YAML mode was right for me — one dashboard, one file, one history. But Option A (additional YAML dashboard, storage mode stays) gets you 90% of the benefit with none of the resources tax, and you can migrate incrementally, one dashboard at a time. If you’re not sure, start there. You can always flip the main mode later; the conversion work all carries over.
The dashboard has been in git for a month now. Two custom-card updates broke views in that time; both times, git log -p told me in under a minute what the working config looked like. That’s the whole pitch, honestly.
Related guides: Wall-mounted tablet dashboard · How I deploy HA config changes · Node-RED to native HA YAML