Tech Expert & Vibe Coder

With 14+ years of experience, I specialize in self-hosting, AI automation, and Vibe Coding – building applications using AI-powered tools like Google Antigravity, Dyad, and Cline. From homelabs to enterprise solutions.

Building a self-hosted offline map server with Protomaps and OpenStreetMap tiles for mobile navigation backup

Why I Built This

I needed offline maps that would actually work when cell service drops. Not a cached view that expires, not a "download for offline" feature that stops working after updates—real, self-hosted map tiles I control completely.

My use case was hiking in Colorado. Cell coverage is spotty, and I wanted navigation that didn't depend on Google, Apple, or any third-party service staying online. I also wanted to customize what shows up on the map—specifically, I wanted trails visible at zoom levels where they normally disappear.

My Setup

I used OpenStreetMap data as the source, processed it with Tilemaker, and packaged everything into a single PMTiles file. This file lives in an S3 bucket and gets served via HTTP range requests, which means clients can pull individual map tiles without downloading the entire dataset.

The stack:

  • OpenStreetMap data from Geofabrik (Colorado extract, about 400MB)
  • Tilemaker to convert .osm.pbf to .pmtiles
  • AWS S3 for hosting the PMTiles file
  • Maputnik for styling the map
  • MapLibre GL for rendering on web and mobile

I ran Tilemaker locally on my Linux machine. Processing the Colorado dataset took under a minute, which made iteration fast.

Processing the Data

Tilemaker takes raw OSM data and organizes it into layers using a Lua script. I started with the example configs from the Tilemaker repo and modified them to include trails at higher zoom levels than default.

The Lua script controls what features end up in which layers. I added logic to pull anything tagged as highway=path and make sure it appeared even when zoomed far out. This isn't standard—most map styles hide trails until you're zoomed in close.

The config files I used:

  • A JSON file defining the layers
  • A Lua file processing OSM features and assigning them to layers

Running Tilemaker looked like this:

tilemaker --input ./data/colorado-latest.osm.pbf \
          --output ./data/colorado-latest.pmtiles \
          --config ./config-demo.json \
          --process ./process-demo.lua

The output was a single 380MB PMTiles file containing all the map data for Colorado.

Hosting on S3

I uploaded the PMTiles file to an S3 bucket and made it publicly readable. The key requirement is HTTP range request support, which S3 handles natively. This lets clients request specific byte ranges instead of downloading the whole file.

I set a CORS policy to allow web clients to access the file:

[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedOrigins": ["*"],
    "ExposeHeaders": [],
    "MaxAgeSeconds": 3000
  }
]

Cost-wise, hosting this file is cheap—about $0.02/month for storage plus minimal data transfer costs since I'm the only user. If you're hosting a larger region or serving many users, those costs scale up.

Styling with Maputnik

Maputnik is a web-based editor for MapLibre styles. I pointed it at my S3-hosted PMTiles file and started adding layers.

The process was mostly trial and error:

  • Add a source pointing to the PMTiles URL
  • Create layers for roads, trails, water, etc.
  • Set colors, line widths, and zoom visibility
  • Preview changes in real-time

One issue I hit: text labels didn't work until I specified fonts that existed in the glyph source. Maputnik defaults to a specific font server, and if you try to use a font that isn't there, labels just don't render. I stuck with Open Sans, which was included.

I also had to set a sprite URL even though I wasn't using custom icons. Without it, MapLibre threw errors on Android.

The final style JSON included:

  • A glyphs URL for fonts
  • A sprite URL (even if unused)
  • A default center and zoom level
  • The PMTiles source with proper CORS access

I exported the style as JSON and uploaded it to the same S3 bucket as the PMTiles file.

Using It with MapLibre

MapLibre GL renders the map in browsers and mobile apps. On the web, it's a simple script include:

<script src="https://unpkg.com/maplibre-gl@^5.5.0/dist/maplibre-gl.js"></script>
<link href="https://unpkg.com/maplibre-gl@^5.5.0/dist/maplibre-gl.css" rel="stylesheet" />
<script src="https://unpkg.com/[email protected]/dist/pmtiles.js"></script>

Then initialize the map:

const protocol = new pmtiles.Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);

var map = new maplibregl.Map({
  container: 'map',
  style: 'https://mybucket.s3.us-west-2.amazonaws.com/style.json',
  center: [-106.0, 38.5],
  zoom: 12
});

This worked immediately in the browser. The PMTiles protocol handler fetches only the tiles needed for the current view, so initial load is fast even with a large dataset.

What Didn't Work

My first attempt used the entire US West extract from Geofabrik—about 8GB. Tilemaker processed it fine, but the resulting PMTiles file was over 6GB. Uploading to S3 took hours, and the cost implications made me rethink the scope. I scaled back to just Colorado.

I also tried using custom fonts initially, but couldn't get the glyph generation working properly. The existing font server was easier and worked well enough for my needs.

Android required the sprite URL even when not using sprites. Without it, MapLibre crashed on initialization. This wasn't documented clearly anywhere—I found it by trial and error.

Offline Usage

For actual offline use, I download the PMTiles file and style JSON to my phone. MapLibre can load from local files, though the setup differs by platform. On Android, I use the file:// protocol. On iOS, it's slightly more involved with app bundle resources.

The key benefit of PMTiles is that the entire dataset is one file. No database, no complex tile server, no thousands of individual files. Just copy the file to the device and point MapLibre at it.

Key Takeaways

This works. I have offline maps that I control, customized to show what I need, with no dependency on external services.

The PMTiles format is the breakthrough here. Before this, self-hosting maps meant running a tile server or managing massive tile caches. Now it's just a static file.

Tilemaker's Lua scripting gives real control over what ends up in the map. If you need custom layers or want to emphasize certain features, you can do it.

Costs are minimal for personal use. Storage and bandwidth for a single region are negligible. If you're serving many users or covering large areas, you need to do the math on S3 costs.

The tooling isn't polished. Maputnik is functional but clunky. Tilemaker's documentation assumes some familiarity with OSM data structure. MapLibre is solid but requires understanding the style spec. This isn't a plug-and-play solution.

But it works, and it's mine. That was the point.