Gluing the Real World: Go CLI for OSM → PostGIS → Tiles → Object Storage¶
Objective: A single Go binary conducts PBF fetch, checksum, PostGIS load, view materialization, tile generation/serving, and publishing. Concrete, durable, and debuggable.
Architecture Diagram¶
flowchart LR
A[OSM PBF Source] -->|fetch+sha256| B[(Staging FS)]
B -->|osm2pgsql| C[(PostGIS)]
C -->|SQL views + indexes| C2[(Generalized Layers)]
C2 -->|tippecanoe| D[(MBTiles/PMTiles)]
C -->|live MVT| M[Martin Tileserver]
D -->|rclone/s3 cp| S[(S3/MinIO)]
subgraph "Go CLI"
F[fetch] --> L[load] --> I[index] --> T[tile] --> P[publish] --> X[purge]
end
F -->|metrics| G[Pushgateway]
L -->|metrics| G
T -->|metrics| G Docker Compose (PostGIS + Martin + Pushgateway)¶
version: "3.9"
services:
db:
image: postgis/postgis:15-3.4
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: osm
ports:
- "5432:5432"
volumes:
- ./volumes/pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
martin:
image: ghcr.io/maplibre/martin:latest
command: ["--listen-addr", "0.0.0.0:3000", "postgresql://postgres:postgres@db:5432/osm"]
depends_on:
db:
condition: service_healthy
ports:
- "3000:3000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 3
pushgateway:
image: prom/pushgateway:latest
ports:
- "9091:9091"
volumes:
- ./volumes/pushgateway:/pushgateway
volumes:
pgdata:
pushgateway:
Why: PostGIS is your compute nucleus; Martin for "live tiles" mode; Pushgateway shows state-of-practice instrumentation.
Data Model & SQL (Indexes, Generalized Views)¶
Base Tables via osm2pgsql¶
-- osm2pgsql creates these tables automatically:
-- planet_osm_point, planet_osm_line, planet_osm_polygon, planet_osm_roads
-- Add spatial indexes for performance
CREATE INDEX IF NOT EXISTS idx_roads_geom ON planet_osm_roads USING gist(way);
CREATE INDEX IF NOT EXISTS idx_line_geom ON planet_osm_line USING gist(way);
CREATE INDEX IF NOT EXISTS idx_polygon_geom ON planet_osm_polygon USING gist(way);
-- Analyze tables for query planner
ANALYZE planet_osm_roads;
ANALYZE planet_osm_line;
ANALYZE planet_osm_polygon;
Generalized Views by Zoom Level¶
-- Roads for zoom 8 (simplified)
CREATE MATERIALIZED VIEW IF NOT EXISTS roads_z8 AS
SELECT
osm_id,
highway,
name,
ST_SimplifyVW(ST_Transform(way, 3857), 30) AS geom
FROM planet_osm_roads
WHERE highway IS NOT NULL
AND highway IN ('motorway', 'trunk', 'primary', 'secondary', 'tertiary');
CREATE INDEX IF NOT EXISTS idx_roads_z8_geom ON roads_z8 USING gist(geom);
-- Roads for zoom 10 (less simplified)
CREATE MATERIALIZED VIEW IF NOT EXISTS roads_z10 AS
SELECT
osm_id,
highway,
name,
ST_SimplifyVW(ST_Transform(way, 3857), 10) AS geom
FROM planet_osm_roads
WHERE highway IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_roads_z10_geom ON roads_z10 USING gist(geom);
-- Buildings for zoom 12
CREATE MATERIALIZED VIEW IF NOT EXISTS buildings_z12 AS
SELECT
osm_id,
building,
name,
ST_Transform(way, 3857) AS geom
FROM planet_osm_polygon
WHERE building IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_buildings_z12_geom ON buildings_z12 USING gist(geom);
-- Refresh materialized views
REFRESH MATERIALIZED VIEW roads_z8;
REFRESH MATERIALIZED VIEW roads_z10;
REFRESH MATERIALIZED VIEW buildings_z12;
Why: Predictable performance, deterministic tile size, and clear separation per zoom tier.
The Go CLI — Structure and Subcommands¶
Main CLI Structure¶
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type Config struct {
OSM struct {
URL string `mapstructure:"url"`
SHA256 string `mapstructure:"sha256"`
} `mapstructure:"osm"`
DB struct {
DSN string `mapstructure:"dsn"`
} `mapstructure:"db"`
Tiling struct {
MinZoom int `mapstructure:"min_zoom"`
MaxZoom int `mapstructure:"max_zoom"`
Layers []Layer `mapstructure:"layers"`
} `mapstructure:"tiling"`
Publish struct {
S3Bucket string `mapstructure:"s3_bucket"`
Prefix string `mapstructure:"prefix"`
} `mapstructure:"publish"`
Metrics struct {
Pushgateway string `mapstructure:"pushgateway"`
} `mapstructure:"metrics"`
}
type Layer struct {
Name string `mapstructure:"name"`
SQL string `mapstructure:"sql"`
}
var (
cfg Config
runID string
)
func main() {
runID = fmt.Sprintf("osmglue-%d", time.Now().Unix())
var rootCmd = &cobra.Command{
Use: "osmglue",
Short: "OSM → PostGIS → Tiles pipeline orchestrator",
Long: "A Go CLI that orchestrates the complete OSM data pipeline",
}
rootCmd.AddCommand(bootCmd())
rootCmd.AddCommand(fetchCmd())
rootCmd.AddCommand(loadCmd())
rootCmd.AddCommand(indexCmd())
rootCmd.AddCommand(tileCmd())
rootCmd.AddCommand(serveCmd())
rootCmd.AddCommand(publishCmd())
rootCmd.AddCommand(statusCmd())
// Load config
viper.SetConfigName("pipeline")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err != nil {
log.Fatal("Failed to read config:", err)
}
if err := viper.Unmarshal(&cfg); err != nil {
log.Fatal("Failed to unmarshal config:", err)
}
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
}
Subcommands¶
func bootCmd() *cobra.Command {
return &cobra.Command{
Use: "boot",
Short: "Ensure docker services are running",
Run: func(cmd *cobra.Command, args []string) {
ctx := contextWithCancel()
logJSON("boot_start", map[string]any{
"run_id": runID,
"services": []string{"postgis", "martin", "pushgateway"},
})
// Start docker compose
if err := execWithRetry(ctx, Opts{
Cmd: "docker",
Args: []string{"compose", "up", "-d"},
Retries: 3,
}); err != nil {
log.Fatal("Failed to start services:", err)
}
// Wait for health checks
time.Sleep(30 * time.Second)
logJSON("boot_complete", map[string]any{
"run_id": runID,
"status": "healthy",
})
},
}
}
func fetchCmd() *cobra.Command {
return &cobra.Command{
Use: "fetch",
Short: "Download OSM PBF and verify checksum",
Run: func(cmd *cobra.Command, args []string) {
ctx := contextWithCancel()
logJSON("fetch_start", map[string]any{
"run_id": runID,
"url": cfg.OSM.URL,
})
start := time.Now()
// Download PBF with resume support
if err := execWithRetry(ctx, Opts{
Cmd: "curl",
Args: []string{"-C", "-", "-o", "data.osm.pbf", cfg.OSM.URL},
Retries: 3,
}); err != nil {
log.Fatal("Failed to download PBF:", err)
}
// Download checksum
if err := execWithRetry(ctx, Opts{
Cmd: "curl",
Args: []string{"-sS", "-o", "data.osm.pbf.sha256", cfg.OSM.SHA256},
Retries: 3,
}); err != nil {
log.Fatal("Failed to download checksum:", err)
}
// Verify checksum
if err := execWithRetry(ctx, Opts{
Cmd: "sha256sum",
Args: []string{"-c", "data.osm.pbf.sha256"},
Retries: 1,
}); err != nil {
log.Fatal("Checksum verification failed:", err)
}
duration := time.Since(start)
logJSON("fetch_complete", map[string]any{
"run_id": runID,
"duration_seconds": duration.Seconds(),
"status": "verified",
})
// Push metrics
pushMetrics("fetch", duration.Seconds())
},
}
}
func loadCmd() *cobra.Command {
return &cobra.Command{
Use: "load",
Short: "Load OSM data into PostGIS via osm2pgsql",
Run: func(cmd *cobra.Command, args []string) {
ctx := contextWithCancel()
logJSON("load_start", map[string]any{
"run_id": runID,
"pbf_path": "data.osm.pbf",
})
start := time.Now()
// Run osm2pgsql in container
if err := execWithRetry(ctx, Opts{
Cmd: "docker",
Args: []string{
"run", "--rm", "--network=host",
"-v", fmt.Sprintf("%s:/data.osm.pbf", "data.osm.pbf"),
"openmaptiles/osm2pgsql",
"--create", "--slim",
"-H", "localhost", "-d", "osm", "-U", "postgres",
"--cache", "20000", "--number-processes", "4",
"/data.osm.pbf",
},
Retries: 1,
}); err != nil {
log.Fatal("Failed to load OSM data:", err)
}
duration := time.Since(start)
logJSON("load_complete", map[string]any{
"run_id": runID,
"duration_seconds": duration.Seconds(),
"status": "loaded",
})
pushMetrics("load", duration.Seconds())
},
}
}
func indexCmd() *cobra.Command {
return &cobra.Command{
Use: "index",
Short: "Create indexes and materialized views",
Run: func(cmd *cobra.Command, args []string) {
ctx := contextWithCancel()
logJSON("index_start", map[string]any{
"run_id": runID,
})
start := time.Now()
// Execute SQL indexing script
sqlScript := `
-- Add spatial indexes
CREATE INDEX IF NOT EXISTS idx_roads_geom ON planet_osm_roads USING gist(way);
CREATE INDEX IF NOT EXISTS idx_line_geom ON planet_osm_line USING gist(way);
CREATE INDEX IF NOT EXISTS idx_polygon_geom ON planet_osm_polygon USING gist(way);
-- Analyze tables
ANALYZE planet_osm_roads;
ANALYZE planet_osm_line;
ANALYZE planet_osm_polygon;
-- Create materialized views
CREATE MATERIALIZED VIEW IF NOT EXISTS roads_z8 AS
SELECT osm_id, highway, name,
ST_SimplifyVW(ST_Transform(way, 3857), 30) AS geom
FROM planet_osm_roads
WHERE highway IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_roads_z8_geom ON roads_z8 USING gist(geom);
REFRESH MATERIALIZED VIEW roads_z8;
`
if err := execWithRetry(ctx, Opts{
Cmd: "psql",
Args: []string{"-d", cfg.DB.DSN, "-c", sqlScript},
Retries: 3,
}); err != nil {
log.Fatal("Failed to create indexes:", err)
}
duration := time.Since(start)
logJSON("index_complete", map[string]any{
"run_id": runID,
"duration_seconds": duration.Seconds(),
"status": "indexed",
})
pushMetrics("index", duration.Seconds())
},
}
}
func tileCmd() *cobra.Command {
return &cobra.Command{
Use: "tile",
Short: "Generate tiles with tippecanoe",
Run: func(cmd *cobra.Command, args []string) {
ctx := contextWithCancel()
logJSON("tile_start", map[string]any{
"run_id": runID,
"min_zoom": cfg.Tiling.MinZoom,
"max_zoom": cfg.Tiling.MaxZoom,
})
start := time.Now()
// Build tippecanoe command with layers
args := []string{
"-o", "tiles.mbtiles",
"-Z", fmt.Sprintf("%d", cfg.Tiling.MinZoom),
"-z", fmt.Sprintf("%d", cfg.Tiling.MaxZoom),
"--drop-densest-as-needed",
"--extend-zooms-if-still-dropping",
"--no-feature-limit",
"--no-tile-size-limit",
"--read-parallel",
}
// Add layers
for _, layer := range cfg.Tiling.Layers {
args = append(args, "-L", fmt.Sprintf("name=%s,sql=%s", layer.Name, layer.SQL))
}
if err := execWithRetry(ctx, Opts{
Cmd: "tippecanoe",
Args: args,
Retries: 2,
}); err != nil {
log.Fatal("Failed to generate tiles:", err)
}
duration := time.Since(start)
logJSON("tile_complete", map[string]any{
"run_id": runID,
"duration_seconds": duration.Seconds(),
"output": "tiles.mbtiles",
"status": "generated",
})
pushMetrics("tile", duration.Seconds())
},
}
}
func publishCmd() *cobra.Command {
return &cobra.Command{
Use: "publish",
Short: "Publish tiles to S3/MinIO",
Run: func(cmd *cobra.Command, args []string) {
ctx := contextWithCancel()
logJSON("publish_start", map[string]any{
"run_id": runID,
"bucket": cfg.Publish.S3Bucket,
"prefix": cfg.Publish.Prefix,
})
start := time.Now()
// Use rclone to copy to S3/MinIO
if err := execWithRetry(ctx, Opts{
Cmd: "rclone",
Args: []string{"copy", "tiles.mbtiles", fmt.Sprintf("s3:%s/%s", cfg.Publish.S3Bucket, cfg.Publish.Prefix)},
Retries: 3,
}); err != nil {
log.Fatal("Failed to publish tiles:", err)
}
duration := time.Since(start)
logJSON("publish_complete", map[string]any{
"run_id": runID,
"duration_seconds": duration.Seconds(),
"status": "published",
})
pushMetrics("publish", duration.Seconds())
},
}
}
Go: Exec Helper, Backoff, Logging, Metrics¶
Exec Helper with Retry Logic¶
// cmd/internal/run/run.go
package run
import (
"context"
"encoding/json"
"log"
"math/rand"
"os/exec"
"time"
)
type Opts struct {
Cmd string
Args []string
Env []string
Dir string
Retries int
}
func ExecWithRetry(ctx context.Context, o Opts) error {
delay := 1 * time.Second
maxDelay := 30 * time.Second
for attempt := 0; attempt <= o.Retries; attempt++ {
cmd := exec.CommandContext(ctx, o.Cmd, o.Args...)
cmd.Dir = o.Dir
cmd.Env = append(cmd.Env, o.Env...)
out, err := cmd.CombinedOutput()
logJSON("exec", map[string]any{
"cmd": o.Cmd,
"args": o.Args,
"attempt": attempt,
"out": string(out),
"err": err,
})
if err == nil {
return nil
}
if attempt == o.Retries {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay + time.Duration(rand.Intn(500))*time.Millisecond):
delay *= 2
if delay > maxDelay {
delay = maxDelay
}
}
}
return context.DeadlineExceeded
}
func logJSON(ev string, payload map[string]any) {
payload["event"] = ev
payload["timestamp"] = time.Now().UTC().Format(time.RFC3339)
payload["run_id"] = runID
b, _ := json.Marshal(payload)
log.Println(string(b))
}
func contextWithCancel() context.Context {
ctx, cancel := context.WithCancel(context.Background())
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
<-c
logJSON("shutdown", map[string]any{"signal": "interrupt"})
cancel()
}()
return ctx
}
Metrics Push to Pushgateway¶
func pushMetrics(stage string, duration float64) {
if cfg.Metrics.Pushgateway == "" {
return
}
metrics := fmt.Sprintf(`
# TYPE stage_duration_seconds gauge
stage_duration_seconds{stage="%s",run_id="%s"} %f
`, stage, runID, duration)
execWithRetry(context.Background(), Opts{
Cmd: "curl",
Args: []string{
"-X", "POST",
"--data-binary", metrics,
fmt.Sprintf("%s/metrics/job/%s", cfg.Metrics.Pushgateway, stage),
},
Retries: 1,
})
}
Why: One reliable glue primitive replaces brittle shell scripts; logs are structured; retry semantics are consistent.
Config & Secrets¶
pipeline.yaml¶
osm:
url: https://download.geofabrik.de/north-america/us-northeast-latest.osm.pbf
sha256: https://download.geofabrik.de/north-america/us-northeast-latest.osm.pbf.sha256
db:
dsn: postgresql://postgres:postgres@localhost:5432/osm?sslmode=disable
tiling:
min_zoom: 5
max_zoom: 12
layers:
- name: roads
sql: SELECT osm_id, highway, name, geom FROM roads_z8
- name: buildings
sql: SELECT osm_id, building, name, geom FROM buildings_z12
publish:
s3_bucket: my-maps
prefix: tiles/
metrics:
pushgateway: http://localhost:9091
Environment Overrides¶
# Override config with environment variables
export OSM_URL="https://download.geofabrik.de/europe/germany-latest.osm.pbf"
export DB_DSN="postgresql://user:pass@db:5432/osm"
export S3_BUCKET="my-production-bucket"
export PUSHGATEWAY_URL="http://pushgateway:9091"
Secrets Management¶
# Use AWS SSO for credentials
aws configure sso
# Or MinIO client
mc alias set myminio https://minio.example.com accesskey secretkey
# Never embed credentials in YAML
# Use rclone config for S3/MinIO authentication
rclone config
Ops & Reliability Best Practices¶
Idempotency¶
// Success marker files per stage
func markStageComplete(stage string) error {
return os.WriteFile(fmt.Sprintf(".%s.ok", stage), []byte(runID), 0644)
}
func isStageComplete(stage string) bool {
_, err := os.Stat(fmt.Sprintf(".%s.ok", stage))
return err == nil
}
// Skip stages unless --force flag
func shouldSkipStage(stage string, force bool) bool {
return !force && isStageComplete(stage)
}
Atomic Outputs¶
// Write to temp file then move into place
func atomicWrite(filename string, data []byte) error {
tempFile := filename + ".tmp"
if err := os.WriteFile(tempFile, data, 0644); err != nil {
return err
}
return os.Rename(tempFile, filename)
}
Health Checks¶
func waitForPostGIS(ctx context.Context, dsn string) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
db, err := sql.Open("postgres", dsn)
if err == nil {
if err := db.Ping(); err == nil {
db.Close()
return nil
}
db.Close()
}
time.Sleep(5 * time.Second)
}
}
}
func waitForMartin(ctx context.Context, url string) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
resp, err := http.Get(url + "/health")
if err == nil && resp.StatusCode == 200 {
resp.Body.Close()
return nil
}
time.Sleep(5 * time.Second)
}
}
}
Backpressure Control¶
// Bound parallelism to CPU cores - 1
func getOptimalParallelism() int {
cores := runtime.NumCPU()
if cores > 1 {
return cores - 1
}
return 1
}
// Use in tippecanoe
args := append(args, "--read-parallel", fmt.Sprintf("%d", getOptimalParallelism()))
Observability¶
type StageMetrics struct {
Stage string `json:"stage"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Duration float64 `json:"duration_seconds"`
Bytes int64 `json:"bytes_processed"`
Tiles int `json:"tiles_generated"`
Status string `json:"status"`
}
func recordStageMetrics(stage string, start time.Time, bytes int64, tiles int) {
duration := time.Since(start).Seconds()
metrics := StageMetrics{
Stage: stage,
StartTime: start,
EndTime: time.Now(),
Duration: duration,
Bytes: bytes,
Tiles: tiles,
Status: "success",
}
logJSON("stage_complete", map[string]any{
"metrics": metrics,
})
pushMetrics(stage, duration)
}
Cost & Time Optimization¶
// Cache downloads with checksum gates
func shouldRedownload(pbfPath, sha256Path string) bool {
if _, err := os.Stat(pbfPath); os.IsNotExist(err) {
return true
}
if _, err := os.Stat(sha256Path); os.IsNotExist(err) {
return true
}
// Check if checksum matches
cmd := exec.Command("sha256sum", "-c", sha256Path)
return cmd.Run() != nil
}
// Reuse MBTiles when input unchanged
func shouldRegenerateTiles(mbtilesPath, pbfPath string) bool {
if _, err := os.Stat(mbtilesPath); os.IsNotExist(err) {
return true
}
// Compare modification times
mbtilesInfo, _ := os.Stat(mbtilesPath)
pbfInfo, _ := os.Stat(pbfPath)
return mbtilesInfo.ModTime().Before(pbfInfo.ModTime())
}
Rollbacks¶
// Keep last N artifacts with versioned prefixes
func publishWithVersion(prefix string) string {
version := time.Now().Format("200601021504")
return fmt.Sprintf("%s/v%s/", prefix, version)
}
// Cleanup old versions (keep last 5)
func cleanupOldVersions(bucket, prefix string) error {
// Implementation depends on your storage backend
// Use rclone or AWS CLI to list and delete old versions
return nil
}
Quick Start (TL;DR)¶
1. Bring up services¶
2. Build the glue¶
go mod init osmglue
go get github.com/spf13/cobra
go get github.com/spf13/viper
go get github.com/lib/pq
go build -o osmglue ./cmd/osmglue
3. Run stages¶
# Start services and wait for health checks
./osmglue boot
# Download OSM data with checksum verification
./osmglue fetch
# Load data into PostGIS via osm2pgsql
./osmglue load
# Create indexes and materialized views
./osmglue index
# Generate tiles with tippecanoe
./osmglue tile
# Start Martin tileserver (optional)
./osmglue serve
# Publish to S3/MinIO (optional)
./osmglue publish
# Check status
./osmglue status
4. Verify the pipeline¶
# Check PostGIS data
psql -d postgresql://postgres:postgres@localhost:5432/osm -c "SELECT COUNT(*) FROM planet_osm_roads;"
# Check Martin tileserver
curl http://localhost:3000/health
# Check generated tiles
ls -la tiles.mbtiles
# Check metrics
curl http://localhost:9091/metrics
This tutorial demonstrates how Go can orchestrate complex, multi-stage data pipelines with reliability, observability, and production-grade error handling. The combination of PostGIS, tippecanoe, and object storage creates a powerful geospatial tile serving infrastructure.