Self-hostable railway infrastructure tile server powered by OpenStreetMap data. Generates Luxembourg-focused railway infrastructure tiles with cross-border context from Belgium, Germany, and France, then serves them via MapLibre + Martin.
This project produces a transparent vector tile overlay of railway infrastructure relevant to Luxembourg, including adjacent cross-border context from Belgium, Germany, and France.
Pre-built images are available on GitHub Container Registry:
docker pull ghcr.io/spillgebees/lux-railway-map-overlay:latestTwo workflows:
- Self-hosted: Docker-based vector tile server (Martin + nginx), with an optional Blazor WASM viewer for local testing
- Raw data for further processing: generates vector tiles (
.mbtiles) and a GeoPackage
- Generate railway data (runs inside Docker, so no local tools are needed):
LOCAL_UID=$(id -u) LOCAL_GID=$(id -g) docker compose --profile generate run --rm generateThe Dockerfile defaults to the Luxembourg coverage set (lu,be,de,fr) so the generated dataset includes cross-border infrastructure that matters operationally around Luxembourg. The first run downloads ~8 GB of PBF data from Geofabrik and queries the Overpass API for route relations. Subsequent runs skip already-downloaded PBFs.
The explicit LOCAL_UID and LOCAL_GID ensure generated files are owned by your host user instead of root. If your shell already exports matching values, the Compose defaults are sufficient.
Route extraction is strict by default. If Overpass is unavailable, generation now fails instead of silently producing empty route layers. For local/manual runs where you explicitly accept missing routes, pass --allow-missing-routes to the generator command.
- Start the tile server:
docker compose up- Start the Blazor viewer:
cd viewer && dotnet run --project RailwayViewer.csproj- Browse to the URL shown by
dotnet run
The .NET solution now lives in viewer/RailwayViewer.slnx, alongside the Blazor project.
Generated data is split by purpose under data/:
data/cache/: reusable downloaded and queried inputsdata/intermediate/: working files produced during generationdata/out/: final deliverables consumed by runtime and external integrations
Operational contract:
data/cache/is safe to retain across runs and should be the only place that accumulates reusable downloads.data/intermediate/is disposable working state; CI and local reruns may replace it at any time.data/out/is the only directory the runtime image and external integrations should depend on.- The publish workflow validates route presence from
data/intermediate/geojson/and bundles onlydata/out/lux-railway-map-overlay.mbtiles.
LOCAL_UID=$(id -u) LOCAL_GID=$(id -g) docker compose --profile generate run --rm generate --countries luFor a local exploratory build that may proceed without routes:
LOCAL_UID=$(id -u) LOCAL_GID=$(id -g) docker compose --profile generate run --rm generate --countries lu --allow-missing-routesIf you have osmium-tool, gdal-bin, python3 (3.13+), and tippecanoe installed locally:
python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
cd scripts && python3 -m generator --countries lu --output-dir ../data
docker compose upTo allow empty route layers during a local manual run:
cd scripts && python3 -m generator --countries lu --output-dir ../data --allow-missing-routesflowchart TD
A["Geofabrik PBF extracts<br/>(lu, be, de, fr)"] -->|osmium tags-filter| B["Filtered railway PBFs"]
B -->|osmium merge| C["Merged PBF"]
C -->|ogr2ogr| D["GeoJSON layers<br/>(EPSG:4326)"]
C -->|ogr2ogr| E["Shapefiles<br/>(EPSG:3857)"]
C -->|ogr2ogr| F["GeoPackage<br/>(EPSG:4326)"]
G["Overpass API"] -->|route relations| H["Route GeoJSON<br/>(canonical + display)"]
D --> I["tippecanoe<br/>(3-pass strategy)"]
H --> I
I -->|tile-join| J["lux-railway-map-overlay.mbtiles"]
J --> K["Martin tile server"]
K --> L["nginx reverse proxy<br/>(port 3000)"]
| Tool | Purpose |
|---|---|
| osmium-tool | Filter and merge OpenStreetMap PBF extracts |
| GDAL/ogr2ogr | Convert PBF data to GeoJSON, Shapefiles, and GeoPackage |
| tippecanoe | Generate vector tiles (.mbtiles) from GeoJSON with zoom-level control |
| Martin | Serve vector tiles and sprites from MBTiles |
| MapLibre GL | Client-side vector tile rendering (style specification) |
| Overpass API | Query OpenStreetMap for route relations |
The pipeline uses a 3-pass tippecanoe strategy to handle the different density characteristics of the data:
- Lines: track geometry at all zooms, no feature dropping (
-r1 --no-tile-size-limit), because the Luxembourg regional coverage area cannot fit in 500 KB tiles at low zoom - Stations & routes: point features that must always be present at their styled zoom, no dropping (
-r1) - Detail: dense infrastructure (signals, switches, crossings) that can be thinned at low zoom (
--drop-densest-as-needed)
The three intermediate .mbtiles are merged with tile-join into the final data/out/lux-railway-map-overlay.mbtiles. Martin serves uncompressed tiles and handles gzip/brotli compression on the wire.
Detailed generator architecture, route extraction notes, and data-flow diagrams live in docs/developer-pipeline.md.
This repository uses two GitHub Actions workflows with intentionally different scopes:
validate.yml: fast validation for pull requests and main-branch pushes affecting the generator, tests, styles, tiles, or workflow definitions. It runs Python formatting checks, the Python unit tests, and build-only Docker validation for the generator and tile-server images.publish-image.yml: the heavy release pipeline for main only. It generates data, verifies that route layers are present, builds the bundled production image, and publishes it.
The validation workflow does not download Geofabrik extracts or query the Overpass API. Those expensive external dependencies are reserved for the publish workflow and for explicit local generation commands.
The repository includes a lightweight task layer in .vscode/tasks.json for the common local flows:
Generate Data: run the Dockerized generator with host UID/GID mapping.Start Tile Server: rundocker compose upfor the local Martin/nginx stack.Run Viewer: start the Blazor viewer, with an optional prompt for the externalSpillgebees.Blazor.Mapproject path when you are not using one of the default sibling worktree locations (see Viewer Dependency below).Validate Fast: run the same lightweight checks used by the fast validation workflow.
These tasks are intended as the default local command surface so the common workflows stay consistent across contributors.
The demo viewer currently builds against a local Spillgebees.Blazor.Map project reference that lives outside this repository.
Supported resolution order:
BLAZOR_MAP_PROJECT_PATHenvironment variable.BlazorMapProjectPathMSBuild property.- Sibling worktree at
../../worktrees/Blazor.Map/feature/maptiler-pivot/src/Spillgebees.Blazor.Map/Spillgebees.Blazor.Map.csproj. - Sibling worktree at
../../worktrees/Blazor.Map/src/Spillgebees.Blazor.Map/Spillgebees.Blazor.Map.csproj.
Examples:
export BLAZOR_MAP_PROJECT_PATH=/absolute/path/to/Spillgebees.Blazor.Map.csproj
cd viewer && dotnet run --project RailwayViewer.csprojcd viewer && dotnet run --project RailwayViewer.csproj /p:BlazorMapProjectPath=/absolute/path/to/Spillgebees.Blazor.Map.csprojThe repository also ships local pre-commit hooks for the same lightweight checks developers should run before pushing:
blackfor Python formattingcsharpierfor C# and project-file formattingactionlintfor GitHub Actions workflow validation
Install and enable them from the repository root:
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/pre-commit install
dotnet tool restoreRun all configured hooks on demand:
.venv/bin/pre-commit run --all-filesThe fast local equivalents of the CI checks are:
.venv/bin/black --check scripts tests
.venv/bin/pytest
dotnet csharpier check viewer
actionlintThe tiles service runs Martin behind nginx on port 3000. Key endpoints:
| Endpoint | Description |
|---|---|
/style.json |
MapLibre style with rewritten URLs |
/fonts/{fontstack}/{range}.pbf |
Self-hosted glyph PBFs |
/sprite/symbols |
SVG sprite sheet |
/lux-railway-map-overlay |
TileJSON metadata |
/lux-railway-map-overlay/{z}/{x}/{y} |
Vector tile endpoint |
/catalog |
Martin tile catalog |
/health |
Health check |
By default, URLs in style.json point to http://localhost:3000. To rewrite them for production, pass PUBLIC_URL as an environment variable when running Compose, or set it in a local .env file that Compose reads:
PUBLIC_URL=https://tiles.example.com docker compose upPUBLIC_URL=https://tiles.example.comWhen deploying behind Cloudflare, vector tiles are cacheable by default (.pbf responses). The nginx layer sets appropriate cache headers. No special configuration is needed beyond proxying through Cloudflare.
The tile image ships self-hosted MapLibre glyphs for these font stacks:
IBM Plex Sans RegularIBM Plex Sans BoldNoto Sans RegularNoto Sans ItalicNoto Sans Bold
These are served from /fonts/{fontstack}/{range}.pbf and are used by styles/style.json.
Map styling is defined in styles/style.json, a MapLibre Style Specification document with 46 layers across a transparent background. The styles are original work licensed under MIT.
Every feature type is distinguishable by at least two visual channels (color + dash pattern, color + shape, or color + luminance contrast) so the overlay remains usable for people with color vision deficiency and on any basemap.
Visible by default:
-
Railway tracks: one layer per type (rail, light rail, subway, narrow gauge, monorail, funicular, miniature), each with a unique color and dash pattern, plus service tracks in neutral gray
-
Tunnels: desaturated lines with tunnel entrance icons and name labels
-
Lifecycle states: one layer per state (construction, proposed, disused, abandoned, preserved, razed), each with a unique dash pattern and opacity
-
Stations & halts: circles with name, operator, and railway ref labels; sort priority by UIC reference
-
Border crossings: white circles with dark stroke at network boundaries
-
Platforms: purple fill at z12-14, transitioning to 3D extruded platforms at z14+ with ref labels
-
Routes: railway route relations colored by OSM
colourtag, offset from the track (route lines and labels visible, casing hidden by default)
Hidden by default (toggled on by the user):
- Tram: tram lines (dashed), tunnels, stops (rounded-square icon), subway entrances (triangle icon), and lifecycle states
- Infrastructure: switches, signals, buffer stops, milestones, turntables, derails, track crossings, owner changes
- Crossings: road-rail level crossings and tram crossings
For a full integration guide with source layer reference, property tables, and working examples, see docs/consumer-integration.md.
To overlay the railway tiles on your own basemap in MapLibre:
const map = new maplibregl.Map({
style: "your-basemap-style-url",
});
// Add railway tiles as a second style
map.addSource("railway", {
type: "vector",
url: "https://tiles.example.com/lux-railway-map-overlay"
});Or use MapLibre's style composition to load the full railway style as an overlay:
Styles: [basemap-style-url, "https://tiles.example.com/style.json"]
MapLibre text rendering uses style glyph endpoints, not browser web fonts. In practice that means composed styles need one shared glyph service that contains every font stack referenced by every participating style.
This repository hosts glyphs for the railway overlay's stacks and for Noto Sans, which is commonly required by third-party basemaps. If your basemap also uses other stacks, one of these must be true:
- Both styles resolve to the same glyph endpoint, and that endpoint serves all required stacks.
- You extend tiles/glyphs/generate-glyphs.sh to add the missing fonts and rebuild the tile image.
- Your map component supports an explicit composed glyph override and you point both styles at a compatible shared glyph service.
Supplying CSS @font-face web fonts alone does not satisfy MapLibre's glyphs requirement.
Railway data is sourced from OpenStreetMap and licensed under the Open Database License (ODbL).
(c) OpenStreetMap contributors
For any public distribution of generated database-form outputs such as the GeoPackage, or for a public service backed directly by those database artifacts, keep the OpenStreetMap attribution visible and be prepared to provide the corresponding derived database under the ODbL terms.
See THIRD_PARTY_NOTICES.md for a summary of third-party software, fonts, and data-source notices used by this repository.
This project is licensed under the MIT License.
