Skip to main content

Docker Deployment

HomeCore runs as two containers.

browser ─▶ hc-web ─┬─ /            the web UI
└─ /api/v1/* ──▶ hc-core ──▶ plugins (child processes)

└─ embedded MQTT broker
ImageWhat it is
ghcr.io/homecore-io/hc-coreThe REST/WebSocket API and the embedded MQTT broker.
ghcr.io/homecore-io/hc-webThe web UI. nginx serves the app and reverse-proxies /api/v1/* to core.

Plugins are not containers. Install them from the UI — Plugins → Add — and core downloads a signed artifact from the plugin registry, verifies its signature, and runs it as a child process. Adding hardware support never means editing a compose file.

The compose files live in homeCore-io/docker.


Quick start

git clone https://github.com/homeCore-io/docker.git homecore-docker
cd homecore-docker

# Create the data dir as your user. The entrypoint detects the owner and
# drops privileges to match — no chown needed.
mkdir homecore-data

docker compose up -d

# First-boot admin password:
cat homecore-data/INITIAL_ADMIN_PASSWORD

Open http://<host-ip>:3000, log in as admin, then Plugins → Add.


Which compose file

FileNetworkingUse it when
compose.ymlbridgeStart here.
compose.host.ymlhostYou have Hue, Sonos, WLED, Roku or Ecowitt — or discovery found nothing.
compose-dev.ymlbridgeYou want the :dev tag, rebuilt on every push to develop.

The distinction that matters is discovery. Hue, Sonos, WLED and Roku find devices over mDNS and SSDP, which is multicast — and a Docker bridge network does not carry it. On compose.yml those plugins install and run perfectly happily and then find nothing at all, which looks like a broken plugin rather than a networking choice. Sonos additionally serves UPnP event callbacks and must advertise an address the speakers can reach back on, which a NATed container IP is not.

Ecowitt is in the same position, for two different reasons. Its gateway discovery is a UDP broadcast to 255.255.255.255:45000, and a bridge network forwards broadcast no more than it forwards multicast. And receiving uploads means the gateway opens a connection to homeCore on [ecowitt].listen_port (default 8888), which compose.yml does not publish — so setting bind_addr = "0.0.0.0" is not enough here, though it would be on bare metal.

Ecowitt has one escape hatch the others do not: set [ecowitt].gateway_ip and the plugin polls the gateway over ordinary outbound HTTP, which works on the bridge unchanged.

Plugins that only reach out over ordinary TCP or HTTP — YoLink, Lutron, Caseta, ISY, Z-Wave — work fine on the bridge setup, because outbound connections NAT out of a container without help.

compose.host.yml is Linux only. Docker Desktop on macOS and Windows runs containers inside a VM, so host networking there does not reach the LAN's multicast traffic.

Under host networking there are no port mappings, so hc-web takes WEB_PORT=3000 directly and reaches core at 127.0.0.1:8080 — compose service names do not resolve on the host network.


Why hc-web proxies the API

The web app calls /api/v1 as a relative path, and core sends no CORS headers at all. The API therefore has to be same-origin with the app, and the nginx inside the hc-web image is what makes it so. This is not an optional convenience layer: point a browser at a bare static build and every API call returns the SPA's index.html instead of JSON.

HOMECORE_URL tells that nginx where core is. It defaults to http://homecore:8080 — the compose service name.


Persistence

One host directory per container, bind-mounted at /homecore:

homecore-data/
├── INITIAL_ADMIN_PASSWORD # one-time, plain text. Delete after first login.
├── config/
│ ├── homecore.toml # main config
│ ├── plugins/ # per-plugin configs, seeded on install
│ └── profiles/ # ecosystem profiles
├── data/
│ ├── state.redb # device registry
│ ├── history.db # time-series
│ └── jwt_secret # generated; do not commit
├── plugins/<id>/<version>/ # installed plugin binaries; old versions kept
├── rules/ # automation RON files, hot-reloaded
└── logs/

There is no config bind-mount. The image seeds config/homecore.toml inside homecore-data on first boot, and the default is correct as shipped: core serves no UI, declares no plugins, and points at the signed registry. Edit that file and docker compose restart homecore; it is yours after first boot and is never overwritten.

An earlier compose file did mount homecore.toml from the clone. Deployed without the repo, Docker created a directory at that path and core's seed copy had nowhere to land — cp: can't create '/homecore/config/homecore.toml/config.toml'. If you are carrying an old compose file locally, delete the mount.

File ownership

mkdir homecore-data as yourself is the whole setup. The entrypoint starts as root, reads the mount's owner UID, and su-execs to it before writing anything. If the directory does not exist, Docker creates it root-owned and the entrypoint chowns it to HOMECORE_UID:HOMECORE_GID (default 1000:1000).


The MQTT broker

Core embeds an MQTT broker on 1883. It is not published by default: it binds loopback, and plugins are children of core in the same network namespace, so nothing outside the container needs to reach it.

Publish it only if LAN devices — Tasmota, Shelly, ESPHome — publish to it directly. That also means setting [broker] host = "0.0.0.0" and adding [[broker.clients]] credentials. Core refuses to start if the broker binds a non-loopback address with no clients configured, rather than quietly running an open broker. HC_ALLOW_ANONYMOUS_REMOTE_BROKER=1 overrides that if you mean it.


Image tags

TagMeaning
:latestMost recent tagged release. What compose.yml tracks.
:0.1.18A specific release, immutable.
:devRebuilt on every push to develop. Mutable.
:dev-<sha7>A specific develop build, immutable.

pull_policy: always is set in the compose files so docker compose up picks up the newest image at the configured tag — which matters on :dev. On an immutable release tag the pull is a cheap no-op.

To pin the whole stack, check the docker repo out at a tag and set both image versions explicitly. Do not expect the numbers to line up: core, hc-web and the docker repo are tagged on their own cadences, so hc-core and hc-web are routinely several releases apart.


External Mosquitto (optional, for stronger MQTT authz)

The embedded rumqttd broker enforces authentication on CONNECT only — allow_pub / allow_sub patterns are metadata, not enforced at publish/subscribe time. For deployments where plugins run third-party code or sit on other hosts, route MQTT through Mosquitto instead:

hc-cli broker generate-mosquitto-config \
--config homecore-data/config/homecore.toml \
--out mosquitto/

That writes a Mosquitto config plus an ACL file derived from your [[broker.clients]] entries. Run Mosquitto in a sidecar container and point [broker] at it with external_url = "mqtt://mosquitto:1883". Full plan at the MQTT broker guide.


Backup and restore

Persistent data is the bind-mount directory. Stop, snapshot, restart:

docker compose stop
tar -czf homecore-backup-$(date +%Y%m%d).tar.gz homecore-data
docker compose start

For a structured backup that excludes runtime state, use the API:

curl -s http://localhost:3000/api/v1/system/backup \
-H "Authorization: Bearer $TOKEN" \
-o homecore-backup.zip

See Administration → Backup & Restore for the full workflow.


Upgrading

docker compose pull          # newest image at the configured tag
docker compose up -d # recreates containers; the bind-mount is preserved

Installed plugins are upgraded separately from the UI — they live in the data directory, not the image, so pulling a new core does not change them.

Across a major version (0.1 → 0.2), check Migration for one-time data-format steps.


Health checks

docker compose ps            # both services "running"

curl http://localhost:3000/api/v1/health # through hc-web's proxy
curl http://localhost:8080/api/v1/health # core directly
# {"status":"ok","version":"0.1.18"}

Checking both is worth the extra second: if core answers and the proxied call does not, the problem is hc-web's HOMECORE_URL, not homeCore.


What used to be here

An appliance image (homecore-appliance) baked core and every plugin into one container, and earlier versions of this page described per-plugin compose fragments (compose.hue.yaml and friends) plus per-plugin images (ghcr.io/homecore-io/hc-hue, …).

All of that is retired. Plugins ship as signed registry artifacts installed at runtime, and the two-container stack above replaces the appliance. Existing appliance images remain in GHCR so anything still running one keeps working, but none are published any more.