Skip to content

Deployment

martin edited this page Aug 26, 2026 · 6 revisions

Deploying to a Raspberry Pi

There are two deployment workflows, and how much RAM your Pi has decides which one you want. Home Assistant is the expensive part by a wide margin; the controller and its own web UI are not.

A — Full stack B — Controller only
RAM required 2 GB minimum, 4 GB recommended 1 GB is enough
Disk 16 GB minimum 8 GB
Containers controller, broker, Home Assistant controller
mem_limit total 848 MB 200 MB
Deploy script deploy.sh deploy-controller-only.sh
Control surfaces Home Assistant :8123 and web UI :8090 web UI :8090
Required in .env site addresses, HOMEASSISTANT_ENABLED, MQTT credentials site addresses only
Extra setup broker password file, HA onboarding none

RAM is the deciding factor, and Home Assistant is why. It reserves 600 MB of the 848 MB the full stack claims and genuinely uses most of it — around 485 MB in steady state. The controller with its UI is one .NET process using roughly 70 MB against its 200 MB limit; the broker sits under 5 MB.

So on a 1 GB board, workflow A commits 94% of the machine before the OS and page cache get a look in. It runs — that was the original reference install — but with no headroom, and a board with no headroom swaps under load. On 1 GB, choose workflow B. Nothing is lost by doing so: everything the full stack can do from Home Assistant — telemetry, every control, charging-session history, the forecast plan — is on the controller's own dashboard instead. Switching later is a matter of running the other script.

                     Raspberry Pi  (arm64)
    ┌────────────────────────────────────────────────────────────────────┐
    │ compose project "solax"        (all state on bind mounts)          │
    │                                                                    │
    │ solax-controller ──MQTT──▶ mosquitto ◀──MQTT── homeassistant       │
    │      │             (opt-in profile,     opt-in profile,            │
    │      │              no host port)          :8123)                  │
    │      └── :8090, the web UI -- on by default                        │
    └────────────────────────────────────────────────────────────────────┘
         ▼ Modbus TCP
  inverter, EV charger

Secrets stay on the target

Configuration and secrets live in /opt/solax/.env on the target machine. That file is never committed and never copied by the deploy script — you create it once, by hand.

IMAGE_TAG=latest

TZ=Europe/Prague
INVERTER_HOST=192.168.2.10
EV_CHARGER_HOST=192.168.2.6

# optional — without these, forecast refreshes are skipped
SOLCAST_API_KEY=
SOLCAST_RESOURCE_ID=

# optional — the weather each charging session ran in, two API calls per session. Without all
# three, no weather call is made and sessions are recorded without it. See Weather.
WEATHER_API_KEY=
WEATHER_LATITUDE=
WEATHER_LONGITUDE=

# WORKFLOW A ONLY. Omit this whole block for workflow B (controller + web UI); the
# controller-only script never reads it and never looks for a broker.
#HOMEASSISTANT_ENABLED=true
MQTT_USERNAME=solax
MQTT_PASSWORD=<a long random password>

# nothing here is needed for the web UI -- it is on by default, on :8090, with the port
# published by docker-compose.yml. These only change that:
#WEB_ENABLED=false                                              # no UI at all
#WEB_PASSWORD_HASH=<generate with the image, see Configuration> # require a login (advanced)

Workflow A — create the broker credentials

Only if you're deploying the full stack with deploy.sh. The broker refuses anonymous connections, so it needs a password file before anything can connect. Create it once, with a username matching MQTT_USERNAME.

docker run --rm -v /opt/solax/mosquitto/config:/mosquitto/config \
  eclipse-mosquitto:2 \
  mosquitto_passwd -c -b /mosquitto/config/passwd solax '<password>'

sudo chown 1883:1883 /opt/solax/mosquitto/config/passwd

# then deploy
./deploy/deploy.sh

How the pieces are wired

Note

The broker is not exposed. It has no ports: entry, and only the other two containers reach it over the Compose network. Home Assistant is on :8123; the web UI is on :8090. All state lives on bind mounts under /opt/solax, so the containers are disposable: upgrades, rollbacks and docker compose down lose nothing.

Service Profile Exposed Memory limit Purpose
solax-controller (always runs) :8090 200m Polling, decision logic, the web UI
mosquitto mosquitto none 48m MQTT broker between the controller and Home Assistant
Home Assistant homeassistant :8123 600m Alternative control surface and dashboards

Those limits are sized for a 1 GB board, leaving roughly 170 MB for the OS and page cache. They are not resized automatically for larger ones: on 4 GB the same 848 MB is about a fifth of the machine, and you can raise or remove them. Keeping them is still worthwhile — a leaking container then hits its own ceiling and restarts, instead of the kernel OOM killer choosing a victim. They only take effect if cgroup memory accounting is enabled — see Platforms.

Workflow B — controller + web UI only

For a 1 GB board, or any Pi where you don't want Home Assistant. Only solax-controller starts: no broker, no Home Assistant, roughly 200–250 MB against 848 MB for the full stack.

This is the whole of the required .env — there is no second list:

TZ=Europe/Prague               # your timezone
INVERTER_HOST=192.168.2.10      # your inverter's address
EV_CHARGER_HOST=192.168.2.6   # your charger's address

Nothing about the web UI needs setting: it is on by default on :8090, with the port published by docker-compose.yml. Skip the broker credentials above — this script never looks for a password file — and skip Home Assistant's onboarding.

./deploy/deploy-controller-only.sh

Which of the two you get is a choice of script, not a .env setting: both set COMPOSE_PROFILES themselves for their own deploy, overriding whatever is already in .env on the Pi, so it can't go stale between deploys.

Note

Upgrading from a release where the UI was opt-in? Delete any COMPOSE_FILE=docker-compose.yml:docker-compose.web.yml line from .env. docker-compose.yml publishes port 8090 itself now; the second file survives only so that line doesn't break the deploy, and merging it does nothing. WEB_ENABLED=true is simply the default and can go too.

Switch to deploy.sh at any point later to add Home Assistant and the broker — nothing about running controller-only forecloses adding the rest of the stack afterward, and vice versa. All four combinations (UI only, MQTT only, both, neither) are supported.

Updating a running deployment

Updating is the same command as deploying. From the developer machine, re-run the script for the workflow already deployed — there is no separate update path and nothing to do on the Pi itself.

./deploy/deploy.sh                    # workflow A
./deploy/deploy-controller-only.sh    # workflow B

The script copies the compose files and mosquitto.conf from your local deploy/, pulls, and runs docker compose up -d. Compose compares each container against the image and configuration it should have and recreates only what actually changed, so an update with nothing new is a no-op and one with a new build restarts a single container.

What survives

All of it. None of the state lives inside a container:

  • .env is never copied by either script — your secrets and site addresses are untouched
  • data/sessions.db — charging-session history
  • logs/ — the controller's log files
  • homeassistant/config/ — seeded on first deploy, never overwritten again
  • mosquitto/config/passwd — broker credentials

mosquitto.conf and the compose files are overwritten every deploy, so edit those in the repo rather than on the Pi.

Two things to expect

  • Home Assistant and the broker update too. The pull covers every image in the active profiles, not just the controller, so on workflow A home-assistant:stable moves to whatever it points at now.
  • Charge control returns to Off. The worker always boots with no mode selected and the battery hold disabled, so a mode that was active before the update is not active after it. The charger is left exactly as it was until you choose again.

A charging session in progress is not lost: on a clean stop it is closed and recorded with reason ServiceStopped, and charging afterwards becomes a new session.

Pinning and rolling back

./deploy/deploy.sh                              # latest from main
IMAGE_TAG=1.0.0 ./deploy/deploy.sh              # a released version -- no "v"
IMAGE_TAG=sha-abc1234 ./deploy/deploy.sh        # one specific build, immutable

A rollback is an update pointed at an older tag. sha- tags never move, which makes them the reliable thing to roll back to. Deploy from a checked-out git tag rather than your working branch, since the script copies the local deploy/ tree:

git switch --detach v1.0.0 && IMAGE_TAG=1.0.0 ./deploy/deploy.sh

v1.0.0 is the git tag; 1.0.0 is the image tag — the publish workflow strips the v, and IMAGE_TAG=v1.0.0 fails with manifest unknown.

Changing settings instead of code

.env is never copied, so a settings change happens on the Pi and then takes effect:

ssh martin@192.168.2.7 'nano /opt/solax/.env'
ssh martin@192.168.2.7 'cd /opt/solax && docker compose up -d'

Confirming the new build is live

ssh martin@192.168.2.7 'cd /opt/solax && docker compose logs solax-controller | grep "starting\."'

The worker logs its version and commit — SolaX Local Controller 1.0.0 (31bf347) starting. — so you can verify rather than assume. 0.0.0-dev means a local build was deployed.

Full detail, including what to do when an update goes wrong, is in deploy/README.md.

Stopping and starting the controller

Ask it to stop; don't kill it. Three ways, all the same shutdown:

From How
The web UI Health page → Stop service, then confirm
Home Assistant the device's Stop service button, in its Configuration section
A shell on the Pi docker compose stop solax-controller

Each returns the charger's setpoint to the pause current, closes and writes the open charging session, flushes the store and disconnects cleanly. Killing it instead — docker kill, docker rm -f, pulling the plug — skips all of that. Almost nothing is lost from disk, but nothing revokes the charging current either: the car keeps drawing at whatever was last written.

A stop from the UI or Home Assistant is a stop, not a restart. Nothing brings it back on its own — that is the point — so starting it again means a shell:

ssh martin@192.168.2.7
cd /opt/solax

docker compose start solax-controller
docker compose ps                          # State should read "running"

docker compose up -d works too, which is why a deploy also starts a service that was stopped this way. Either way it comes back in charge mode Off, like any other restart.

While it is stopped there is no web UI and no Home Assistant integration — the service is both of them, so neither can start it back. A future version may replace the stop with a standby mode that keeps the UI up to host a Start button.

A reboot still brings it back

docker-compose.yml runs the controller under restart: on-failure, and the worker chooses its exit code so that policy can tell a deliberate stop from everything else:

How the run ended Exit code What Docker does
Stop service pressed 0 leaves it down until you start it
docker compose stop — leaves it down (a manual stop always wins)
Reboot, daemon restart, docker compose restart 143 starts it again
Crash, OOM kill, power cut non-zero starts it again

So a Pi coming back from a power cut or a reboot brings the controller with it, exactly as before — only a stop somebody actually asked for is respected as one.

How long it takes, and how to check it worked

Usually a second or two, but a Modbus read already in flight cannot notice the stop until it times out, and a device that has gone quiet costs 5 seconds per unanswered exchange. A stop measured on a live system took 19 seconds with nothing charging, purely because the EV charger went silent for one poll. stop_grace_period: 60s covers that, and the charger-release step gives up after 10 seconds rather than spending the whole budget:

[WRN] Gave up pausing the charger on shutdown after 00:00:10 — it is not answering. It may
      still be charging under our last setpoint until something else changes it.

That line means a car may still be drawing — check the charger itself.

Every run that ends properly closes with one of these, so a log that ends without one is a run that died rather than stopped:

ssh martin@192.168.2.7 'grep -h -E "starting\.|stopped cleanly" /opt/solax/logs/solax-*.log | tail'
SolaX Local Controller stopped cleanly at the request of Web UI. Exiting with code 0: …
SolaX Local Controller stopped cleanly after a termination signal. Exiting with code 143: …

That matters here more than elsewhere: the Pi's journal is RAM-only, so after a reboot the controller's own log files in /opt/solax/logs are the only surviving account of what happened.

What appears in Home Assistant

MQTT discovery auto-creates a device carrying a button per strategy — Charge from solar, Charge from forecast, Charge fast, Activate target and Charge off — a read-only Charge mode sensor saying which one is driving, the controls those buttons apply, sensors for solar power and surplus, battery SOC, grid power and EV charging current, and binary sensors for whether the car is connected and charging. The running build shows on the device page as its software version.

Charging is started by a button rather than by picking a mode from a select; see MQTT for why, and for what each control does.

There is also a Stop service button, in the device's Configuration section rather than on the dashboard card. It shuts the controller down gracefully — and it is one-way from Home Assistant, which cannot start it again; see Stopping and starting the controller.

An availability topic means Home Assistant marks the device unavailable if the controller stops, rather than showing stale values. See MQTT for how that works.


Previous: Platforms · Next: MQTT

SolaX Local Controller

Advanced


Images: ghcr.io/mpospisil/solax-controller

Clone this wiki locally