Production deployment¶
How to run Offene Quelle on a public server: the map and the docs behind a reverse proxy that handles TLS. For local development, the services and the data directory, see Deployment.
flowchart LR
B[Browser] -->|HTTPS| P[Reverse proxy<br/>TLS, compression, headers]
P -->|127.0.0.1:8080| V[viewer]
P -->|127.0.0.1:8081| D[docs]
V --- DD[(data directory)]
D -.read only.- DD
O[openwater<br/>batch job, cron] --> DD
V -->|basemap tiles| T[OpenFreeMap]
The containers speak plain HTTP and listen on localhost only. Hostnames, TLS certificates, compression and security headers are the reverse proxy's job.
On the server, everything runs through ./server.sh in the repository root. It needs only bash and Docker Compose, not make. The Makefile is for local development.
| Command | Does |
|---|---|
./server.sh up |
Build the images, start (or update) map and docs in the background |
./server.sh update [--cached] [layers/x.py …] |
Export the layers with fresh downloads (--cached: reuse them), rebuild the docs and the tiles. Default: all scripts in layers/ |
./server.sh tiles |
Build the tiles of all layers now, not on the first visit |
./server.sh status |
Container health, read-only flag and docs URL of the viewer |
./server.sh logs |
Follow the viewer and docs logs |
./server.sh backup [dir] |
Archive the layer styles, .env and viewer.yaml (default ./backups) |
./server.sh down |
Stop all containers |
./server.sh up checks .env first and warns about public URLs still pointing to 127.0.0.1, a UID/GID that isn't yours, and an editable viewer. DRY_RUN=1 ./server.sh … prints the Docker commands instead of running them.
Before going public¶
- [ ]
.envsetsMAP_URLandDOCS_URLto the public HTTPS URLs, and./server.sh upprints no warnings - [ ]
./server.sh statusshowsviewer read-only: True - [ ] Layer styles were made locally and copied to the server (step 3)
- [ ] Tiles are built (
./server.sh updatedoes it, or./server.sh tiles) - [ ] The reverse proxy compresses responses and allows long requests (step 4)
- [ ] Ports 8080/8081 can't be reached from outside (Firewall)
- [ ] Data updates run on a schedule (Updating the data)
- [ ] Privacy notice and imprint are in place (Privacy and legal)
Requirements¶
| System | Linux server (e.g. Ubuntu 24.04) with git and Docker Engine with the Compose v2 plugin (docker compose version works). Install Docker from Docker's apt repository, or add docker-compose-v2 to Ubuntu's docker.io |
| Memory | 2 GB minimum, 4 GB recommended: openwater holds the GTFS timetable and grid data in memory, tippecanoe builds the tiles of the largest layers (40 MB GeoJSON) |
| Disk | About 1 GB per full data set today (≈ 300 MB data directory, ≈ 700 MB download cache), plus the images; leave room to grow with more regions |
| Network | Inbound 80 and 443 to the reverse proxy only; outbound HTTPS for the Overpass API, dataset downloads and image builds |
| User | A non-root user in the docker group, e.g. openwater (sudo usermod -aG docker openwater, then log in again) |
1. Install and configure¶
sudo git clone <repo> /opt/openwater
sudo chown -R openwater:openwater /opt/openwater
cd /opt/openwater
cp .env.example .env
All following commands run as that user in /opt/openwater. Edit .env:
| Variable | Production value | Why |
|---|---|---|
MAP_URL |
https://karte.example.org/ |
"Open the map" link in the docs |
DOCS_URL |
https://docs.example.org/ |
Docs button and "Details" links on the map; the docs' 404 page and sitemap |
UID, GID |
output of id -u, id -g |
Owner of the files the containers write |
BIND_ADDRESS |
127.0.0.1 (keep) |
Only the reverse proxy reaches the containers |
WEB_PORT, DOCS_PORT |
8080, 8081 or any free ports |
What the reverse proxy forwards to |
VIEWER_READ_ONLY |
leave unset (or true) |
Read-only is the default outside make dev. Never false on a public server: anyone could change or delete the layer styles |
VIEWER_DATA_DIR |
default ./data/viewer, or e.g. /srv/openwater |
Where layers, styles and tiles live |
URLs must end with /. Both are applied when a container starts, so after changing them run ./server.sh up again.
Site texts, the default project and the default basemap are in viewer/viewer.yaml, baked into the viewer image. Edit it before the first start, see Viewer → Viewer configuration.
2. Start¶
./server.sh up # first run compiles tippecanoe (about 5 min)
./server.sh status # both containers should become "healthy"
The containers have restart: unless-stopped: they come back after a reboot as long as the Docker service is enabled (sudo systemctl enable docker).
3. Load the data and copy the styles¶
./server.sh update --cached # the cache is empty, so this downloads everything once
Use ./server.sh update --cached layers/kiel.py for one script. The first run downloads the Zensus grid, the GTFS timetables and the OSM data (several hundred MB) and takes a while. At the end it rebuilds the docs and builds all tiles. Otherwise the viewer would build a layer's tiles on its first request: for the large layers that takes a while, and the first visitor stares at an empty map or the proxy times out.
The styles are made on your machine (make dev, where the viewer is editable) and copied over. From the repository on your machine:
cd data/viewer && rsync -R layers/*/style.json openwater@server:/opt/openwater/data/viewer/
Layers without a style.json are shown with the defaults from viewer.yaml. The viewer reads styles on each request, so no restart is needed.
4. Reverse proxy¶
What the proxy must do, whichever one you use:
- TLS and HTTP → HTTPS redirect.
- Compression of HTML, CSS, JavaScript, JSON and GeoJSON. The viewer sends everything uncompressed (MapLibre alone is about 1 MB). Do not compress
application/vnd.pmtiles: the map reads tiles with range requests, and the tiles inside are already compressed. Caddy's and nginx's defaults below leave it alone. - Long timeouts for the map: a request for tiles that aren't built yet waits for tippecanoe.
- Small request bodies: the only request with a body on a read-only viewer is the legend count (a few hundred bytes); 1 MB is plenty.
- Forward
Host,X-Forwarded-ForandX-Forwarded-Proto. The viewer trusts these headers (uvicorn--proxy-headers), so it builds correcthttps://redirects. - HSTS once HTTPS works.
Option A: two hostnames (recommended)¶
.env: MAP_URL=https://karte.example.org/, DOCS_URL=https://docs.example.org/
Caddy gets certificates automatically:
karte.example.org {
encode zstd gzip
request_body {
max_size 1MB
}
header Strict-Transport-Security "max-age=31536000"
header X-Content-Type-Options "nosniff"
header Referrer-Policy "strict-origin-when-cross-origin"
reverse_proxy 127.0.0.1:8080
}
docs.example.org {
encode zstd gzip
header Strict-Transport-Security "max-age=31536000"
reverse_proxy 127.0.0.1:8081
}
Caddy has no response timeout by default, so tile builds are not cut off.
nginx (certificates e.g. with certbot --nginx, which also adds the HTTP → HTTPS redirect):
server {
listen 443 ssl;
http2 on;
server_name karte.example.org;
ssl_certificate /etc/letsencrypt/live/karte.example.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/karte.example.org/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
client_max_body_size 1m;
gzip on;
gzip_types text/css text/javascript application/javascript application/json application/geo+json image/svg+xml;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 10m; # the first request for a layer's tiles builds them
}
}
server {
listen 443 ssl;
http2 on;
server_name docs.example.org;
ssl_certificate /etc/letsencrypt/live/docs.example.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/docs.example.org/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000" always;
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
}
}
The docs container already compresses and sets X-Content-Type-Options and Referrer-Policy itself.
Option B: one hostname, docs under /docs/¶
.env: MAP_URL=https://example.org/, DOCS_URL=https://example.org/docs/
All links in the map and the docs are relative, so the proxy only strips the prefix. It must also rewrite the docs server's redirects (e.g. /docs/layers → /docs/layers/), which otherwise lose the prefix and land on the map.
Caddy
example.org {
encode zstd gzip
header Strict-Transport-Security "max-age=31536000"
redir /docs /docs/
handle_path /docs/* {
reverse_proxy 127.0.0.1:8081 {
header_down Location ^/ /docs/
}
}
handle {
request_body {
max_size 1MB
}
reverse_proxy 127.0.0.1:8080
}
}
nginx: as the map server in option A, with these locations:
location = /docs { return 301 /docs/; }
location /docs/ {
proxy_pass http://127.0.0.1:8081/; # the trailing slash strips /docs
proxy_redirect / /docs/;
proxy_set_header Host $host;
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 10m;
}
The map can live under a sub-path the same way (with a trailing slash in MAP_URL).
Reverse proxy in Docker¶
For Traefik, Caddy or nginx-proxy running as containers: remove ports: from viewer and docs (in a compose.override.yaml, so git pull stays clean), attach both services to the proxy's Docker network, and route to viewer:8080 and docs:80.
Firewall¶
Docker writes its own iptables rules for published ports and bypasses ufw. With BIND_ADDRESS=127.0.0.1 that's harmless: the ports exist only on localhost. Don't change it to 0.0.0.0, or the containers are reachable without TLS and without the proxy's limits.
5. Check¶
Replace the hostnames and the layer id:
# map and docs answer over HTTPS, with HSTS
curl -sI https://karte.example.org/ | grep -i -e '^HTTP' -e strict-transport
curl -sI https://docs.example.org/ | grep -i '^HTTP'
# read-only: saving a style is refused
curl -s https://karte.example.org/api/config | grep -o '"readOnly":[a-z]*' # "readOnly":true
curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H 'Content-Type: application/json' \
-d '{}' https://karte.example.org/api/layers/kiel-hydrants/style # 403
# links between map and docs use the public URLs
curl -s https://karte.example.org/api/config | grep -o '"docsUrl":"[^"]*"'
# compression, and range requests for tiles
curl -sI -H 'Accept-Encoding: gzip' https://karte.example.org/vendor/maplibre-gl/maplibre-gl.mjs | grep -i content-encoding
curl -sI -H 'Range: bytes=0-15' https://karte.example.org/api/layers/kiel-hydrants/tiles.pmtiles | grep -i '^HTTP' # 206
Then open the map in a browser: the default project loads, the layers draw, Docs opens the docs, and Open the map in the docs leads back.
Updating the data¶
OSM changes continuously. ./server.sh update re-runs all layer scripts with fresh downloads, rebuilds the docs and builds the new tiles. Run it from the deploy user's crontab (crontab -e), e.g. every Monday at 04:00:
0 4 * * 1 /opt/openwater/server.sh update >> /opt/openwater/data/update.log 2>&1
- A fresh update downloads everything again, the large datasets included, once per script. That is several GB per run with all regions. If that is too much, name the scripts that matter (
./server.sh update layers/kiel.py layers/hamburg.py) or refresh all of them monthly. - The viewer keeps serving while the scripts run and picks up each new
layer.jsonimmediately. Saved styles are kept. - If a script fails, the update stops and the docs aren't rebuilt. Check
data/update.log; each line has a timestamp.
Deploying a new version¶
cd /opt/openwater
git pull
./server.sh up # rebuilds changed images and recreates their containers
If the release changes the layer scripts or the layer format, run ./server.sh update --cached afterwards. A changed viewer/viewer.yaml is part of the image and needs only ./server.sh up.
Backups¶
The download cache and everything openwater writes can be regenerated. ./server.sh backup archives what can't:
| What | Where |
|---|---|
| Layer styles | data/viewer/layers/*/style.json |
| Server settings | .env |
| Viewer configuration (if changed on the server) | viewer/viewer.yaml |
The archive lands in ./backups/ (or the directory you pass). That is the same disk, so copy it somewhere else, e.g. a weekly cron line after the update:
30 5 * * 1 /opt/openwater/server.sh backup /opt/openwater/backups && rsync -a /opt/openwater/backups/ backup-host:openwater/
Privacy and legal¶
For a public deployment in Germany:
- Basemap: the map's background tiles come from a third party (by default OpenFreeMap), so visitors' IP addresses go to that server. Mention it in the privacy notice, or self-host a basemap and set
basemap.defaultinviewer/viewer.yaml. Everything else (MapLibre, PMTiles, the docs' fonts and Mermaid) is served from the containers. - Access logs of the reverse proxy contain IP addresses. Set a retention period (e.g. 7 days via logrotate) and mention it in the privacy notice.
- Imprint and privacy notice: the map has no link to an imprint or a privacy notice yet.
site.introinviewer.yamlis plain text, so it can't hold links. Until the viewer gets a place for them, publish both pages under the same domain and point to them in the intro text. - Attribution: OpenStreetMap (ODbL) and the other sources are credited in the map's attribution and in the About dialog, from each layer's sources.
Troubleshooting¶
| Symptom | Cause and fix |
|---|---|
docker compose (v2) is not available |
Compose plugin missing, or the user isn't in the docker group (log in again after adding it) |
| 502 Bad Gateway | Container not running or wrong port: ./server.sh status, compare WEB_PORT/DOCS_PORT with the proxy |
| Map stays empty on first load, or 504 | Tiles are being built: ./server.sh tiles, raise the proxy timeout |
Docs button or "Open the map" points to 127.0.0.1 |
DOCS_URL/MAP_URL not set in .env; fix and ./server.sh up |
| "This viewer is read-only" when saving | Intended. Style locally and copy style.json over |
| Containers can't write to the data directory | Wrong UID/GID in .env (./server.sh up warns), or Docker created the folder as root: sudo chown -R $(id -u):$(id -g) data |
Redirects under /docs/ land on the map |
The proxy doesn't rewrite Location headers, see option B |
| Docs show old layers | The docs are built when the container starts: docker compose restart docs |