Files
potato-mesh/DOCKER.md
T

12 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:

  1. Any deployment (portable): have the app emit the headers itself. The container bakes assets into the image at /app/public with no volume, so a host proxy cannot read them from disk - this is the only option for the Compose/GHCR stack. Tracked in #870.
  2. Bare-metal (repo checkout): serve /assets/ straight from nginx off disk (the location /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-mount web/public into 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 read web/public. Check with namei -l <path>/web/public/assets/styles/base.css - every parent directory needs o+x, or disk-served assets return 403.
  • Upstream keepalive needs both proxy_http_version 1.1 and proxy_set_header Connection "".
  • TLS session resumption: don't add ssl_session_tickets on; alongside Certbot's options-ssl-nginx.conf - it already sets ssl_session_tickets off and ships its own ssl_session_cache; duplicating the directive is a fatal config error. Leave it off.

Rate limits (optional). The example carries two commented limits: federation peers by User-Agent, and per client address the API requests the response cache does not answer (any since or before, or a collection limit other than 10, 200 or 1000) plus /api/telemetry/aggregated. Both answer with the example's one limit_req_status 429; line: uncomment it once. The read limit:

# http { }
map "$uri?$args" $potatomesh_read {
    default                         "";
    ~^/api/telemetry/aggregated[?]  $binary_remote_addr;
    "~*^/api/.*[?&]((s|%73)(i|%69)(n|%6e)(c|%63)(e|%65)|(b|%62)(e|%65)(f|%66)(o|%6f)(r|%72)(e|%65))="  $binary_remote_addr;
    "~*^/api/(nodes|positions|telemetry|neighbors|traces|ingestors|waypoints|messages)/?[?](.*&)?(l|%6c)(i|%69)(m|%6d)(i|%69)(t|%74)=(?!(10|200|1000)(&|$))"  $binary_remote_addr;
}
limit_req_zone $potatomesh_read zone=potatomesh_reads:10m rate=40r/s;

# location / { }
limit_req zone=potatomesh_reads burst=2400 nodelay;

An open dashboard sends up to 8 of these requests a second on a busy mesh, and visitors behind one NAT (camps, venues) share one address: set rate to 8r/s per such visitor.

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 - size MAX_THREADS above your expected concurrent SSE clients plus API/ingest headroom. Override with MIN_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/ttyACM0 or add your user to the dialout group.
  • Port already in use: identify the conflicting service with sudo lsof -i :41447.
  • Viewing logs: docker compose logs -f tails output from both services.

For general Docker support, consult the Docker Compose documentation.