# 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:` (e.g. `latest`, `0.6.0`, `v0.6.0`, or `0.7.0-rc1`) | | Ingestor | `ghcr.io/l5yth/potato-mesh-ingestor-linux-amd64:` (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. ```env 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. | | `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](https://raw.githubusercontent.com/l5yth/potato-mesh/main/docker-compose.yml)). 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. 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: ```yaml volumes: - ./my-pages:/app/pages ``` ## Start the stack From the directory containing the Compose file: ```bash 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: ```bash COMPOSE_PROFILES=bridge docker compose up -d ``` ## Updating ```bash 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`](deploy/nginx.example.conf); the notes below explain the parts that matter. Forwarded headers (required). The app derives its public scheme and host - used for `INSTANCE_DOMAIN`, page metadata, the sitemap, and federation links - from `X-Forwarded-Proto` and the `Host` header. Forward both, or generated URLs resolve to the wrong scheme/host: ```nginx proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; ``` Static-asset caching. Every JS module and `base.css` is versioned (`?v=`) 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](https://github.com/l5yth/potato-mesh/issues/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 /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. Verify after `nginx -t && systemctl reload nginx`: ```bash curl -sD- -H 'Accept-Encoding: gzip' https:///assets/js/app/main.js?v= -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](https://docs.docker.com/compose/).