This repository holds the infrastructure the rest of the Weather Station system depends on. It carries no application code: the firmware, the backend, and the frontend live in their own repositories and connect to what is defined here.
| Directory | Purpose |
|---|---|
compose/ |
The Docker Compose files: the local development stack and the whole production stack |
terraform/ |
Azure resources that host the system: VM, network, container registry and Key Vault |
ansible/ |
Packages installed on that VM, and the production stack it runs |
scripts/ |
Operator scripts that write the broker certificate and passwords to Key Vault |
edge/ |
Nginx in front of the frontend and backend: TLS and routing |
The two halves are independent. compose/ is what you run for local
development; terraform/ provisions the machine where that same stack
runs in production, and ansible/ installs what that machine needs to run
it. edge/ runs only in production, on that machine.
| Service | Image | Port | Role |
|---|---|---|---|
mosquitto |
eclipse-mosquitto:2.1-alpine |
1883, and 8883 with TLS in production |
Receives the sensor readings published by the ESP32 boards |
influxdb3 |
influxdb:3-core |
8181 |
Stores the snapshots written by the backend |
influxdb-init |
curlimages/curl:8.12.1 |
— | Creates the configured InfluxDB database once, then exits |
backend |
Local build or versioned production image | 8000 |
Stores snapshots and serves live and historical readings |
The backend sits between Mosquitto and InfluxDB: it subscribes to the
broker, assembles complete snapshots and writes them to InfluxDB.
The initializer configures the database before the backend starts; it exits
with status 0 after setup, which is expected.
compose/init-influxdb.sh runs inside a Linux container and is kept in LF
format by .gitattributes, including when checked out on Windows.
Docker Engine with the Compose v2 plugin and a sibling checkout of
weather-station-backend, because the local backend image is built from source.
For example, the two repositories should be next to each other:
projects/
weather-station-platform/
weather-station-backend/
Mosquitto and InfluxDB run from their official images; Compose builds the backend from the sibling checkout.
The local stack reads the variables in compose/.env.example:
cp compose/.env.example compose/.env| Variable | Default | Purpose |
|---|---|---|
MQTT_PORT |
1999 |
Host port mapped to the Mosquitto broker |
INFLUXDB_PORT |
2000 |
Reserved; the Compose file currently publishes InfluxDB on 8181 directly, so this value has no effect |
INFLUXDB_DATABASE |
weather-station-db |
Database created by the InfluxDB initializer and used by the backend |
INFLUXDB_RETENTION_PERIOD |
15d |
Retention period applied when the database is first created |
INFLUXDB_QUERY_FILE_LIMIT |
600 |
InfluxDB server-wide maximum Parquet files accessed by a query |
HISTORICAL_CACHE_RETENTION_DAYS |
15 |
Maximum age of cached historical day segments; keep aligned with InfluxDB retention |
The file must sit next to docker-compose.yaml, inside compose/. Docker
Compose resolves .env from the project directory — the directory holding
the compose file — not from the directory the command runs in. A .env
placed in the repository root is never read.
That failure is silent. An unset MQTT_PORT expands to an empty string,
and Docker answers an empty host port by publishing the broker on a random
one, so the stack reports itself as running while the ESP32 boards cannot
reach it. Confirm the mapping instead of assuming it:
docker compose -f compose/docker-compose.yaml config | grep -A2 'target: 1883'Passing the file explicitly with --env-file <path> also works and ignores the
project directory entirely.
docker compose -f compose/docker-compose.yaml up -d --builddocker compose -f compose/docker-compose.yaml ps # initializer Exited (0) is expected
docker port mosquitto # must read 1883 -> 0.0.0.0:1883
curl http://localhost:8181/health # InfluxDB answering: OK
curl "http://localhost:8000/api/v1/readings/station-01?from=2026-09-01T00:00:00Z&to=2026-09-03T00:00:00Z"
docker run --rm --network host eclipse-mosquitto:2.1-alpine \
mosquitto_sub -h 127.0.0.1 -t 'weather/#' -v # live readingsThe backend caches completed UTC days in SQLite at
/var/lib/weather-cache/historical.sqlite3; requests touching the current UTC
day still query InfluxDB. The dedicated historical-cache Docker volume
survives backend container restarts and docker compose down. To inspect its
entries while the backend is running:
docker compose -f compose/docker-compose.yaml exec backend \
python -c 'import sqlite3; c=sqlite3.connect("/var/lib/weather-cache/historical.sqlite3"); print(c.execute("SELECT station_id, utc_date FROM historical_days ORDER BY utc_date").fetchall())'
docker compose -f compose/docker-compose.yaml restart backenddocker compose down -v also deletes the cache. When changing
INFLUXDB_RETENTION_PERIOD, set HISTORICAL_CACHE_RETENTION_DAYS to the same
number of days so cached data does not outlive its source retention.
docker compose -f compose/docker-compose.yaml down # keeps local data
docker compose -f compose/docker-compose.yaml down -v # also removes named volumesMosquitto and InfluxDB store runtime data in bind-mounted directories under
compose/. These directories survive both commands above; down -v removes
Docker-managed named volumes (including the historical cache), not
bind-mounted host directories.
The broker is configured by compose/mosquitto/config/mosquitto.conf:
listener 1883 0.0.0.0
allow_anonymous true
persistence true
persistence_location /mosquitto/data/
log_dest stdoutThe explicit listener matters. Mosquitto 2.0 and later accept only local connections under their built-in defaults, so without this line the ESP32 boards would be refused.
Persistence writes retained messages and queued QoS messages to
compose/mosquitto/data/, so they survive container restarts and removal.
The directory is local runtime data and is excluded from Git. Logs go to
stdout, where the Docker logging driver collects them:
docker compose -f compose/docker-compose.yaml logs -f mosquittoThe firmware publishes one topic per measurement:
weather/<device-id>/airTemperature
weather/<device-id>/airPressure
weather/<device-id>/airHumidity
weather/<device-id>/daylight
weather/<device-id>/waterLevel
weather/<device-id>/airQualityIn secrets.h, the ESP32 must point at the LAN address of the machine
running this stack — never localhost, which from the board means the
board itself.
Authentication is disabled in development. allow_anonymous true lets
any client on the network publish and subscribe, which is acceptable on a
development LAN and not on a public address. Production uses its own
configuration, described below.
The VM runs compose/mosquitto/config/mosquitto.prod.conf, installed as
mosquitto.conf, with two listeners:
| Listener | Reachable from | Transport | Clients |
|---|---|---|---|
8883 |
Internet, published by Compose | TLS | The ESP32 boards |
1883 |
The Compose network only, never published | Plain TCP | The backend |
Both listeners require a username and password: allow_anonymous false
applies to every listener. The password file is generated on the VM from Key
Vault on each deployment; see Secrets.
compose/mosquitto/config/aclfile decides what each user may do:
| Rule | Effect |
|---|---|
pattern write weather/%u/+ |
Each station publishes only under its own username: station-01 writes weather/station-01/airTemperature and cannot write weather/station-02/... |
user weather-station-backend + topic read weather/+/+ |
Only the backend subscribes to the readings |
A station's MQTT username is its DEVICE_ID. Adding a station means adding
its ID to weather_platform_mqtt_station_ids in the weather-platform role
defaults and creating its password.
The service runs InfluxDB 3 Core with authorization turned off:
--node-id influxdb3
--object-store file
--data-dir /var/lib/influxdb3
--without-authData is stored in the bind-mounted directory compose/influxdb3-data/ on
the host, mapped to /var/lib/influxdb3 in the container. The directory is
excluded from Git and survives container restarts, docker compose down,
and docker compose down -v; remove it from the host only when you intend to
delete the local database data.
The influxdb-init one-shot service creates the configured database through the
InfluxDB API before the production backend starts. In local development, docker compose up runs the same initializer. It sets a 15-day data retention period
by default; points older than 15 days expire under this policy. If the database
already exists, the initializer accepts the conflict and leaves it unchanged.
Changing retention on an existing database requires a separate migration.
The server's Parquet file-access limit is 600 files per query by default
(InfluxDB 3 Core's default is 432). This is a server-wide startup setting, not a
per-database property. A higher limit can increase query latency and memory use.
Adjust INFLUXDB_QUERY_FILE_LIMIT cautiously and recreate/restart InfluxDB for
the setting to take effect.
Local settings live in compose/.env:
INFLUXDB_DATABASE=weather-station-db
INFLUXDB_RETENTION_PERIOD=15d
INFLUXDB_QUERY_FILE_LIMIT=600
Copy compose/.env.example to create the local file. Production values are
managed by the weather-platform Ansible role defaults. The backend receives
the configured database name from Compose and no longer creates or configures
the database itself.
Write and read a point by hand:
curl -X POST "http://localhost:8181/api/v3/write_lp?db=weather-station-db" \
--data-binary 'weather_reading,station_id=station-01 temperature=21.5'
curl -X POST "http://localhost:8181/api/v3/query_sql" \
-H 'Content-Type: application/json' \
-d '{"db":"weather-station-db","q":"SELECT * FROM weather_reading LIMIT 5"}'The terraform/ module provisions the Azure host in mexicocentral. The
Azure for Students subscription only allows deployments in a fixed list of
regions (az policy assignment list), and none of them is in Brazil; Mexico
Central is the closest. It requires Terraform 1.9 or later (1.x) and the
azurerm provider 5.5 or later (5.x).
| Resource | Name | Notes |
|---|---|---|
| Resource group | weather-station-rg |
Holds everything below |
| Container registry | var.acr_name |
Basic, globally unique; admin account disabled |
| Virtual network | weather-station-vnet |
10.10.0.0/16 |
| Subnet | weather-station-subnet |
10.10.1.0/24 |
| Network security group | weather-station-nsg |
Rules below |
| Public IP | weather-station-pip |
Standard SKU, static, with a DNS label |
| Network interface | weather-station-nic |
|
| Virtual machine | weather-station-vm |
Ubuntu 24.04 LTS, Standard_B2ats_v2, 30 GB disk |
| Role assignment | — | Grants the VM AcrPull on the registry |
| Key Vault | var.key_vault_name |
Standard, globally unique, RBAC authorization, reachable only from the VM's public IP and ssh_allowed_cidrs |
| Role assignment | Grants the VM Key Vault Secrets User on the vault: it reads the broker secrets |
|
| Role assignment | Grants the operator running apply Key Vault Secrets Officer on the vault: it writes them |
The VM carries a system-assigned managed identity, and that identity is what
holds AcrPull. The registry needs no stored credentials: the VM pulls
images using its own identity, and admin_enabled = false keeps the
registry's shared account switched off.
Password authentication is disabled on the VM. Access is by SSH key
only, taken from ssh_public_key. The key is set by value, not read from
a local path, so every operator plans against the same key — a different
key would force the VM to be replaced.
The VM has prevent_destroy. Its OS disk holds the InfluxDB data, the
broker state and the ACME certificate, so any plan that would destroy
or replace it fails instead. Remove the lifecycle block deliberately if
that is really intended.
| Port | Priority | Source | Purpose |
|---|---|---|---|
22 |
100 | ssh_allowed_cidrs — no default |
Administrative access |
80, 443 |
110 | Internet |
Frontend, backend API over TLS and ACME HTTP-01 |
8883 |
120 | Internet |
MQTT over TLS from the ESP32 boards (dynamic addresses) |
ssh_allowed_cidrs must be set — the module refuses an empty list and
0.0.0.0/0. Use your own address with a /32 mask; get it with
curl -4 -s ifconfig.me. The same list opens the Key Vault firewall, so
when your address changes, update it and run apply again before using the
scripts in Secrets.
State is stored in an Azure Storage Account, declared in the backend
block of versions.tf. The blob lease locks the state during every
operation, and access uses Entra ID (use_azuread_auth), so the account
keeps shared keys disabled.
The account is created once, outside Terraform, because it must exist before
init. Its name is globally unique: if weatherstationtfstate is taken,
pick another and update storage_account_name in versions.tf.
az provider register --namespace Microsoft.Storage --wait
az group create -n weather-station-tfstate-rg -l mexicocentral
az storage account create -n weatherstationtfstate -g weather-station-tfstate-rg \
-l mexicocentral --sku Standard_LRS --min-tls-version TLS1_2 \
--allow-blob-public-access false --allow-shared-key-access false
az storage account blob-service-properties update \
--account-name weatherstationtfstate -g weather-station-tfstate-rg \
--enable-versioning true --enable-delete-retention true --delete-retention-days 30
az role assignment create --role "Storage Blob Data Contributor" \
--assignee "$(az ad signed-in-user show --query id -o tsv)" \
--scope "$(az storage account show -n weatherstationtfstate -g weather-station-tfstate-rg --query id -o tsv)"
az storage container create -n tfstate --account-name weatherstationtfstate --auth-mode loginThe first command registers the storage resource provider: new
subscriptions, such as Azure for Students, start without it, and every
other command fails with SubscriptionNotFound until it is registered.
Blob versioning keeps every previous state, so a corrupted or overwritten
state can be restored. The role assignment may take a minute to
propagate. Every other operator needs the same Storage Blob Data Contributor role on the account.
az login
cp terraform/terraform.tfvars.example terraform/terraform.tfvarsFill in subscription_id, ssh_allowed_cidrs, ssh_public_key, dns_label,
acr_name and key_vault_name. The example file carries the command for each
value. Then:
terraform -chdir=terraform init
terraform -chdir=terraform plan -out=main.tfplan
terraform -chdir=terraform apply main.tfplanterraform.tfvars is ignored by Git and must not be committed.
terraform -chdir=terraform output| Output | Used for |
|---|---|
public_ip_address |
The target of the A record of the project domain; see Domain |
public_fqdn |
<dns_label>.mexicocentral.cloudapp.azure.com, the name Azure generates; a second name in the broker certificate |
acr_login_server |
Registry hostname used in image tags |
acr_name |
az acr login --name <value> |
key_vault_name |
The scripts in Secrets and weather_platform_key_vault_name in the weather-platform role |
resource_group_name |
Scoping az commands |
vm_name |
Scoping az commands |
The system answers on weather-station.thomaspalma.com.br: the website, the
API under /api/ and the MQTT broker on port 8883 all use that name. It is a
subdomain of thomaspalma.com.br, whose DNS is hosted by Registro.br, so
the record is created by hand in the Registro.br panel, not by Terraform:
| Name | Type | Value |
|---|---|---|
weather-station |
A |
The public_ip_address output |
The public IP is static, and deallocating the VM keeps it, so the record only changes if the public IP resource is destroyed and created again. Check the record from the operator workstation:
dig +short A weather-station.thomaspalma.com.brThe answer must be the public_ip_address output. The same name appears in
server_name in edge/nginx.conf, in ansible_host in
ansible/inventory/azure.yaml, in the broker certificate, in MQTT_HOSTS in
the firmware and in the PUBLIC_FQDN variable of the backend and frontend
repositories.
Terraform provisions the host and nothing more. Everything above it comes
from ansible/: the packages, the production stack and each release. See
Host configuration.
The broker secrets live in the Key Vault created by terraform/. Their
values never enter Terraform or Git: the operator writes them with the two
scripts below, and the weather-platform role reads them on the VM with the
managed identity, the same way it signs in to the registry.
| Secret | Written by | Read by |
|---|---|---|
mqtt-server-certificate |
scripts/issue-mqtt-certificate.sh |
Mosquitto, as certs/server.crt |
mqtt-server-private-key |
scripts/issue-mqtt-certificate.sh |
Mosquitto, as certs/server.key |
mqtt-password-<username> |
scripts/create-mqtt-password.sh |
Mosquitto's password file, the backend's .env and, for stations, secrets.h in the firmware |
Both scripts run on the operator workstation, after terraform apply, with
az login done and your address in ssh_allowed_cidrs. The Key Vault Secrets Officer role may take a few minutes to propagate after the first apply.
The broker uses a certificate signed by a private Certificate Authority (CA). The firmware trusts that CA directly, so the certificate does not depend on Let's Encrypt or on the edge proxy. On the operator workstation, from the repository root:
scripts/issue-mqtt-certificate.sh "$(terraform -chdir=terraform output -raw key_vault_name)" \
weather-station.thomaspalma.com.br \
"$(terraform -chdir=terraform output -raw public_fqdn)"The script creates the CA in certificates/ on the first run only, issues
a new server certificate for every hostname passed, and stores the certificate
and its key in Key Vault. certificates/ is ignored by Git.
Keep certificates/ca.key safe and backed up. Running the script again
reuses it, so the stations keep trusting the new server certificate. Losing it
means creating a new CA and flashing every station again with the new
ca.crt.
One password per MQTT user: each station, by its DEVICE_ID, and the backend.
On the operator workstation, from the repository root:
key_vault_name="$(terraform -chdir=terraform output -raw key_vault_name)"
scripts/create-mqtt-password.sh "$key_vault_name" weather-station-backend
scripts/create-mqtt-password.sh "$key_vault_name" station-01Each run generates a new password, stores it and prints it. Copy the
station's password into MQTT_PASSWORD in the firmware's secrets.h; the
backend's needs no copy, because Ansible writes it to .env. Passwords are
hexadecimal, so they need no quoting in .env or in C strings.
Rotating a password is the same command followed by deploy.yaml, plus a new
firmware upload for a station.
ansible/ prepares the VM created by terraform/. configure.yaml installs
packages; deploy.yaml copies the production files and starts the stack.
| Role | What it does |
|---|---|
update-os |
Upgrades every package, removes orphans and cleans the apt cache |
base-packages |
Installs git, curl, jq and other utilities, and sets the timezone |
docker-install |
Installs Docker Engine with Compose v2 and Buildx from Docker's repository, rotates container logs and adds azureuser to the docker group |
azure-cli |
Installs the Azure CLI from Microsoft's repository |
weather-platform |
Installs the production stack and starts it — run by deploy.yaml, not by configure.yaml |
The Azure CLI is what lets the VM pull from the registry with its managed identity, without any stored password:
az login --identity
az acr login --name <acr_name>The registry token lasts about 3 hours. Log in again before each deployment; containers that are already running are not affected.
It requires ansible-core 2.15 or later. SSH reaches the VM only from
ssh_allowed_cidrs, as azureuser, with the key set in ssh_public_key.
ansible_host in ansible/inventory/azure.yaml is the project domain, so its
A record must already exist; see Domain. Then:
cd ansible
ansible-galaxy collection install -r requirements.yaml
ansible-playbook playbooks/configure.yamlRun the commands from ansible/: Ansible reads ansible.cfg from the
current directory, and that file points at the inventory and the roles.
The first connection records the VM's host key in ~/.ssh/known_hosts. A
different key later stops the run. That is expected only if the VM was
replaced: then remove the old entry with
ssh-keygen -R weather-station.thomaspalma.com.br.
The VM reboots on its own when an upgrade needs it, for example after a
new kernel, and only then: the role checks for /var/run/reboot-required
first. Set update_os_reboot_if_required to false in the role's defaults to
keep the reboot manual.
playbooks/deploy.yaml runs the weather-platform role, which puts the whole
stack in /opt/weather-station/ on the VM:
| File | Contents |
|---|---|
docker-compose.yaml |
Copied from compose/docker-compose.prod.yaml: the six services |
.env |
The registry and application image versions, the InfluxDB settings and the backend's broker credentials; readable by root only |
init-influxdb.sh |
Copied from compose/; creates the database on startup, then the influxdb-init service exits |
nginx.conf |
Copied from edge/ |
mosquitto/config/mosquitto.conf |
Copied from compose/mosquitto/config/mosquitto.prod.conf |
mosquitto/config/aclfile |
Copied from compose/mosquitto/config/ |
mosquitto/config/passwordfile |
Generated from the Key Vault passwords and hashed with mosquitto_passwd |
mosquitto/config/certs/ |
The broker certificate and key, from Key Vault |
deploy.sh |
Puts one new version in production |
Every file is copied as it is in this repository, and the role renders no
templates, so what you read here is what runs on the VM. The only generated
files are the secrets above. The two application images are named by
version, never latest:
image: ${ACR_LOGIN_SERVER}/weather-station-backend:${BACKEND_VERSION:?}The :? makes Docker Compose refuse to start when the variable is missing,
instead of quietly falling back to latest.
The first run needs the broker certificate and every password already in Key Vault (see Secrets), and both versions already published to the registry:
ansible-playbook playbooks/deploy.yaml \
-e weather_platform_backend_version=1.0.0 \
-e weather_platform_frontend_version=1.0.0After that, the pipelines own the backend and frontend version lines in
.env; Ansible leaves those unchanged. The role reconciles
INFLUXDB_DATABASE, INFLUXDB_RETENTION_PERIOD, INFLUXDB_QUERY_FILE_LIMIT,
HISTORICAL_CACHE_RETENTION_DAYS and the backend's MQTT_BACKEND_USERNAME
and MQTT_BACKEND_PASSWORD on every deployment.
The password file is regenerated only when a password changes: the role keeps a SHA-256 digest of the credentials next to it and compares it on each run. A new certificate, password or ACL restarts only Mosquitto; the ESP32 boards reconnect on their own within seconds.
deploy.sh is what the application pipelines call, through
az vm run-command, so the VM needs no inbound SSH for a release:
/opt/weather-station/deploy.sh backend 1.0.2It rewrites that version in .env, signs in to the registry with the VM's
managed identity, pulls the image and recreates only that service. It
builds nothing: the image must already exist in the registry.
Each application is released on its own, by tagging its repository. To roll back, run the script with the previous version — the image is still in the registry, so nothing is rebuilt.
edge/nginx.conf is the only public entry to the web application. It
answers on ports 80 and 443, obtains and renews the Let's Encrypt
certificate by itself, and routes each request:
| Path | Destination | Container |
|---|---|---|
/api/ |
127.0.0.1:8000 |
backend |
| everything else | 127.0.0.1:8080 |
frontend |
The frontend and the backend are published on 127.0.0.1 only, as their
own READMEs describe, so they are reachable only through the edge. FastAPI's
/docs and /openapi.json are not exposed: only /api/ reaches the backend.
It does not run locally. Let's Encrypt must reach a public domain on
port 80, which only the VM has.
server_nameinedge/nginx.confis the project domain,weather-station.thomaspalma.com.br, and its A record must already resolve to the VM: Let's Encrypt validates the name over port80before issuing the certificate. See Domain.- Build the frontend without
VITE_WEATHER_API_URL. Its empty default leaves the API calls relative, so they resolve against the page's own origin — which the edge serves. The same image then works behind any domain, andWEATHER_ALLOWED_ORIGINScan stay empty. - The production Compose file sets
FORWARDED_ALLOW_IPS: "*". Without it, the server ignores theX-Forwarded-*headers the edge sends, because requests arrive from the Docker bridge, not from127.0.0.1. Trusting every address is safe only because the backend port is bound to127.0.0.1.
The edge is one of the services in the production stack, so
deploy.yaml starts it along with the rest. Its two settings
that matter are already in the compose file:
network_mode: hostlets it reach127.0.0.1:8000and127.0.0.1:8080and listen on ports80and443of the VM directly.- The
edge-acmevolume must be kept. It stores the ACME account, the certificate and its private key. Without it, every restart requests a new certificate, and Let's Encrypt allows only 5 identical certificates per week.docker compose down -vdeletes it.
On the first deployment, point uri in edge/nginx.conf at the
staging directory, https://acme-staging-v02.api.letsencrypt.org/directory,
and switch back to production once the certificate is issued. Delete the
edge-acme volume when switching, so the staging account and certificate are
discarded.
curl -I http://weather-station.thomaspalma.com.br # 301 to https
curl https://weather-station.thomaspalma.com.br/healthz # ok, from the frontend
curl -N https://weather-station.thomaspalma.com.br/api/v1/readings/station-01/stream # live readings
docker logs edge # access log, real client addressesThe production broker is closed: port 8883 is open to the internet,
because the ESP32 boards connect from dynamic addresses, but every client
needs TLS, a password and an ACL entry. See
Production broker.
The development broker keeps allow_anonymous true on port 1883, which
is acceptable only on a development LAN.
One setting is still open and must be changed before this stack serves real users:
--without-auth: the InfluxDB API accepts any request, including deletes. Its port is bound to127.0.0.1, so only processes on the VM reach it. Remove the flag and issue an admin token.