MeshInfo uses PostgreSQL as its storage backend. This document covers configuration, schema, and operational guidance.
Add the following to your config.toml:
[storage.postgres]
enabled = true
host = "postgres"
port = 5432
database = "meshinfo"
username = "postgres"
password = "your_password"
min_pool_size = 1
max_pool_size = 5postgres.enabled: Must betruepostgres.host: Database server hostname (use"localhost"if not using Docker)postgres.port: Database server port (default: 5432)postgres.database: Database namepostgres.username: Database userpostgres.password: Database passwordpostgres.min_pool_size: Minimum connection pool size (default: 1)postgres.max_pool_size: Maximum connection pool size (default: 5)
The schema is automatically created on first run. Key tables:
nodes: Core node information (ID, name, hardware, status)node_positions: Historical position datanode_neighborinfo: Neighbor relationships (JSONB)node_telemetry_current: Most recent telemetry per nodetelemetry: Complete telemetry history (JSONB payloads)chat_channels: Chat channel metadatachat_messages: All chat messagestraceroutes: Complete traceroute history (JSONB payloads)
- MQTT message received
- Handler reads existing node via
pg_storage.get_node_cached(LRU + DB) - Handler merges incoming fields into the node
data.update_nodewrites the merged node to PostgreSQL and refreshes the cache
- Application starts
- PostgreSQL connection established
- API queries PostgreSQL directly for each request (node lookups hit the LRU cache first)
- PostgreSQL write failures are logged but never block application execution
- Connection pool handles transient network issues
- Failed writes are logged for manual investigation
The postgres service in both compose files sets a few core memory
parameters via its command:. The defaults are deliberately conservative so
the stack runs on a small host, but they are already well above the stock
postgres image defaults (shared_buffers 128 MB, work_mem 4 MB).
Size them to your host: copy .env.sample to .env next to the compose file
(Docker Compose reads it automatically) and set any of:
| Variable | Default | What it does |
|---|---|---|
PG_SHARED_BUFFERS |
256MB |
Dedicated PG page cache (real RAM allocation). |
PG_WORK_MEM |
8MB |
Per-sort/hash memory; multiplied by concurrency. |
PG_MAINTENANCE_WORK_MEM |
64MB |
VACUUM / CREATE INDEX working memory. |
PG_EFFECTIVE_CACHE_SIZE |
1GB |
Planner hint for OS+PG cache (not an allocation). |
Suggested starting points by total host RAM (assuming Postgres shares the box with the rest of the MeshInfo stack — give it less than a dedicated DB host would get):
| Host RAM | PG_SHARED_BUFFERS |
PG_WORK_MEM |
PG_MAINTENANCE_WORK_MEM |
PG_EFFECTIVE_CACHE_SIZE |
|---|---|---|---|---|
| 4 GB | 512MB |
16MB |
128MB |
2GB |
| 8 GB | 1GB |
32MB |
256MB |
4GB |
| 16 GB | 2GB |
64MB |
512MB |
10GB |
Example .env:
PG_SHARED_BUFFERS=512MB
PG_WORK_MEM=16MB
PG_MAINTENANCE_WORK_MEM=128MB
PG_EFFECTIVE_CACHE_SIZE=2GB
Recreate the container to apply: docker compose up -d postgres.
All writes happen in real-time as data arrives from MQTT, ensuring minimal data loss on crash.
asyncpg connection pooling handles concurrent writes efficiently. Tune min_pool_size and max_pool_size based on your load.
Key indexes are maintained on node IDs, timestamps, and foreign key relationships for fast queries even with large datasets.
PostgreSQL operations are logged at INFO and ERROR levels:
INFO: PostgreSQL mode: Data will be queried directly from database
ERROR: Failed to write node abc123 to Postgres: connection timeout
Monitor these logs to ensure healthy operation.
If PostgreSQL connection fails on startup, MeshInfo will log the error and exit. Check:
- PostgreSQL container/service is running
- Connection settings in
config.tomlare correct (host,port,username,password) - Network connectivity between MeshInfo and the database
To verify record counts:
SELECT COUNT(*) FROM nodes;
SELECT COUNT(*) FROM chat_messages;
SELECT COUNT(*) FROM telemetry;
SELECT COUNT(*) FROM traceroutes;Very old deployments used JSON file storage. The one-time migration script
(scripts/migrate_json_to_postgres.py) was removed after the JSON store was
retired — it required the legacy [paths] config section that no longer
exists. If you still need it, run the copy from a pre-removal release
(check out a tag from before July 2026) against your legacy deployment,
then upgrade.
PostgreSQL major versions (16 → 18, etc.) use incompatible on-disk storage
formats. A newer postgres container will refuse to start against a data
volume initialised by an older one, failing with:
FATAL: database files are incompatible with server
DETAIL: The data directory was initialized by PostgreSQL version 16,
which is not compatible with this version 18.x.
When a MeshInfo release bumps the postgres image major version, run the
included migration script once. It dumps the database with a temporary
container of the old version, recreates the volume, and restores into the
new version — using only official postgres images.
git pull
docker compose pull # fetch the new postgres image
bash scripts/migrate-postgres.sh # one-time: dump old → restore new
docker compose up -dFor a development stack, point the script at the dev compose file:
COMPOSE_FILE=docker-compose-dev.yml bash scripts/migrate-postgres.shNotes:
- The script writes a compressed SQL dump to
backups/and verifies it before recreating the volume — that file is your recovery artifact. - It is a no-op on fresh installs and when the volume is already current, so it is safe to run unconditionally as part of an update.
- On Windows, run it from Git Bash (bundled with Git) —
bash scripts/migrate-postgres.sh. The script is MSYS-path-safe, so the single bash version covers every platform. - Set
KEEP_VOLUME_BACKUP=1to also snapshot the old data volume to<volume>_oldpg_backupfor an instant rollback (uses extra disk; delete it once the upgrade is verified). - To roll back from the SQL dump: set the
postgresimage (and volume mount) in your compose file back to the old version, remove the data volume so it re-initialises empty,docker compose up -d postgres, then restore —gzip -dc backups/<dump>.sql.gz | docker compose exec -T postgres psql -U postgres -d meshinfo.
mqtt_messages is the raw packet firehose and dominates the database — it
typically holds 98%+ of all rows. Fresh installs since this change get a
partitioned table (RANGE partitioned by month on created_at), so the
table stays operationally manageable as it grows: each date-range query
prunes to the relevant month(s) instead of scanning everything, and
maintenance runs at partition scale, not table scale. The app
(ensure_mqtt_partitions) keeps next month's partition created ahead of
the rollover.
Databases created before partitioning landed need a one-time conversion:
bash scripts/migrate-mqtt-partitioning.sh
# dev stack: COMPOSE_FILE=docker-compose-dev.yml bash scripts/migrate-mqtt-partitioning.shWhat it does, inside a single atomic transaction:
- Renames the existing table to
mqtt_messages_old. - Creates a new month-partitioned
mqtt_messages(with thepayloadcolumnlz4-compressed). - Creates one partition per month spanned by the data, copies every row, and verifies the row count matches before committing.
- Rebuilds indexes, re-homes the
idsequence, reinstalls the trigger.
Any failure rolls the whole thing back — mqtt_messages is untouched.
The script stops meshinfo for the duration and restarts it on success.
Notes:
- Requires lz4: the partitioned
mqtt_messagespayloadcolumn usesCOMPRESSION lz4, so Postgres must be built with lz4 support (PG 14+ — the officialpostgres:18image this stack ships with includes it). A self-hosted Postgres compiled without lz4 will reject both the fresh-install schema and the migration withcompression method lz4 not supported. - Disk: the migration needs ~3× the current
mqtt_messagessize free on the data volume transiently (new copy + WAL + headroom). The pre-flight check aborts with a clear message if there's not enough. Expand the data volume first if you're short. - Rollback safety: the original data is kept as
mqtt_messages_olduntil you drop it. Disk is not reclaimed until then. Once you've confirmed the app + Logs page look right, run:docker compose exec postgres psql -U postgres -d meshinfo \ -c 'DROP TABLE mqtt_messages_old;'
- Idempotent: re-running the script when the table is already partitioned (or doesn't exist yet) is a no-op.
- On Windows, run from Git Bash, like
migrate-postgres.sh. - Partitioning does not shrink disk — it makes a huge table manageable. Complete-history retention still implies the data volume must be allowed to grow.
Every gateway that hears a mesh packet uplinks its own copy — measured ~10-11
rows per logical packet, ~91% of archive bytes, with the decoded payload
byte-identical across copies. Since the dedup change, ingest stores one
canonical mqtt_messages row per packet (the first-heard copy, verbatim) and
records every uplink copy — including the first — as a packet_receptions
row (~70 B vs ~700 B per copy):
| column | meaning |
|---|---|
mqtt_row_id |
the canonical mqtt_messages.id this copy belongs to |
gateway |
uplinking gateway (envelope sender, else topic !suffix) |
topic_id |
interned topic string (mqtt_topics) — region paths differ per gateway |
rx_rssi rx_snr rx_time hop_limit hops_away relay_node transport |
the per-copy RF envelope |
extras |
sparse JSONB patch vs. the reconstruction template (usually NULL) |
Losslessness contract: each original copy is exactly reconstructible as
reception_template(canonical, gateway, topic, fields) + extras patch — see
storage/db/uplink_dedup.py (reconstruct_copy). Copies whose patch cannot be
stored (e.g. \u0000 escapes, which JSONB rejects) fall back to a verbatim
mqtt_messages row, so no path drops data. Packets without a mesh id
(mapreports) are stored verbatim as before.
Dedup is keyed (from_node_id, packet_id) within storage.dedup_window_seconds
(default 900 s; mesh packet ids recycle, so the window must stay bounded). The
in-memory cache survives restarts via a window-bounded DB lookup on
idx_mqtt_messages_packet. Config: storage.dedup_uplinks (default true).
Deploying onto a large existing archive: first startup builds
idx_mqtt_messages_packet (partial, WHERE packet_id IS NOT NULL) which scans
every partition once — minutes on a multi-10-GB archive, during which ingest
hasn't started yet. To avoid the pause, pre-build it while the old version is
still running (CREATE INDEX CONCURRENTLY can't target a partitioned parent,
so use the ON ONLY dance): CREATE INDEX idx_mqtt_messages_packet ON ONLY mqtt_messages (from_node_id, packet_id, created_at DESC) WHERE packet_id IS NOT NULL;, then per partition CREATE INDEX CONCURRENTLY <part>_packet_idx ON <part> (...same...) + ALTER INDEX idx_mqtt_messages_packet ATTACH PARTITION <part>_packet_idx;. The app's CREATE INDEX IF NOT EXISTS then no-ops.
Side effects to expect: total_mqtt_messages, the 24h preset split, and
per-node packet counts drop ~10× (they now count logical packets, not copies);
the Logs page shows one row per packet with a "heard N×" chip; the SSE live
stream still emits one packet event per gateway copy (all carrying the
canonical mqtt_row_id), so the live map's fan-in arcs are unaffected.
Months ingested with dedup enabled are born compact — compaction only applies
to legacy months from before the dedup deploy. The rewrite is the same lossless
transform as ingest: same window semantics, canonical rows keep their original
ids (permalinks to first-heard copies survive), payload text preserved
byte-for-byte. Every original copy remains reconstructible afterwards —
GET /v1/packets/{id}?copies=1 returns each gateway's exact original uplink
message rebuilt from its reception row.
This is a one-time, manual migration — run it once after deploying dedup, and it is done forever:
docker compose exec meshinfo \
python scripts/compact_mqtt_partitions.py --all --dry-run # discover + counts only
docker compose exec meshinfo \
python scripts/compact_mqtt_partitions.py --all [--drop-original]--all discovers every eligible pre-dedup month and sweeps them oldest-first;
--month YYYY_MM targets one at a time. Originals are kept as
mqtt_messages_YYYY_MM_precompact for review — disk is reclaimed when you
DROP them, or pass --drop-original to drop right after the verified swap.
Expect ~80-85% reduction per month. The script refuses current, mixed, or already-deduped months. Note: the month a dedup deploy lands in is mixed forever and can't be compacted — deploy early in a month to minimize it.
The archive is keep-forever, so backups are the only copy of history. The app schedules snapshots itself from config.toml — applied on every (re)start, no host cron required:
[backups]
schedule = "daily" # off | daily | weekly | monthly
keep_days = 4 # prune local dumps older than this
dir = "backups" # relative to the repo root, or absolute
remote_target = "" # optional rsync destination for off-box copiesDumps are pg_dump -Fc (compressed), validated with pg_restore --list
before being trusted, written to the ./backups mount, and pruned to
keep_days. A backup runs whenever the newest dump is older than the
schedule, so a missed window (host was off) is caught up at the next start.
scripts/backup_db.sh reads the same [backups] section for manual or
host-cron runs, and is the path that pushes to remote_target (SSH keys live
on the host, not in the container). Environment variables (KEEP_DAYS,
BACKUP_DIR, REMOTE_TARGET, CONTAINER, DB_USER, DB_NAME) override the
config for one-off runs.
- Use strong passwords for PostgreSQL
- Consider SSL/TLS for database connections in production
- Restrict database access via network policies
- Set up regular PostgreSQL backups (e.g.,
pg_dump) - Keep PostgreSQL updated with security patches
For issues or questions:
- Open an issue on GitHub
- Join #meshinfo on SacValleyMesh Discord
- Review application logs for detailed error messages