I built a web GIS by myself. It holds 2.7 million road features, 51 GB of LiDAR and imagery, and runs as ten containers on one machine.
This is the map of it: what each piece does, why the boundaries are where they are, and — since I've spent several posts on this — where I got it wrong.
What it does
Road inventory. Surveyors drive a vehicle with a LiDAR scanner and a panoramic camera, and the result is a catalogue of everything alongside a road: signs, poles, kerbs, drains, cameras, bins, street lights. Users then work with that catalogue in a browser — draw, correct, measure, export.
Two things about that shape drive every decision below:
Users edit. This isn't a published dataset that refreshes nightly. Someone moves a sign and expects the map to show it moved.
The binary dwarfs the records. The database is 2.3 GB. One survey is a gigabyte on its own.
The containers
proxy TLS, routing, caching
frontend the browser app
backend Django + DRF — auth, projects, layers, features
importer FastAPI — uploads, imports, exports, mosaics
martin vector tiles from PostGIS
titiler raster tiles from Cloud-Optimised GeoTIFFs
db PostgreSQL + PostGIS
pgbouncer connection pooling
file-drop bulk data ingest
ci builds and deploys
Ten containers for one developer looks like over-engineering until you notice most of them are off-the-shelf processes doing one job. Only three contain code I wrote.
Why the boundaries are where they are
Django and FastAPI, both. Not fashion — a split by workload. Django holds the domain: users, roles, projects, layers, feature CRUD. It's a request/response application and the ORM and admin earn their keep.
Imports are not request/response. A 200 MB shapefile occupies a worker for minutes, and a synchronous endpoint gives the client no way to ask how it's going. So that work went to a separate async service with a job-and-poll shape, asyncio.to_thread for the blocking GDAL calls, and a semaphore to bound concurrency — because heavy geospatial work is memory-bound, and unbounded parallelism converts "slow" into "OOM-killed". → A Shapefile Is Four Files
Two tile servers. Martin renders vector tiles from PostGIS live. TiTiler serves raster tiles from COGs on disk. They share nothing but the reverse proxy, because vector and raster have nothing in common at this layer: one is a query, the other is a byte range in a file.
Features live in three tables, not one per layer. Points, lines and polygons, each with a layer_id. A table per layer would mean DDL every time a user clicks "new layer". The cost lands on tiles: Martin publishes views, so every layer gets a generated view filtered to its id, and auto_publish picks it up within five seconds. Create a layer in the browser, it's a live tile endpoint before you've finished naming it. → One View Per Layer
Big binary never enters the database. Point clouds, panoramas and orthophotos sit on disk and are served as static files. The database holds the trajectory — the line the survey vehicle drove — plus metadata and paths. That trajectory is 0.01% of the bytes and answers every question anyone asks. → One Gigabyte per Survey
pgbouncer in front of Postgres. Martin alone opens a hundred connections. Postgres does not enjoy that; a pooler does.
One pipeline per service repo. Push to the deploy branch, the image is built and the service restarted. Keeping each service in its own repository with its own pipeline means deploys are independent — a change to the tile server config never waits on a backend build, and a broken pipeline takes one service out of date rather than all of them.
The four things I got wrong
Writing these posts turned into an audit, and the audit found more than the posts did.
A cache with no TTL isn't a cache. Martin's built-in tile cache has no invalidation hook. A tile cached before an edit is served, byte-identical, for the life of the process — I measured the same 242 B tile long after the geometry under it had changed. It's off now, and nginx caches the same tiles with a five-second TTL instead, which absorbs the burst from panning a map without ever showing yesterday's geometry. → My Tile Cache Has No Invalidation
Users write SQL identifiers. Layer names become view names, and they were being interpolated into DDL with an f-string, unquoted and unvalidated. The tell wasn't a security scan — it was a layer somebody had named 1, whose view silently failed to exist because an unquoted identifier can't start with a digit. The same string that breaks the syntax could have completed it.
One dead container stopped nginx from starting. proxy_pass with a literal hostname resolves at config load, and nginx refuses to start if any name is missing. A stopped background service took down the entire entry point. Moving addresses into variables fixes it — and then quietly breaks trailing-slash path stripping, and then quietly breaks any if block sitting below the rewrite you added to compensate. → One Dead Container
A dropdown had the wrong EPSG code. Two coordinate systems in this region describe the same Gauss-Krüger zone with identical projection parameters and different datums — 427 metres apart, which lands in the right city and is therefore the dangerous kind of wrong. A third option was labelled as that zone but was actually a neighbouring country's grid, 5,000 km out. → Same Zone, Same Projection, 427 Metres Apart
What building it alone actually changes
Not the architecture. The boundaries above are the ones I'd argue for on a team.
What changes is who notices. Every one of those four defects had been in production for months, behind an error that was caught and logged and never read. No code review would have caught the EPSG label — you'd have to know the region. But a second person wondering aloud why one layer never renders would have found the injection in an afternoon.
So the thing I'd tell anyone in the same position: you are the code review, and you have to schedule it. Not "read your own code" — that finds nothing. Pick a subsystem, write down what it does as if explaining it to somebody, and check every claim against the running system as you go.
I set out to write a blog post about a tile server. What I got was a security fix, four bugs, and a config file that no longer has a password in it.
The stack, plainly
| Backend | Django, DRF, PostGIS |
| Import/export | FastAPI, geopandas, GDAL, async jobs |
| Vector tiles | Martin, one view per layer, auto-publish |
| Raster tiles | TiTiler over COGs |
| Database | PostgreSQL + PostGIS, pgbouncer |
| Edge | nginx — TLS, routing, tile and raster caching |
| CI | one pipeline per service repo |
| Data | 2.7M features · 102 layers · 51 GB imagery and point clouds |
Top comments (0)