Building flatlas.sg — a 35-year HDB price map, and the production bug that taught me to fail loud
I built an interactive map of every HDB resale flat sold in Singapore since 1990 — ~970,000 transactions, geocoded to the block, coloured by inflation-adjusted price, with a hedonic model that tries to explain why a given flat costs what it does. You pan, you zoom, the country resolves from a town choropleth into individual blocks into 3D buildings, all on one consistent price ramp. There’s a Sale/Rent toggle, school catchments, MRT lines, childcare centres.
It’s a fun consumer toy. But the part worth writing about isn’t the map — it’s everything around the map. This post is two stories: how the thing is built, and the afternoon a single deploy quietly broke production while every health check stayed green. The second story has a moral I kept relearning all day: fail loud, not silent.
The architecture: two channels, one database
The core design decision is a split that I’d recommend to anyone building a data-heavy map:
- Rendering channel — vector tiles. PostGIS functions (
ST_AsMVT) served by Martin. This draws the map: town polygons, block dots, extruded 3D buildings. - Interaction channel — JSON. A small Go API serves transaction history for the current viewport (a bounding-box query). This is what powers hovering a block, opening its price history, the “why this price” panel.
Tiles are for drawing; JSON is for interacting. They share one PostGIS database but
they are different services with different performance characteristics, and keeping
them separate kept both simple. The basemap (streets, labels) is a self-hosted
Singapore .pmtiles file — not served by Martin, just a static byte-range file behind
the web server.
The whole stack runs on a single VM via Docker Compose:
PostGIS (source of truth)
├── Martin → vector tiles (rendering channel)
├── Go API → BBOX JSON (interaction channel)
└── Caddy → TLS, single-origin, serves the SPA + proxies /api and /tiles
Frontend is React + MapLibre GL, built with Vite. Three zoom tiers — town choropleth below z12, block dots from z12–14, 3D extrusions above — all sharing one price→colour ramp so the visual language never changes as you zoom. The design ethos: monochrome everything, colour only on the price ramp. Amenity markers are plain white discs. The one exception is MRT line colours, because fighting Singaporeans on their MRT colours is a battle you lose.
The data pipeline
Everything starts at data.gov.sg: resale transactions back to
1990, rental approvals from 2021, CPI, land-use polygons, building footprints, childcare
centres. The ETL is a chain of small Python scripts orchestrated by one refresh.sh:
- Download the source CSVs/GeoJSON (data.gov.sg’s blob CDN 403s the default
python-urllibuser-agent, so every downloader spoofs a browser UA — a fun half-hour to discover). - Normalise with DuckDB — unify five eras of resale data with different lease formats, tag the price basis, compute CPI-deflated “real” prices.
- Geocode the ~10,000 unique blocks (not the million transactions) against OneMap, with a permanent cache. ~98.6% resolve; the rest are logged and skipped rather than forced to a wrong coordinate. OneMap will happily fuzzy-match “JALAN BATU” to “JALAN JAMBU BATU” and drop a Kallang block in Bukit Timah, so the geocoder validates the block number and road name before accepting a hit.
- Load into PostGIS, incrementally (only the last few months on a normal run), with a quarterly full reload to absorb restatements.
- Enrich — land-use features, building footprints, upgrading status, amenities.
- Fit a hedonic regression so the block panel can decompose a price into “unit size +X%, remaining lease −Y%, distance to MRT +Z%…”.
That hedonic model is the feature I’m proudest of and, as you’ll see, the one that blew up in the most instructive way.
Shipping: a deploy script that does only what changed
Early on, deploys were “SSH in, git pull, rebuild whatever I remember touching.” That
doesn’t scale past the third 11pm deploy. So I wrote ops/deploy.sh: pull, diff HEAD
against what was pulled, and do only the work that diff requires.
db/migrations/ changed → run migrations
api/ changed → rebuild + recreate the Go container
ops/Caddyfile changed → validate + hot-reload Caddy (zero downtime)
web/ changed → rebuild the frontend
A pure frontend change costs one npm run build and zero container churn, because Caddy
bind-mounts the built web/dist and serves it live. Then I wired it to GitHub Actions:
push to the deploy branch → an Action SSHes into the VM and runs deploy.sh. The SSH key
on the box is locked to a forced command, so a leaked key can only trigger a deploy,
never run an arbitrary shell. Push = deploy, with a blast radius of one command.
I layered in fail-safety, mostly because I’d been bitten by each failure mode at least once:
- Frontend builds to a staging directory and is swapped into the live
distonly after it validates. Vite empties its output dir at the start of a build, so building straight into the live directory means a failed build leaves a 404 where your app used to be. Staging fixes that. - Caddy config changes use
caddy validatethencaddy reload— zero dropped connections, and the old config stays live if the new one is broken. A badrestarttakes the whole site down; a badreloaddoes nothing. - Before migrations, a gzipped
pg_dumpsnapshot, so a bad schema change is restorable. - Containers come up with
--waitand healthchecks; if a rebuilt API image fails its health gate, the deploy re-tags the previous image and recreates from it — an automatic rollback to the last-good build, then exits non-zero so I know.
I felt very clever about all of this. Then the database reminded me that none of it matters if your data is wrong.
The incident: a deploy that broke nothing and everything
The symptom was simple. After a deploy, the map’s basemap and choropleth rendered fine,
but clicking any block — or just letting the viewport load flat data — returned HTTP
500. The frontend showed an empty map of dots that wouldn’t open. /api/health
returned 200. Every container was “healthy.” The deploy had reported success.
The API returned a useless body: query failed. No log line. The handler caught the
error and threw it away:
if err != nil {
http.Error(w, "query failed", http.StatusInternalServerError) // the real error: /dev/null
return
}
Lesson zero: an error you swallow is an error you will spend an hour rediscovering.
My first fix wasn’t a fix at all — it was adding log.Printf("%v", err) so the database
could finally tell me what it thought.
When it did, the chain of causes was almost comically deep.
Cause 1: the migration baseline trap
The Go API had been rebuilt from a branch where the BBOX query selects a bunch of
block-attribute columns — year_completed, total_dwelling_units, plot ratio,
commercial-nearby, and so on — added across migrations 025–028. The query referenced
columns that did not exist in the live database. column "gpr" does not exist.
But the migrations were recorded as applied. How?
My migration runner, migrate.sh, had a “baseline” mode for the common case: a fresh DB
where Docker’s initdb had already run every migration on first boot. On its first run
against such a DB, it marks every migration file as applied without running them —
because they already ran, at boot. Sensible.
The trap: that logic assumes the database contains the schema for every current
migration file. But this DB had been first-booted months earlier, at a commit that
only had migrations up to ~023. Migrations 025–028 were added later. So when
migrate.sh ran its first baseline, it cheerfully marked 025–028 as “applied” — for a
schema that had never been created.
The migration table said everything was applied. The columns didn’t exist. The API queried them. 500.
Nothing in the deploy noticed, because a deploy’s health checks verify that services serve, not that the data is complete. The single most expensive bug of the project was a script silently asserting success.
The red herring
While debugging, I kept reproducing the 500 with curl and a hand-typed bounding box —
and the error I got back, once logging was in, was:
invalid input syntax for type date: "1990-01"
I chased this for a while before realising it was my own malformed test query —
the column is a DATE, I’d sent 1990-01 instead of 1990-01-01. The real frontend
sent valid dates. So for a stretch I was debugging a bug that only existed in my curl,
layered on top of the real bug underneath. Reproduce with the real client’s inputs, not
your own approximation of them.
The fix, and the principle
Three changes, all variations on the same theme:
- An initdb hook that records exactly which migration files
initdbapplied, into the migration history table. Now a fresh DB starts with an accurate record andmigrate.shnever has to guess. migrate.shrefuses to silently baseline. On a populated-but-unrecorded DB it now errors with instructions and requires an explicitBASELINE=1. A silent assumption became a loud stop.- Atomic migration batches — all pending migrations apply in one transaction, so a failure rolls the whole batch back instead of leaving a half-migrated schema.
The second blow-up: a cascade of silence
Fixing the schema surfaced the next layer. Some features were still missing on production but present locally. Same root shape — new schema, stale data — but a different cause.
The monthly refresh.sh had fired a burst of downloads at data.gov.sg and got
rate-limited: HTTP 429. The land-use and building-footprint downloaders had no
retry, so they failed instantly. Each failure was a one-line || echo "[warn] …" buried
in a 200-line log, and the script carried on. The deploy “succeeded.”
Then the cascade. With land-use never loaded, every block’s gpr (plot ratio) was
NULL. The hedonic model imputes missing gpr to the column median — but the median of
an all-NULL column is itself NaN. fillna(NaN) is a no-op. The next line, dropna(),
dutifully dropped every single row. The model then tried to fit on zero transactions
and died:
HEDONIC FIT FAILED: With n_samples=0 ... the resulting train set will be empty.
fitting on 0 transactions since 2021-01-01
A rate limit on a 190 MB file download had, four steps downstream, produced a regression with no data. And every link in that chain had failed quietly.
The fixes write themselves once you say them out loud:
- Add 429 backoff/retry to the downloaders (the other downloaders already had it; these two didn’t).
- Guard the hedonic model so an all-
NULLfeature degrades gracefully instead of nuking the dataset. - And the important one: make
refresh.shloud. Best-effort steps now record into aFAILEDlist, and the script ends with
⚠️ REFRESH INCOMPLETE — 2 step(s) failed:
- landuse — land-use hedonic features not refreshed
- building_footprints — 3D footprints not refreshed
…and exits non-zero. A partial refresh can no longer masquerade as a complete one.
There was even a tidy little coda: re-running a single ETL step by hand failed with
password authentication failed for user "hdb", because a bare python etl/step.py
never loads the .env that refresh.sh sources for you. One more silent assumption, one
more small wrapper script (ops/etl-run.sh) to make the supported path obvious.
What I’d tell past me
Most of the engineering I was proud of — the diff-aware deploy, the auto-rollback, the zero-downtime config reload — worked exactly as designed and was never the problem. The problems were all the places something failed and said nothing:
- A migration runner that asserted “applied” for migrations it never ran.
- An API handler that swallowed the database’s error message.
- An ETL step that warned into the void and let the script continue.
- A regression that turned “no input data” into a crash three steps removed from the cause.
None of these were hard bugs. Each was a silent bug, and silence is what turned a five-minute fix into an afternoon. The single highest-leverage change across the whole project wasn’t a feature or an optimisation — it was making every layer fail loudly: log the real error, refuse the dangerous default, summarise what didn’t happen, exit non-zero when the work is incomplete.
Health checks tell you the server is up. They don’t tell you the data is right. Build the thing that screams when it isn’t.
flatlas.sg is built with PostGIS, Martin, Go, React + MapLibre GL, and Caddy, on a single VM. Data from data.gov.sg, geocoded via OneMap. If you’re building something similar and want to compare notes, I’m happy to talk shop.