New sites run new 0.x under systemd, or the same release package in Docker. 2.x and 3.x receive no further updates. Switch using Install and update FTW; it covers every old and new layout. A new setup is available now; guided migration of old data is not ready. The control loop is local; Energyplan ships with Core and Core falls back safely when it is unavailable.
Install and update FTW is the full guide and explains how to choose a published new 0.x beta. Use the installer from the same tag you install, on a fresh 64-bit Raspberry Pi OS, Debian or Ubuntu host.
Follow the complete native install block. It stops on errors and uses a temporary download. Do not run it on the old SD card.
Do not use GitHub releases/latest; it stays on 2.x for old boxes.
--fresh-host confirms that no FTW site exists, including a stopped Docker
site in a custom directory, and the installer refuses a site it recognises.
It checks the package's SHA-256, creates the
native layout and starts ftw.service. Open
http://<host>:8080/setup on the LAN, then check health and live device
readings. If the install stops partway, inspect journalctl -u ftw, then
rerun with --resume --tag and the same tag. It keeps any data already saved.
Docker runs the same release package; see Docker.
Existing Docker, Home Assistant and older native sites can switch with a separate new setup; preserve old data and follow the one-Core checks in the guide. No native 0.x package for macOS has shipped; the old macOS Docker installer is retired.
Native (see native-beta.md):
ftw status # version, releases, last update, disk, health
ftw update [--channel beta|stable] [--retry]
ftw rollback # previous release, same data
ftw backup --output-dir /media/usb/ftw # verified full backup, copied off the disk
ftw support [--output FILE] # redacted support file
sudo systemctl restart ftw
journalctl -u ftw -n 200 --no-pager # logs; -f followsDocker 0.x, in ~/ftw-local: docker compose ps,
docker compose logs --tail 200 and docker compose exec ftw ftw status.
Update or go back by changing FTW_VERSION in .env and running
docker compose up -d --build.
Older Docker installs (1.x–3.x), in ~/ftw or /opt/ftw on the old
Raspberry Pi image: docker compose ps,
docker compose logs -f ftw ftw-updater and docker compose restart ftw.
They keep their Update Center. Do not use it, the retired paired-upgrade
procedure or a moving Docker alias to cross release lines.
The host operating system is maintained separately; see self-update.md.
The data directory is /var/lib/ftw on a native install,
~/ftw-local/data in Docker 0.x and data/ in an older Docker install. It
holds:
config.yaml— operator configuration;state.dbplus SQLite WAL files — settings, state and learned models;history.db— history as SQLite buckets (10 s for 7 days, 1 min for 90 days, 1 h for 5 years) and the energy ledger;cache.db— prices and forecasts; past prices cost the savings history and come back only from a full backup;- key files such as
nova.key— the box's identity; keep them with the data; drivers/— custom and managed drivers;backups/— full backups, unlessstate.backup_dirpoints elsewhere.
Core no longer writes a Parquet cold/ directory. A box converted from an
older version may still have one.
Do not store mutable state inside a container. In Docker the data directory
must be writable by uid 100/gid 101. A native install runs as the ftw user.
Make a verified full backup with ftw backup --output-dir <another disk>.
It captures the persistent data, verifies file hashes and SQLite, and
records the installed component versions. In Docker 0.x, back up the data
directory while FTW is stopped. See
backup-and-restore.md.
A native update takes no local rollback point: ftw rollback returns to the
previous release with the current data. Older Docker installs keep bounded
pre-update rollback points of the settings database and configuration. They
remain on the same disk and cannot recover a failed SD card.
Core exposes a read-only storage check at
GET /api/storage/inventory. It reports allocated, live and free SQLite pages
for state.db and cache.db, their WAL/SHM sizes, and free space on the volume
that contains the data directory. It does not expose paths, scan a storage
engine's internal files, change retention, checkpoint WAL, run VACUUM, or
delete data. GET /api/backups lists full backups and their sizes.
config.example.yaml and
go/internal/config define the current schema.
Edits are validated before application. A rejected hot reload leaves the
previous live configuration intact.
Driver set and most control values reload live. Listener addresses, state
paths and some integration transports are startup bindings; restart after
changing them. When unsure, inspect the restart classification in
go/internal/config/restart_required.go.
FTW accepts state-changing requests without credentials only when they are
addressed through a local name or address: loopback, private/link-local IP,
.local, .localhost, or .home.arpa. The setup
wizard follows the same rule, and the actual client address must also be local.
Browser writes must also be same-origin; FTW checks Origin, Host, and
Sec-Fetch-Site and does not advertise CORS.
The box refuses to be framed: every response includes
Content-Security-Policy: frame-ancestors 'none' and X-Frame-Options: DENY.
Local non-browser clients such as curl and Home Assistant may omit browser
fetch headers, but JSON bodies must use Content-Type: application/json.
Active reads that start discovery, begin an authorization flow, or force an
external update check pass through the same boundary. So do reads of config,
logs, support dump and report, driver source and identity, integration
status, notification history and rules, system and storage info, local
repository and snapshot paths, research dump, the app-link device list, driver
health, EV charger detail, planner diagnose, time series, and the fleet-ping
payload. Live dashboard reads such as status, energy, prices, plan,
loadpoints and history stay open, even if someone publishes the port. Still do
not publish the box directly to the internet.
Mutation requests addressed through any other hostname or a public IP fail
closed. To expose that API intentionally, generate a random token of at least
32 characters and give it to Core as the environment variable FTW_API_TOKEN:
- Native: run
sudo systemctl edit ftw, addEnvironment=FTW_API_TOKEN=<secret>under[Service], thensudo systemctl restart ftw. The drop-in stays wheninstall.sh --refreshreplaces the unit. Local users can read it; on a shared machine useEnvironmentFile=with a root-only file instead. - Docker 0.x: add the entry below to a
compose.override.yamlbesidecompose.yaml, keep the value in.envwith mode0600, and rundocker compose up -d. - Older Docker installs already pass it from the project
.env; rundocker compose up -d ftwafter setting it.
services:
ftw:
environment:
FTW_API_TOKEN: ${FTW_API_TOKEN}Send the token as a Bearer credential:
curl -X POST -H "Authorization: Bearer <same-random-secret>" \
https://ftw.example.net/api/restartOn the Home Assistant app the same call shuts Core down and re-execs the binary in-process. Supervisor does not restart a stopped app unless Watchdog is on, so exiting would leave the app stopped.
The same Bearer is accepted on the LAN when api.lan_auth is on. FTW_API_TOKEN
and the house password are independent secrets: the token is not hashed as a
password guess, and a mismatch does not lock Settings.
The built-in browser UI does not store API tokens. For a public/FQDN browser deployment, put FTW behind an operator-managed HTTPS reverse proxy with login or session authentication and have that trusted proxy inject the Bearer header upstream.
Recovery cannot be disabled by a bad token: connect through localhost, the
host's private IP, or its .local name, correct/remove FTW_API_TOKEN, and
restart Core. Tokens shorter than 32 characters are ignored and remote
mutations remain locked.
FTW_API_TOKEN is an operator-managed mechanism, not the credential for
remote access; remote access is the FTW app. See
architecture.md.
api.lan_auth is off by default. Turn it on from loopback inside the
process: http://127.0.0.1:8080 Settings → System on the machine, or curl
to 127.0.0.1. When on, protected LAN routes need the house password or
FTW_API_TOKEN. curl sends Authorization: Bearer <house-password> or
Authorization: Bearer <FTW_API_TOKEN>. The browser login form sets a
session cookie (ftw_lan, 12 hours). Loopback (127.0.0.1 / ::1) never
asks. Live status stays readable without the password; a viewer caller is
minted for those reads. The two secrets do not share a lockout: retrying the
API token cannot lock the household out of Settings. A first enable from
another LAN address is refused, so a visitor cannot set the password.
POST /api/config cannot flip the flag.
On an older Docker install under Docker Desktop (docker-compose.macos.yml),
a host curl to localhost arrives as the bridge gateway, not loopback, so
Settings and host curl get 403. Desktop SNAT uses that same peer for every
published-port client, including LAN visitors, so it must not count as
loopback. Enable from inside the container:
docker compose -f docker-compose.macos.yml exec ftw \
wget -qO- --header='Content-Type: application/json' \
--post-data='{"enabled":true,"password":"ETT-LANGT-LOSEN"}' \
http://127.0.0.1:8080/api/auth/passwordAn owner pairing QR is minted only from loopback or with the house password. Promoting a paired phone to owner uses the same gate. The first pairing on an empty box is an owner whatever the code said, so that mint also stays at the box. A viewer invite still works from the LAN once an owner exists.
The FTW app and Home Assistant MQTT are unchanged.
Recovery: curl to 127.0.0.1, or set api.lan_auth: false in
config.yaml and restart Core.
The full message is remote access to protected API routes is disabled; configure FTW_API_TOKEN or use a local address. The dashboard still loads —
the message appears when a protected request (saving settings, a scan) is
refused. It means the request failed the locality rules
above, and "local" is judged on the request, not on which network the
browser sits on:
- The address in the browser's address bar must be a private or
loopback IP,
localhost, or a.local/.localhost/.home.arpaname. - The address the connection arrives from must be loopback, private, or link-local.
Being on the same LAN as the box satisfies neither by itself. The common ways to trip the boundary from the couch next to the Pi:
- A plain hostname without a dot —
http://myhost:8080. Since v2.2.1 a no-dot name is deliberately not local (a DNS-rebinding guard), so an address that worked before an update stops working. Use the.localname or the IP instead. - A router-issued name with a dot in it —
pi.lan,ftw.fritz.box,box.home. Only the suffixes listed above count as local names. - Tailscale — both
100.x.y.zaddresses (CGNAT space) and*.ts.netnames count as remote. - IPv6 — when the name resolves to a global IPv6 address, the browser
connects from a global address and fails the second rule, even with a
.localname in the address bar. - A reverse proxy or Home Assistant ingress reached through a public URL — the request arrives carrying the public hostname.
The reliable fix for a browser is to open the UI through the box's private
IPv4 address, for example http://192.168.1.123:8080. Setting
FTW_API_TOKEN does not change what the built-in UI sends — the token is
only for API clients that attach the Authorization: Bearer header, as
described above — and a ?token=... query parameter in the URL does
nothing.
curl -fsS http://localhost:8080/api/health answers on every install; the
commands above show logs. Warnings about stale site-meter telemetry are
safety actions, not cosmetic noise. Dispatch remains idle until fresh data
returns.
Check the driver-specific error and transport connectivity. Core should already have marked it offline and sent its default mode. Restart only after capturing the useful log context. If telemetry resumes, watchdog recovery is automatic.
Check, in order:
- site-meter freshness and sign;
- selected mode and current plan freshness;
- asset SoC/capability;
- fuse/per-phase saturation;
- driver health and command errors;
- optimizer status and fallback messages.
A safety clamp or fallback should be visible in status/logs. Do not raise a limit until the physical installation and configuration agree.
Inspect Core logs and /api/components for the Energyplan worker status.
Core uses its Go fallback when needed; recovery needs no data reset.
Verify the broker address from the same network namespace as core, then inspect broker and driver logs. Device credentials and topic mappings belong to the driver configuration.
With allow_unverified_local enabled, FTW resolves .local device names
itself because a CGO_ENABLED=0 Go binary never consults NSS. Without the
option, FTW leaves the name to Go's system DNS path. FTW's own lookup has two
backends, and the log line for a successful lookup says which:
resolved host over mDNS host=zap.local addr=192.168.1.42 via=avahi
via=avahi — the host's avahi-daemon answered over its socket. This is
preferred when available because avahi already holds a record cache. When NSS
and the socket mount are configured, getent hosts zap.local asks the same
daemon, though cache timing can still differ.
via=multicast — FTW queried the LAN directly. This is what happens when
the avahi socket is absent, and it needs no host software at all.
Failures log mDNS resolution failed, and the message names both backends when
both were tried.
Either way multicast has to reach the LAN. A native install is on the host
network, and both Docker compose files for Linux use network_mode: host.
Under the old docker-compose.macos.yml the container is bridged and
multicast does not reach the LAN, so configure devices by IP there. The
direct path sends on every active, non-loopback multicast
interface. It supports IPv4 and IPv6; a link-local IPv6 answer is used only
with its interface zone, and an unscoped link-local answer is discarded.
mDNS has no built-in authentication. Treat a .local name as a LAN trust
boundary, reserve names used by control drivers, and use TLS certificate pins
where the driver supports them. Network allowlists still check the configured
host name and port before resolution; they do not prove that an mDNS responder
is the intended device.
That risk is not unique to names, which is why the opt-in below gates the resolver rather than the connection. A raw IP address is no more an identity on a LAN than a name is — it can be claimed by ARP, and DHCP can hand it to a different device with no attacker involved at all. The durable check is the identity a device reports once connected: make and serial, or its MAC.
So by default FTW does not use its own mDNS answer for a .local name: the
name goes to the system resolver, and on platforms that answer .local
themselves — Home Assistant does, through Supervisor's DNS service — it simply
works. Opt in per driver to let FTW
resolve the name itself, over Avahi or the LAN:
capabilities:
allow_unverified_local: true
modbus:
host: inverter.local
port: 502For the Home Assistant bridge, set homeassistant.allow_unverified_local: true
instead. It applies to every transport — HTTP, WebSocket, MQTT, Modbus and raw
TCP — and is per driver, so a name allowlist never becomes a server identity for
another driver. Literal IP addresses and ordinary DNS names are untouched.
Without it, a .local dial is not refused; it is handed to the system resolver,
and the log records why FTW's own answer was not used. That matters on a host
where nothing else resolves .local — a native or Docker install whose
resolv.conf points at the router — because there the name will simply not
resolve until you opt in.
HTTP and WebSocket requests to .local hosts always bypass configured proxies,
so credentials and control payloads stay on the LAN path. Ordinary DNS names
still use the configured proxy. A TLS pin does not bypass the mDNS gate yet.
A native install uses /run/avahi-daemon/socket when the host runs
avahi-daemon. In Docker, host networking shares ports, not Unix sockets, so
avahi has to be bind-mounted in. The older docker-compose.yml carries the
line commented out:
volumes:
- /run/avahi-daemon:/run/avahi-daemon:roMount the directory, not the socket file inside it. If the host path is
missing Docker creates it, and an empty directory is harmless — whereas a
directory created where the socket belongs stops avahi-daemon from ever
starting. Restarting avahi detaches the mount, so restart FTW after you do.
This is an optimisation, not a requirement: device connectivity is unchanged
without it. In the older image it also lets getent hosts zap.local, curl
and wget check the name from inside the container.
Under the Home Assistant add-on none of this applies: Supervisor mounts only a
fixed set of named paths, so the socket cannot be provided and FTW always
queries the LAN directly. The add-on runs with host_network: true, which is
what makes that work.
Read the validation error, compare with
config.example.yaml, fix the file and
save again. Do not delete state.db to resolve a YAML error.
sudo ss -ltnp | grep ':8080'Stop the conflicting service or change the configured API port, then restart.
The installer creates:
/opt/ftw/ftw-launcher starts the current release slot
/opt/ftw/slots.json current and previous release, trial state
/opt/ftw/releases/<tag>/ Core, ftw-backup, web, bundled drivers, Energyplan
/var/lib/ftw/ config.yaml, state.db, history.db, drivers/, backups/
/etc/systemd/system/ftw.service deploy/ftw-native.service, alias forty-two-watts.service
/usr/local/bin/ftw operator command
deploy/ftw-native.service runs the launcher
as the ftw user. ftw update changes only the release slots; the launcher,
the ftw command and the unit move with install.sh --refresh
(native-beta.md).
No 0.x stable has shipped; run a beta only on an agreed test site. A beta and its promoted stable build identify the same commit.
-
Native:
ftw rollbackreturns to the previous release when it reads the same data. If Core does not start andftwcannot reach it, roll back offline:sudo -u ftw /opt/ftw/ftw-launcher -root /opt/ftw rollback sudo systemctl restart ftw
-
Docker 0.x: set the previous
FTW_VERSIONin.envand rundocker compose up -d --build. There is no automatic fallback. -
Across a state-schema change, restore a full backup made before the update; see backup-and-restore.md.
-
Older Docker installs (1.x–3.x) roll back within their own line through their Update Center. Never use it to reach 0.x.