⚠️ Proof of concept – Not intended for production use.
HTTP server that generates cached WebP map tiles from OpenStreetMap with custom markers.
This project was created as a proof of concept to display earthquake coordinates in a user-friendly visual format. Instead of showing raw latitude/longitude data, it generates map images with markers indicating the exact location of seismic events, making the information more accessible and intuitive for end users.
- 🗺️ Simple REST API for map generation
- 💾 Dual-layer caching system (disk + memory)
- 🚀 Optimized OSM tile fetching with automatic retries
- 🔒 Configurable CORS and security headers
- ⚙️ YAML and environment variable configuration
- 🎨 Customizable marker styling
- 📊 Built-in health check and monitoring endpoints
- ⚡ Built with Bun for maximum performance
Pull and run the pre-built image from GitHub Container Registry:
docker pull ghcr.io/alvarosdev/staticmap-osm-generator:latest
docker run -p 3000:3000 \
-v $(pwd)/cache:/app/cache \
ghcr.io/alvarosdev/staticmap-osm-generator:latestOr using docker-compose:
docker-compose up# Install dependencies
bun install
# Development mode (with hot reload)
bun run dev
# Production mode
bun run startServer runs on http://localhost:3000 by default.
Generates a 256×256 WebP map tile with a centered marker at the specified coordinates.
Query Parameters:
| Parameter | Type | Range | Description |
|---|---|---|---|
lat |
number | -90 to 90 |
Latitude coordinate |
lon |
number | -180 to 180 |
Longitude coordinate |
zoom |
integer | 0 to 19 |
Zoom level (higher = more detail) |
marker |
string | defined in config | Optional marker image name |
anchor |
string | defined in config | Optional anchor name (defaults from marker or config) |
scale |
integer | 1 to 4 |
Output scale multiplier (e.g., 2 → 512×512) |
Example Request:
# Buenos Aires, Argentina at zoom level 12
curl "http://localhost:3000/map?lat=-34.6037&lon=-58.3816&zoom=12" -o map.webp
# New York City, USA at zoom level 15
curl "http://localhost:3000/map?lat=40.7128&lon=-74.0060&zoom=15" -o nyc.webpResponses:
200 OK– Returns WebP image (Content-Type:image/webp)400 Bad Request– Invalid or missing parameters500 Internal Server Error– Server-side error
How it works:
- Validates input parameters (latitude, longitude, zoom)
- Checks disk cache for existing image
- If not cached, fetches 4 OSM tiles in parallel (with memory cache)
- Composes tiles and draws custom marker at center
- Saves to disk cache and returns WebP with appropriate headers
- Cache key includes
scale, avoiding collisions across resolutions
Health check endpoint for monitoring and load balancers.
Response:
200 OK– Server is healthy
Returns cache statistics for monitoring performance.
Response:
{
"size": 450,
"maxSize": 1000
}size: Current number of tiles in memory cachemaxSize: Maximum cache capacity
The server implements a dual-layer caching system for optimal performance:
- Location:
cache/directory - Purpose: Stores final generated map images
- Key: Content hash from
zoom,lat,lon - Persistence: Survives server restarts
- Strategy: Check first, serve instantly if exists
- Purpose: Caches individual OSM tiles in RAM
- Type: LRU (Least Recently Used) eviction
- Configuration:
cache: maxSize: 1000 # Max tiles in memory ttlMinutes: 60 # Time to live
- Helps reduce repeated requests to OSM servers
- Automatic expiration and eviction of old tiles
Docker volume mount example:
docker run -p 3000:3000 -v $(pwd)/cache:/app/cache ghcr.io/alvarosdev/staticmap-osm-generator:latestThis ensures your disk cache persists between container restarts.
- CORS configurable - Control allowed origins, methods, and headers via config
- Security headers - X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, etc.
- Input validation - Robust validation and sanitization of all parameters
- Cache headers - Proper caching directives for optimal CDN/browser caching
Note: Rate limiting should be handled at the infrastructure level (e.g., Cloudflare, nginx)
OSM Tile Fetching:
- In-memory LRU cache for OSM tiles with configurable size and TTL
- Rate limiting (2 concurrent requests, 2 req/sec by default) to respect OSM policies
- Automatic retries with exponential backoff (3 attempts: 1s → 2s → 4s)
- Request timeouts (10s) and proper User-Agent identification
- Parallel fetching of the 4 tiles needed per map
For heavy/production traffic, do not use the public
tile.openstreetmap.org. Use your own tile server or a commercial provider. Consider increasing the tile cache TTL (e.g., 24h+) viaconfig.yamlto reduce upstream requests.
Bun Native APIs:
Bun.CryptoHasherfor fast synchronous hashing (~5x faster than Web Crypto)- Static routes for zero-allocation responses on
/healthendpoint Bun.file()for optimized file I/O with automatic streamingBun.write()for fast file writing operations- Native gzip compression (automatic in Bun)
Edit config.yaml or use environment variables:
port: 3000
cacheDir: cache
tileSize: 256
osmBaseUrl: https://tile.openstreetmap.org
marker:
radius: 8
fillColor: "#e53935"
borderColor: "black"
crossColor: "white"
maxZoom: 20
minZoom: 0
# Optional attribution bar at the bottom of the image
attribution:
enabled: true
text: "© OpenStreetMap contributors"
backgroundColor: "#000000"
textColor: "#FFFFFF"
opacity: 0.5
# Tile cache configuration
cache:
maxSize: 1000 # Maximum tiles in memory
ttlMinutes: 60 # Time to live in minutes
# CORS configuration
cors:
enabled: true # Enable/disable CORS
allowedOrigins: "*" # Allowed origins (* for all)
allowedMethods: "GET, OPTIONS" # Allowed HTTP methods
allowedHeaders: "Content-Type" # Allowed headers
maxAge: 86400 # Preflight cache duration (24 hours)Environment Variables:
| Variable | Description | Default |
|---|---|---|
PORT |
Server port | 3000 |
CACHE_DIR |
Cache directory path | cache |
NODE_ENV |
Environment mode | production |
CORS_ENABLED |
Enable CORS | true |
CORS_ALLOWED_ORIGINS |
Allowed origins | * |
CORS_ALLOWED_METHODS |
Allowed HTTP methods | GET, OPTIONS |
CORS_ALLOWED_HEADERS |
Allowed headers | Content-Type |
CORS_MAX_AGE |
Preflight cache duration (seconds) | 86400 |
| Variable | Description | Default |
|---|---|---|
OSM_USER_AGENT |
Custom User-Agent for tile requests (identify your app and contact) | staticmap-osm-generator/1.0 (+https://github.com/alvarosdev/staticmap-osm-generator) |
OSM_REFERER |
Optional Referer header sent to tile server | empty |
OSM_MAX_CONCURRENT |
Max concurrent tile requests | 2 |
OSM_REQUESTS_PER_SECOND |
Global request rate (tiles/sec) | 2 |
Public API (default):
cors:
enabled: true
allowedOrigins: "*"
allowedMethods: "GET, OPTIONS"
allowedHeaders: "Content-Type"
maxAge: 86400Specific domain:
cors:
enabled: true
allowedOrigins: "https://example.com"
allowedMethods: "GET, OPTIONS"
allowedHeaders: "Content-Type"
maxAge: 86400Multiple domains (via environment variable):
CORS_ALLOWED_ORIGINS="https://example.com, https://app.example.com"Disable CORS (behind Cloudflare/nginx):
cors:
enabled: falseOr via environment variable:
CORS_ENABLED=falseThe easiest way to run this project is using the official Docker image:
# Pull the latest image
docker pull ghcr.io/alvarosdev/staticmap-osm-generator:latest
# Run with cache persistence
docker run -d \
--name staticmap-server \
-p 3000:3000 \
-v $(pwd)/cache:/app/cache \
-e PORT=3000 \
ghcr.io/alvarosdev/staticmap-osm-generator:latestUse the published image from GitHub Container Registry:
version: "3.9"
services:
staticmap:
image: ghcr.io/alvarosdev/staticmap-osm-generator:latest
container_name: staticmap-osm-generator
environment:
- NODE_ENV=production
- PORT=3000
- CACHE_DIR=/app/cache
# CORS configuration (optional)
- CORS_ENABLED=true
- CORS_ALLOWED_ORIGINS=*
- CORS_ALLOWED_METHODS=GET, OPTIONS
- CORS_ALLOWED_HEADERS=Content-Type
- CORS_MAX_AGE=86400
ports:
- "3000:3000"
volumes:
- ./cache:/app/cache
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
restart: unless-stoppedRun with:
docker compose up -ddocker build -t staticmap-osm-generator .
docker run -p 3000:3000 staticmap-osm-generator
Note: Docker build includes a Biome linter check stage and will fail if lint errors are found.[Client] → [CDN/Reverse Proxy] → [Load Balancer] → [Docker Container]
↓ ↓
Rate Limiting SSL/TLS Termination
DDoS Protection Health Checks
Caching Load Balancing
When deploying behind a reverse proxy or CDN:
-
Disable CORS in the application (handled by proxy):
CORS_ENABLED=false
-
Configure rate limiting at the proxy level (not in the app)
-
Enable caching at the CDN/proxy level:
- Cache
/mapresponses based on query parameters - Don't cache
/statsor/health
- Cache
- Docker (recommended) or Bun v1.3.0+
- sharp for WebP conversion (installed automatically when using Docker).
If running locally without Docker, install dependencies with
bun installto fetchsharpprebuilt binaries. On first install it may download platform-specific binaries.
- Internet access to fetch OSM tiles
MIT License – See LICENSE file.
Note: This project uses OpenStreetMap tiles. Please respect OSM tile usage policies.
