Aqil Nazri

9 min readupdated

Choosing Belang's backend on a fresh grad's budget, and running it like a team

Where Belang's server lives and why it costs €11.50 a month, why routing runs on Valhalla, how the server stays private, and the issue-and-pull-request routine that keeps a one-person project honest.

The first Belang post was about the part you can see: the layout, the BM voice, the tiger. This one is about the part you can’t. Phase 1 turns the prototype into real navigation, and that needs a server for routing and search, somewhere for the map to come from, and a way of working that still holds up when the project is bigger than a weekend.

I had two constraints. It has to be cheap, because I’ve just graduated and Belang earns nothing yet. And it has to keep the promise on the landing page: nobody, me included, should end up with a record of where people drive.

The shape of it

When Phase 1 is done, asking for a route works like this:

  1. The phone asks Belang’s own API, a small Cloudflare Worker.
  2. The function checks the request, drops anything that identifies the person, adds a secret key and passes it on.
  3. A server in Japan checks the key and hands the request to Valhalla, the routing engine.
  4. The route comes back the same way.

Search works the same way with Photon on the same server. The map comes as one file from storage. The phone never talks to the server directly, so the server never sees anyone’s IP address, and the server’s address isn’t anywhere in the app.

Update, 25 October: a day after this post, Belang moved from Vercel to Cloudflare Workers. One Worker now serves both the app and the small API. The beta waitlist moved from Vercel Blob to Cloudflare D1, a SQLite database placed in Asia-Pacific. The existing sign-ups were copied over and then deleted from Vercel, and each pull request preview gets its own empty database, so testing never touches real sign-ups. I made the call for two reasons. Vercel’s free plan doesn’t allow commercial use, and its Pro plan is US$20 a month. Belang can instead share an existing Cloudflare account, and its bill, with another project, where I’d already worked with Workers. That account is on the free plan for now, with US$5 a month for Workers Paid if Belang ever needs it. On privacy it’s about even. Cloudflare now runs the code and sees the requests, instead of Vercel rather than as well, and the Worker’s request logs are switched off. The server in Japan is unchanged. The rest of this post has been updated to match.

Every choice below has a short record in a decision log in the repo: what the situation was, what else I looked at, what it costs and when to look at it again. That log is where most of this post comes from.

Where the server lives (a correction)

In the last post I wrote that iCore in Cyberjaya had won. It didn’t survive checkout. The plan I’d picked was RM39.90 a month plus SST, but the cart slipped in an extra of about RM50 a month on top. At that point a server in Malaysia stopped being the cheap option.

Here’s how the options ended up:

Option Cost a month What happened
Oracle Cloud Always Free €0 I already have a free Oracle account, and its home region is Ashburn, Virginia. Oracle allows one free account per person, so there’s no Singapore one to make, and the instance already there runs other work I wasn’t going to touch. Ashburn is about 230 ms each way from Malaysia.
Contabo Cloud VPS 6, Singapore about US$14 with the Asia fee The plan for an afternoon. Out of stock.
iCore VPS, Cyberjaya RM39.90 + SST, then the cart extra In Malaysia, but only 4 GB of RAM, and the real price was much higher than the listed one.
AWS, Malaysia region about US$60-80 Probably what a government buyer would want one day. Far too much for now.
Contabo Cloud VPS 6, Japan €11.50, about RM60 6 vCPUs, 12 GB of RAM and 200 GB of disk, month to month. About 82 ms from Malaysia.

Malaysia’s PDPA doesn’t require personal data to stay in the country, so Singapore and Japan are both allowed. Japan is around 60 ms further than Singapore would be. For a route request that’s not something a driver notices, and search as you type still feels quick.

The 12 GB mattered more than I expected. The earlier plan was to build the routing data in GitHub Actions and copy it over, because a 4 GB server can’t build it. With 12 GB the server builds its own data, which is simpler and uses none of the repo’s limited Actions minutes.

What it costs beyond the money: the CPU is shared, there’s no uptime guarantee, support is slow, and it’s one server. Nothing in the setup is specific to Contabo, though. It’s a setup script and a Docker Compose file, and moving to any Ubuntu server takes about an hour. If Singapore comes back in stock and the field test says latency matters, I’ll move it.

Routing: Valhalla, not OSRM

The prototype’s one sample route was saved from the public OSRM demo server. That server isn’t meant for real use, and it would see every trip. So Belang needs its own routing, and routing in Malaysia has a few needs of its own: tolls (show them, and let people avoid them), motorcycles, which are a big share of the traffic here, and later lorries, with height and weight limits. Lane guidance matters too at the big highway interchanges.

  • OSRM is very fast, but every vehicle type is its own preprocessed graph, and changing how routes are weighed, like avoiding tolls, means rebuilding. Cars, motorcycles and lorries would be three builds and three copies in memory.
  • GraphHopper is flexible, but I’d be writing and maintaining the motorcycle and lorry rules myself.
  • A paid API (Google, Mapbox, HERE) charges per request, and every trip goes to them.
  • Valhalla serves every vehicle type from one set of data and picks the rules per request: car, motorcycle, truck with its dimensions, avoid tolls. Routes come back with toll flags and lane information.

Valhalla is pinned to version 3.9.1. Its map data is Geofabrik’s Malaysia, Singapore and Brunei extract, which covers the Peninsula, Sabah, Sarawak and the crossings. Every Monday at 3 am Malaysia time a script checks for newer data, builds new routing tiles next to the live ones, and asks the new tiles for a route from KL Sentral to Bukit Beruntung (the same drive as the prototype’s demo). Only if that route comes back do the new tiles replace the old ones. A broken edit in OpenStreetMap can’t take routing down. The worst case is a map that’s a week old.

The trade-off: Valhalla is slower per request than OSRM. The target is under half a second for that test drive. It’s being set up this week.

Search and the map: also ours

The search box sees every place people look for, and a map server sees every area people look at. Paid APIs charge for each search or map load, and they keep logs.

So search will be Photon, which is built on OpenStreetMap for search as you type, on the same server. It needs about 2 GB of memory, and it lives or dies by OpenStreetMap’s Malaysian addresses, which are patchy in places. It has to get five real searches right before it ships, including “R&R Ulu Bernam” and “Hospital Kuala Lumpur”. The map will be one PMTiles file of Malaysia, read straight from storage with no tile server to run, which is also the starting point for offline maps. Both are planned, not built, and the map file’s size and monthly cost will be written down when they’re known.

Keeping the server private

Valhalla has a known denial-of-service bug with no fix released yet: one crafted request can stop a routing worker. I didn’t want to wait for a fix, so the server is locked down instead:

  • Only the proxy gets in. Every routing or search request needs a key that only Belang’s Worker has. Without it, the server answers 403. The key was made on the server and stored once as a secret in the Worker’s settings. It isn’t in the code.
  • Only what the app uses. Three Valhalla endpoints are reachable. Everything else is a 404, and request bodies are capped at 64 KB.
  • No access logs. A request URL can contain coordinates, so the web server (Caddy) writes none to disk. There’s nothing to leak and nothing to hand over.
  • HTTPS everywhere, with a free Let’s Encrypt certificate on an sslip.io host name until I buy a domain.
  • A plain, hardened host. SSH with a key only, no root or password login, a firewall that allows three ports, and automatic security updates. I rebooted it to check everything comes back by itself. Caddy was back in about 30 seconds.

Going without logs has a cost: debugging is harder, and so is spotting abuse. Checking from outside covers some of that.

Running a one-person project like a team

I build Belang by directing an AI coding agent, and I make the calls. That makes a paper trail more important, not less. A month from now I want to be able to find why something was built the way it was, in one click.

The routine is simple, and checks enforce most of it:

  • Every task is a GitHub issue with three parts: why, what to do, and how we’ll know it’s done. Each has a priority (P0 to P2) and an area, and sits under the Phase 1 milestone. Phase 1 started as 27 issues. One tracking issue lists them all.
  • Every pull request closes an issue. The description has to say Closes #n, or a check fails. One issue per pull request.
  • Squash merges, so main reads as one commit per finished task.
  • CI on every pull request: lint, a typecheck, 32 unit tests for the navigation logic, and a full build.
  • A preview for every pull request. Cloudflare’s Workers Builds deploys each pull request to its own preview, which I open on my phone before merging. Merging to main is the only way anything reaches production.
  • An uptime check. Every hour, GitHub Actions asks the server if it’s alive, three times 20 seconds apart. If all three fail, it opens an issue labelled outage. When the server answers again, it closes that issue and notes the time.

The uptime check is hourly for a boring reason. The repo is private, private repos get 2,000 free Actions minutes a month, and every run counts as at least a minute. Hourly is about 720 runs a month. Every five minutes would be over 8,600. When there are real users, it moves to a proper monitor.

Here’s what the trail looks like for one task, the server. Issue #5 said what was needed and what “done” meant. The whole hosting comparison happened in its comments, including five changes of mind in one day as prices and stock turned out differently from the listings. Pull request #32 closed it with a checklist of what had been tested on the real machine: a request without the key gets 403, plain HTTP is redirected to HTTPS, and everything survives a reboot. One box stayed unticked, because scheduled workflows only run from main, so the uptime check could only be confirmed after merging. It ran green right after.

As of today, five Phase 1 issues are closed, each by its own pull request, and main has 24 commits. The code is private, because Belang is meant to become a real app that people download and use, so I can’t link the issues here.

What it all costs

Piece Choice Cost a month Status
Server Contabo Cloud VPS 6, Japan €11.50 Running
Routing Valhalla 3.9.1 On the server Being set up
Search Photon On the server Planned
Map PMTiles from storage Storage and traffic, to be measured Planned
App, API and waitlist One Cloudflare Worker, waitlist in D1 Free plan on a shared account; US$5 if Workers Paid is ever needed Running
Uptime alerts GitHub Actions, hourly Free minutes Running

Next comes the private proxy, then search and the map, then real GPS, snapping to the road and rerouting. After that comes the part I’m looking forward to most: driving it around the Klang Valley to see where it gets things wrong.

Map data © OpenStreetMap contributors.