11 KiB
PotatoMesh Docker Guide
PotatoMesh publishes ready-to-run container images to the GitHub Packages container registry (GHCR). You do not need to clone the repository to deploy them - Compose will pull the latest release images for you.
Prerequisites
- Docker Engine 24+ or Docker Desktop with the Compose plugin
- Access to
/dev/ttyACM*(or equivalent) if you plan to attach a Meshtastic device to the ingestor container - An API token that authorises the ingestor to post to your PotatoMesh instance
Images on GHCR
| Service | Image |
|---|---|
| Web UI | ghcr.io/l5yth/potato-mesh-web-linux-amd64:<tag> (e.g. latest, 0.6.0, v0.6.0, or 0.7.0-rc1) |
| Ingestor | ghcr.io/l5yth/potato-mesh-ingestor-linux-amd64:<tag> (e.g. latest, 0.6.0, v0.6.0, or 0.7.0-rc1) |
Images are published for every tagged release. Stable builds receive both
semantic version tags (for example 0.6.0) and a matching v-prefixed tag (for
example v0.6.0), plus a latest tag that tracks the newest stable release.
Pre-release tags (for example -rc, -beta, -alpha, or -dev suffixes) are
published only with their explicit version strings (0.7.0-rc1 and v0.7.0-rc1
in this example) and do not advance latest. Pin the versioned tags when
you need a specific build.
Configure environment
Create a .env file alongside your Compose file and populate the variables you
need. At a minimum you must set API_TOKEN so the ingestor can authenticate
against the web API.
API_TOKEN=replace-with-a-strong-token
SITE_NAME=PotatoMesh Demo
CONNECTION=/dev/ttyACM0
INSTANCE_DOMAIN=mesh.example.org
Additional environment variables are optional:
| Variable | Default | Purpose |
|---|---|---|
API_TOKEN |
required | Shared secret used by the ingestor and API clients for authenticated POST requests. |
INSTANCE_DOMAIN |
auto-detected | Public hostname (optionally with port) advertised by the web UI, metadata, and API responses. With a scheme (https://mesh.example.org), generated URLs keep that scheme whatever the proxy forwards. |
SITE_NAME |
"PotatoMesh Demo" |
Title and branding surfaced in the web UI. |
CHANNEL |
"#LongFast" |
Default LoRa channel label displayed on the dashboard. |
FREQUENCY |
"915MHz" |
Default LoRa frequency description shown in the UI. |
CONTACT_LINK |
"#potatomesh:dod.ngo" |
Chat link or Matrix room alias rendered in UI footers and overlays. |
MAP_CENTER |
38.761944,-27.090833 |
Latitude and longitude that centre the map view. |
MAP_ZOOM |
unset | Fixed Leaflet zoom (disables the auto-fit checkbox when set). |
MAX_DISTANCE |
42 |
Maximum relationship distance (km) before edges are hidden. |
DEBUG |
0 |
Enables verbose logging across services when set to 1. |
ALLOWED_CHANNELS |
unset | Comma-separated channel names the ingestor accepts; packets from other channels are dropped before HIDDEN_CHANNELS applies. The node list sent at connect can still carry a node's last position, metrics and signal details (last heard, SNR, hop limit) heard on a filtered channel. |
HIDDEN_CHANNELS |
unset | Comma-separated channel names whose packets the ingestor drops. The node list sent at connect can still carry a node's last position, metrics and signal details (last heard, SNR, hop limit) heard on a filtered channel. |
FEDERATION |
1 |
Controls whether the instance announces itself and crawls peers (1) or stays isolated (0). |
PRIVATE |
0 |
Restricts public visibility and disables chat/message endpoints when set to 1. |
CONNECTION |
/dev/ttyACM0 |
Serial device, TCP endpoint, or Bluetooth target used by the ingestor to reach the radio. |
MIN_THREADS |
16 |
Minimum Puma worker threads kept warm on the web service. |
MAX_THREADS |
96 |
Maximum Puma worker threads on the web service. Each active /api/events (SSE) stream pins one thread, so keep this above your peak concurrent SSE clients plus API/ingest headroom. |
The ingestor posts to the URL configured via INSTANCE_DOMAIN (defaulting to
http://web:41447 in the provided compose file). CHANNEL_INDEX sets the
channel the activity announcement is sent on; it does not filter what is
ingested.
Docker Compose file
Use the docker-compose.yml file provided in the repository (or download the
raw file from GitHub).
It already references the published GHCR images, defines persistent volumes for
data, configuration, and logs, and includes optional bridge-profile services for
environments that require classic port mapping. Place this file in the same
directory as your .env file so Compose can pick up both.
The dedicated configuration volume binds to /app/.config/potato-mesh inside
the container, storing the instance private key and staged
/.well-known/potato-mesh documents. Credentials survive reboot and re-deploy
as long as this volume persists.
If the web container restarts in a loop, or exits, and its log shows
Instance private key file cannot be parsed, restore the file it names
(keyfile in this volume) from a backup, or delete that file to start with a new
key. A new key gives the instance a new id; peers replace its old record when it
next announces.
The potatomesh_pages volume mounts to /app/pages and holds operator-managed
Markdown files that are rendered as static content pages in the web UI. On first
start the default 1-about.md page is copied from the image into the volume.
You can add, edit, or remove .md files in this volume to customise your
instance's navigation. To use a host directory instead of a named volume, replace
the volume entry with a bind mount:
volumes:
- ./my-pages:/app/pages
Start the stack
From the directory containing the Compose file:
docker compose up -d
Docker automatically pulls the GHCR images when they are not present locally.
The dashboard becomes available at http://127.0.0.1:41447. Use the bridge
profile when you need to map the port explicitly:
COMPOSE_PROFILES=bridge docker compose up -d
Updating
docker compose pull
docker compose up -d
Running behind a reverse proxy (TLS + static assets)
The web container serves plain HTTP on port 41447. For any public deployment,
terminate TLS in a reverse proxy in front of it. A ready-to-adapt nginx example
lives at deploy/nginx.example.conf; the notes
below explain the parts that matter.
Forwarded headers (required). Generated URLs (page metadata, the sitemap) take
their scheme from INSTANCE_DOMAIN when it names one (https://mesh.example.org),
else from Forwarded or X-Forwarded-Proto, http or https only; without an
INSTANCE_DOMAIN their host comes from Host. Set Host and
X-Forwarded-Proto, and clear Forwarded:
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Forwarded "";
Security headers. The app sends Content-Security-Policy on HTML pages and
Referrer-Policy: strict-origin-when-cross-origin on every response. Do not set
either header in the proxy.
Static-asset caching. Every JS module and base.css is versioned
(?v=<APP_VERSION>) and safe to cache immutably for a year. Images,
icons, and fonts are not versioned - keep a short TTL with revalidation. Two
ways to serve the versioned assets with long-lived caching:
- Any deployment (portable): have the app emit the headers itself. The
container bakes assets into the image at
/app/publicwith no volume, so a host proxy cannot read them from disk - this is the only option for the Compose/GHCR stack. Tracked in #870. - Bare-metal (repo checkout): serve
/assets/straight from nginx off disk (thelocation /assets/block in the example). This also keeps the ~50 per-page ES-module requests off the single Ruby process. For containers, only do this if you bind-mountweb/publicinto an nginx sidecar.
Three things that bite in practice - all handled in the example file:
- Filesystem permissions: the proxy worker user (
http,www-data, …) must be able to traverse to and readweb/public. Check withnamei -l <path>/web/public/assets/styles/base.css- every parent directory needso+x, or disk-served assets return403. - Upstream keepalive needs both
proxy_http_version 1.1andproxy_set_header Connection "". - TLS session resumption: don't add
ssl_session_tickets on;alongside Certbot'soptions-ssl-nginx.conf- it already setsssl_session_tickets offand ships its ownssl_session_cache; duplicating the directive is a fatal config error. Leave it off.
Verify after nginx -t && systemctl reload nginx:
curl -sD- -H 'Accept-Encoding: gzip' https://<host>/assets/js/app/main.js?v=<ver> -o /dev/null \
| grep -i 'cache-control\|content-encoding'
# expect: cache-control: public, max-age=31536000, immutable and content-encoding: gzip
Performance & scaling
The web app runs as a single Puma process with a bounded thread pool
(MIN_THREADS:MAX_THREADS, default 16:96). Keep hot read paths cheap and
let the reverse proxy absorb static traffic.
- Thread pool. Each live-update SSE stream (
GET /api/events) pins one thread for its lifetime - sizeMAX_THREADSabove your expected concurrent SSE clients plus API/ingest headroom. Override withMIN_THREADS/MAX_THREADS. - Static assets. Serve
/assets/from the reverse proxy (or via app-level immutable headers) so the module fan-out and revalidations never touch Ruby - see the section above. - Cluster (multi-process) mode is not supported. To scale on one host: front it with the reverse proxy, serve assets from disk, and keep queries cheap.
Troubleshooting
- Serial device permissions (Linux/macOS): grant access with
sudo chmod 666 /dev/ttyACM0or add your user to thedialoutgroup. - Port already in use: identify the conflicting service with
sudo lsof -i :41447. - Viewing logs:
docker compose logs -ftails output from both services.
For general Docker support, consult the Docker Compose documentation.