In a recent project, I've got the chance to learn more about web mapping architecture.
It starts from spinning up a new Linux server, downloading OpenStreetMap (OSM) assets, generate an .mbtiles file and finally serving it over HTTP with TileServer GL.
If you have no idea what those words mean, don't worry, I'm gonna walk you through all of it in this blog post, so bare with me until the end!
setup the server
First thing first, before we are gonna touch anything regarding map stuff, we are gonna need a server to host it.
So look for your credit card and rent a server from your favorite VPS provider. The server specification would depend on what kind of web map that you need.
As of my needs:
- Max zoom level: 19
- Indonesia-area only
- Full road labels
Since I only need Indonesia area for the map, I don't really need a gigantic storage size for the server.
A full-world OSM map requires a significant storage, ranging from 150 GB to over 2 TB, depending on whether you are using pre-rendered vector tiles or hosting a live PostGIS database for rendering tiles yourself.
Here's my server specification that I'm testing for this, if you're curious:
echo "OS: $(. /etc/os-release && echo "$PRETTY_NAME")"; \
echo "CPU: $(lscpu | awk -F: '/Model name/ {gsub(/^[ \t]+/, "", $2); print $2; exit}') ($(nproc) cores)"; \
echo "RAM: $(free -h | awk '/Mem:/ {print $3 "/" $2}')"; \
echo "Disk: $(df -h / | awk 'NR==2 {print $3 "/" $2 " used (" $5 ")"}')"; \
echo "Storage: $(lsblk -dn -o NAME,SIZE,TYPE | awk '$3=="disk"{print $1":"$2}' | paste -sd ", " -)"
OS: Ubuntu 22.04.5 LTS
CPU: AMD EPYC-Genoa Processor (8 cores)
RAM: 789Mi/15Gi
Disk: 41G/503G used (9%)
Storage: vda:512G
The only deployment it has is the web map itself. I basically setup from a fresh Ubuntu 22.04.5 LTS installation.
vector tiles vs raster tiles
Ok, before we continue, let's first understand the difference between vector tiles and raster tiles. As I mentioned before, choosing between the two depends on your specific needs and resources.
- Vector tiles: map data is sent as geometry and metadata. The client (your website, mobile app, map viewer) or tile server can style it dynamically.
- Raster tiles: map data is already rendered into image tiles, usually
.pngor.jpg.
The easiest way to think about it:
Vector tile = "here are the roads, buildings, rivers, labels, etc."
Raster tile = "here is a finished image of the map."
Vector tiles are more flexible because you can change the map appearance without regenerating the whole dataset. For example, if you want to make roads darker, hide POIs, change building opacity, or create a dark mode map, you can do that through the style file.
On the other hand, raster tiles are simpler to consume because the app only needs to display images. But they are less flexible because the styling is already baked into the image.
In my case, the infrastructure uses vector tiles as the source data, then TileServer GL renders them into raster tiles for the clients.
what's OSM?
OpenStreetMap (OSM) is a free, editable map of the whole world that is being built by volunteers largely from scratch and released with an open-content license.
When you use Google Maps, Mapbox, or other commercial map providers, you usually consume their map through an API. You don't really think about the data pipeline behind it.
But when you self-host a map, you need to understand that the map is not just "an image." It is made from geographic data.
That data can include:
- roads
- buildings
- rivers
- lakes
- administrative boundaries
- city names
- village names
- points of interest (POI)
- landuse areas
- parks
- airports
- house numbers
For this project, I downloaded the Indonesia OSM extract from Geofabrik. Instead of downloading the whole planet, I only downloaded the Indonesia region because that is the only area needed by the application.
The downloaded file is an .osm.pbf file.
indonesia-latest.osm.pbf
A .pbf file is a compact binary format for OSM data. It is much smaller and faster to process than the raw .osm XML format.
At this stage, you still do not have something a browser or mobile app can draw directly. You only have source geographic data.
from .osm.pbf to .mbtiles
The next step is converting raw OSM data into map tiles.
For this, I used Tilemaker. Tilemaker reads the .osm.pbf extract, applies a config and process file, then writes the result into an .mbtiles database.
In simple terms:
- input:
indonesia-latest.osm.pbf - tool: Tilemaker
- output:
indonesia.mbtiles
Why .mbtiles?
Because it is a convenient SQLite-based container for tiles and metadata. Instead of dealing with millions of tiny tile files, you can package everything into one database file.
A basic Tilemaker command looks like this:
tilemaker \
--input indonesia-latest.osm.pbf \
--output indonesia.mbtiles \
--config config.json \
--process process.lua
The exact config depends on what kind of map you want. Road-heavy navigation maps need different layers from a minimalist landuse map.
Some things that affect output size and build time:
- selected region size
- max zoom level
- how many feature layers you keep
- whether labels are included
- geometry complexity
If you push zoom very high and keep every possible feature, the generated .mbtiles file can get large very quickly.
what is inside .mbtiles
An .mbtiles file is basically a SQLite database with tile data plus some metadata.
You can inspect it like a normal SQLite file:
sqlite3 indonesia.mbtiles
Then check the tables:
.tables
Usually you will see tables like:
metadatatiles
The metadata table stores information such as:
- name
- format
- bounds
- center
- minzoom
- maxzoom
The tiles table stores the actual tile payloads.
You do not normally query these by hand in day to day usage, but it helps to know that .mbtiles is not some magical black box.
serving tiles with TileServer GL
After generating the .mbtiles file, I used TileServer GL to serve the map over HTTP.
TileServer GL can read vector tiles from .mbtiles, apply a style, and expose endpoints that clients can consume.
That means your app does not need to understand raw OSM data at all. It only needs to request tiles like this:
/tiles/{z}/{x}/{y}.png
In my setup, TileServer GL rendered raster PNG tiles on the server side.
This was a good fit because:
- the clients stay simple
- I can control styling in one place
- Flutter and web clients can consume standard XYZ tiles
A very rough Docker example would look like this:
docker run --rm -it \
-v $(pwd):/data \
-p 8080:8080 \
maptiler/tileserver-gl \
--file /data/indonesia.mbtiles
Once it is running, TileServer GL exposes things like:
- a style JSON
- tile JSON metadata
- rendered raster tile endpoints
- preview pages
why I added a Go gateway in front
You could point your apps directly to TileServer GL, but I decided to place a Go service in front of it.
That gateway handled a few useful things:
- API key validation
- rate limiting
- response caching
- a cleaner public endpoint for clients
This is especially useful when the same tile service is consumed by multiple apps.
Instead of exposing the tile server directly, the gateway becomes the stable contract.
The flow becomes:
- client requests a tile from your API
- Go service verifies access rules
- Go service fetches from TileServer GL or cache
- response goes back to client
This makes it easier to evolve infrastructure later without changing every client.
how the client actually uses it
From the client side, the map is usually just a URL template.
For example, in Leaflet or Flutter you often configure something like:
https://your-domain.com/tiles/{z}/{x}/{y}.png
The map library replaces {z}, {x}, and {y} based on the viewport and zoom level.
z= zoom levelx= tile columny= tile row
So when a user pans around Jakarta at zoom 12, the client asks for many tile URLs that cover the visible map area.
That is why caching matters a lot. Popular areas and common zoom levels will be requested repeatedly.
what was tricky
The hardest part was not starting the server or running Docker. The tricky part was understanding the shape of the pipeline.
At first, many tools in the mapping world felt disconnected:
- OSM extract providers
- tile generators
- style files
- tile servers
- mobile and web client libraries
But once I saw the flow clearly, it became much easier:
- download geographic data
- transform it into tiles
- serve those tiles over HTTP
- plug the tile endpoint into client apps
That is the core mental model.
practical notes
A few practical lessons from this setup:
- Start with the smallest region possible. Building the whole world too early will slow everything down.
- Decide early whether your clients need vector flexibility or simple raster delivery.
- Storage matters quickly once you increase region size and zoom depth.
- A gateway layer is useful if you care about auth, quotas, or analytics.
- Test the result from the actual client device, not only from a desktop browser.
If your use case is limited to one country or one province, self-hosting can be surprisingly reasonable.
closing thoughts
Before this project, web maps felt like a black box to me. Now they feel much more mechanical in a good way.
You start with geographic source data, convert it into a tiled format, serve it through a tile server, then let clients request only the small pieces they need.
That is really the whole story.
There are still many deeper topics I have not covered here, like custom styling, hillshading, live OSM updates, CDN caching strategy, and vector tile delivery straight to MapLibre. But this setup was enough to make the stack feel understandable and deployable.
If you are trying to self-host maps for the first time, my advice is simple: do not start from the whole planet, and do not try to learn every mapping tool at once. Start with one region, one output format, and one client. That is enough to build intuition fast.