feat(train): the instance's own map tiles, drawn by Train's style (ADR 0030, D50)

Protomaps' basemap cut for Europe and served by pmtiles beside Train's server
(compose.maps.yaml), drawn by Train's own style, light and dark, which the
server serves. Only people signed in get tiles: map.source hands a key that
Caddy has the server check before each tile; nothing logs a tile.

Also fixes .dockerignore, which left apps/train/ out of the build context, so
Train's server's and BRouter's images couldn't have been built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-29 16:04:05 +01:00
commit 190c3872f4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 3726 additions and 97 deletions

View file

@ -65,6 +65,12 @@ DOMAIN=http://localhost
#MAP_SOURCE_NAME=
#MAP_ATTRIBUTION=
#MAP_ABOUT_URL=
# With Train's own map (compose.maps.yaml, sidecars/README.md, Train's map; ADR 0030,
# D50), after compose.train.yaml in COMPOSE_FILE: the secret its tiles' keys are made
# with (openssl rand -hex 32), and the region cut, `west,south,east,north` in degrees
# (empty: Europe's, -25,35,45,75). MAP_STYLE_URL is then not used.
#MAP_KEY_SECRET=
#MAP_REGION=
# Postgres password, shared by the postgres and server services.
# Generate: openssl rand -base64 32

68
deploy/compose.maps.yaml Normal file
View file

@ -0,0 +1,68 @@
# The instance's own map for Halkyon Train (ADR 0030, D50), beside compose.train.yaml,
# which it needs: Protomaps' basemap cut for Europe, served by pmtiles as ordinary
# tiles, which Train's style (served by Train's server) draws. Switched on in .env:
#
# COMPOSE_FILE=compose.yaml:compose.train.yaml:compose.maps.yaml
# MAP_KEY_SECRET=<openssl rand -hex 32>
#
# Only people signed in to Train get tiles: Caddy asks Train's server about the key
# each tile comes with before passing it on (sidecars/train-maps.caddy). Neither
# logs a tile, as what was asked shows the area viewed. The cut (some 45 GB, to be
# measured) is in ./maps/tiles, renewed monthly, owned by a user ID of its own, which
# no person, no other container and not the CI runner uses. Setting it up:
# sidecars/README.md, Train's map.
services:
map-tiles:
build:
context: ..
dockerfile: deploy/maps/Dockerfile
image: halkyon-maps
restart: unless-stopped
user: "61178:61178"
read_only: true
volumes:
- ./maps/tiles:/tiles:ro
# Beside Caddy alone: no site of its own, no published port, no way out.
networks:
- tiles
mem_limit: 512m
# No log: a tile's address is where someone looked.
logging:
driver: none
map-tiles-refresh:
image: halkyon-maps
restart: unless-stopped
user: "61178:61178"
read_only: true
command: ["/app/tiles.sh"]
environment:
# `west,south,east,north`; Europe's.
MAP_REGION: ${MAP_REGION:--25,35,45,75}
volumes:
- ./maps/tiles:/tiles
# Out to Protomaps' builds alone; on a network of its own, beside nothing.
networks:
- downloads
mem_limit: 512m
logging:
driver: journald
train-server:
environment:
# The tiles as Caddy serves them, and the secret their keys are made with.
MAP_TILES_URL: https://${TRAIN_DOMAIN}/tiles/europe/{z}/{x}/{y}.mvt
SERVERPOD_PASSWORD_mapKeySecret: ${MAP_KEY_SECRET:?set MAP_KEY_SECRET in deploy/.env}
caddy:
volumes:
- ./sidecars/train-maps.caddy:/etc/caddy/train/maps.caddy:ro
networks:
tiles: {}
networks:
# Caddy and the tiles, without a way out.
tiles:
internal: true
downloads:

22
deploy/maps/Dockerfile Normal file
View file

@ -0,0 +1,22 @@
# The instance's own map tiles (Halkyon Train's ADR 0030, D50): go-pmtiles, checked
# against GitHub's SHA-256 of its release, serving the region's cut of Protomaps'
# basemap, and cutting it anew each month (tiles.sh). Built from the repository root:
#
# docker build -f deploy/maps/Dockerfile .
FROM alpine:3.22
# go-pmtiles v1.31.2, released 22 Jul 2026 (BSD-3-Clause).
ADD --checksum=sha256:3ed7dbf4ec2e6dfe5e25b6f70d1ffc932729f93c86db353bf514dd71010a312f \
https://github.com/protomaps/go-pmtiles/releases/download/v1.31.2/go-pmtiles_1.31.2_Linux_x86_64.tar.gz /tmp/pmtiles.tar.gz
RUN apk add --no-cache curl ca-certificates \
&& mkdir -p /app \
&& tar -xzf /tmp/pmtiles.tar.gz -C /app pmtiles \
&& rm /tmp/pmtiles.tar.gz
COPY --chmod=755 deploy/maps/tiles.sh /app/tiles.sh
ENV PATH="/app:$PATH"
EXPOSE 8080
# The tiles in /tiles (a volume), `/<name>/{z}/{x}/{y}.mvt` for each `<name>.pmtiles`;
# no TileJSON (Train's style names the tiles itself), no CORS (no browser asks).
CMD ["pmtiles", "serve", "/tiles", "--port", "8080", "--cache-size", "256"]

43
deploy/maps/tiles.sh Normal file
View file

@ -0,0 +1,43 @@
#!/bin/sh
# Keeps the instance's own map tiles current (Halkyon Train's ADR 0030, D50): the
# newest of Protomaps' daily builds of the planet, cut to MAP_REGION (`west,south,
# east,north` in degrees; by default Europe's, the box BRouter's segments cover) at
# zooms 0 to 15, into /tiles/europe.pmtiles; again every MAP_REFRESH_DAYS days (30)
# where a newer build has come. The cut is written into /tiles/next/ and moved over
# the old one, so the server never reads half a file; until it is done, the old one
# serves. Both at once take twice a cut's room.
set -eu
builds=https://build-metadata.protomaps.dev/builds.json
region=${MAP_REGION:--25,35,45,75}
days=${MAP_REFRESH_DAYS:-30}
cd /tiles
mkdir -p next
refresh() {
build=$(curl -fsS "$builds" | grep -o '"key": *"[0-9]\{8\}\.pmtiles"' |
grep -o '[0-9]\{8\}\.pmtiles' | sort | tail -n 1) || build=
if [ -z "$build" ]; then
echo "Protomaps' builds didn't come; the tiles stay as they are." >&2
return 0
fi
if [ -e europe.build ] && [ "$(cat europe.build)" = "$build" ]; then
echo "The tiles are $build's, the newest."
return 0
fi
rm -f next/europe.pmtiles
if pmtiles extract "https://build.protomaps.com/$build" next/europe.pmtiles \
--bbox="$region" --maxzoom=15 --download-threads=4; then
mv next/europe.pmtiles europe.pmtiles
echo "$build" >europe.build
echo "The tiles are $build's now, $(du -h europe.pmtiles | cut -f1)."
else
rm -f next/europe.pmtiles
echo "The cut of $build failed; the tiles stay as they are." >&2
fi
}
while :; do
refresh
sleep $((days * 86400))
done

View file

@ -71,6 +71,10 @@ main() {
if [ -n "$train" ]; then
docker compose build -q train-server brouter
fi
# Train's own map, where .env switches it on (compose.maps.yaml).
if docker compose config --services | grep -qx map-tiles; then
docker compose build -q map-tiles
fi
dump halkyon_learn
if [ -n "$train" ]; then
dump halkyon_train

View file

@ -20,6 +20,7 @@ site changes.
| Jellyfin, which people watch and listen to learn from | `compose.jellyfin.yaml` | `jellyfin.caddy` |
| LanguageTool, which checks spelling and grammar | `compose.languagetool.yaml` | none |
| Halkyon Train's server, with BRouter for its routes | `compose.train.yaml` | `train.caddy` |
| Train's own map tiles, beside Train | `compose.maps.yaml` | `train-maps.caddy` (in Train's site) |
Both together: `COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml:compose.languagetool.yaml`.
@ -225,3 +226,49 @@ Steps, in order; ✱ marks a step as root.
7. **Check it**: `https://train.halkyon.eu/` answers; in Train, signed in with a Learn
account, a route plans between two points of the region.
## Train's map
The instance's own map for Halkyon Train (Halkyon Train's ADR 0030, D50), beside Train
(`compose.train.yaml` first): Protomaps' basemap cut for Europe from Protomaps' daily
build (the planet's was 139 GB on 29 Oct 2026; Europe's cut is estimated at some 45 GB,
to be measured), served by `pmtiles serve` and drawn by Train's own style, which Train's
server serves. Only people signed in to Train get tiles: `map.source` hands the app a
key, which Caddy has Train's server check before each tile. No tile is logged. The cut
is renewed monthly where Protomaps has a newer build, written beside the old one and
moved over it. Off, Train's maps are OpenFreeMap's (or `MAP_STYLE_URL`'s) again.
Steps, in order; ✱ marks a step as root.
1. **Room on the disk** (the operator, on the instance's server): `df -h ~deploy`.
A cut takes some 45 GB, and twice that while a new one is cut beside it.
2. ✱ **The tiles' user and folder** (the operator, as a user with sudo): the user and
group ID 61178 are the tiles' alone, as the compose file runs them.
```sh
sudo groupadd --system --gid 61178 maptiles
sudo useradd --system --uid 61178 --gid 61178 --no-create-home --shell /usr/sbin/nologin maptiles
sudo mkdir -p ~deploy/halkyon-learn/deploy/maps/tiles
sudo chown -R maptiles:maptiles ~deploy/halkyon-learn/deploy/maps/tiles
```
3. **Switch it on** (the operator, as `deploy`): in `deploy/.env`, `compose.maps.yaml`
added to `COMPOSE_FILE` after Train's, and the secret the keys are made with:
```sh
COMPOSE_FILE=compose.yaml:compose.languagetool.yaml:compose.train.yaml:compose.maps.yaml
MAP_KEY_SECRET=<openssl rand -hex 32>
```
`MAP_STYLE_URL` is then not used. Changing `MAP_KEY_SECRET` refuses every key handed
out before, until the apps ask for the source again (as each opens).
4. **Start it** (the operator, as `deploy`): `docker compose up -d --build`. Caddy and
Train's server are recreated with it. The first cut takes hours
(`docker compose logs -f map-tiles-refresh` says when it is done); until then Train's
maps stay empty.
5. **Check it**: in Train, signed in, a workout's map is drawn and names the instance as
its source; `curl -I https://train.halkyon.eu/tiles/europe/0/0/0.mvt` is refused
with 401. Not yet tried on the server (29 Oct 2026): whether `pmtiles serve` reads a
new cut moved over the old one without a restart (else `docker compose restart
map-tiles` after each cut), and Caddy's `import` of an empty folder where the map is
off.

View file

@ -0,0 +1,14 @@
# Halkyon Train's own map (compose.maps.yaml, ADR 0030, D50), inside Train's site
# (train.caddy imports it): Train's style, public, from its web server; and the tiles,
# each passed on only once Train's server takes the key it came with
# (`Authorization: Bearer`, /map/tile-key: 204 or 401). Caddy logs no request.
handle /map/style/* {
reverse_proxy train-server:8082
}
handle_path /tiles/* {
forward_auth train-server:8082 {
uri /map/tile-key
}
reverse_proxy map-tiles:8080
}

View file

@ -1,10 +1,18 @@
# Halkyon Train's site (compose.train.yaml, ADR 0030, D46): its API server alone. Its
# web server (8082) takes only the register's calls between the instance's services
# (`/halkyon-base/…`, ADR 0016), on the instance's own network, never through here.
# Halkyon Train's site (compose.train.yaml, ADR 0030, D46): its API server, and its own
# map where compose.maps.yaml switches it on (D50). Its web server (8082) takes the
# register's calls between the instance's services (`/halkyon-base/…`, ADR 0016) on the
# instance's own network, never through here; through here only the map's style and
# the tiles' check (train-maps.caddy).
{$TRAIN_DOMAIN} {
encode zstd gzip
# Train's own map where compose.maps.yaml mounts it (train-maps.caddy); none
# without.
import /etc/caddy/train/*.caddy
# Serverpod's streaming methods use WebSockets; reverse_proxy upgrades them as-is.
# The visitor's address comes last in X-Forwarded-For, as for Learn's server.
reverse_proxy train-server:8080
handle {
reverse_proxy train-server:8080
}
}