Repository navigation
Deployment
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
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)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.shNote
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.
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 addressNothing 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.shWhich 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 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 BThe 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.
All of it. None of the state lives inside a container:
-
.envis 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.
-
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:stablemoves 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.
./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, immutableA 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.shv1.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.
.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'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.
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.
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.
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.
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.
Runs entirely on your LAN · Full configuration reference in README.md · Pi preparation and backup in deploy/README.md
- Home
- Get started — build & run
- Configuration
- Solcast
- Weather
- Troubleshooting
Advanced
- Docker
- Platforms
- Deployment — Pi, two workflows
- MQTT
Images: ghcr.io/mpospisil/solax-controller