The Open Transit Stack Behind BusCyprus
BusCyprus is a public transit app for Cyprus: real-time arrivals, route maps, trip planning, service alerts. It is a founder-led project, built and operated by NAYEE's founder as an independent product, and it is the system where our open transit stack runs in production.
What riders see is an app. What actually runs it is a stack of open transit infrastructure that most people never think about: GTFS feeds, a transit data bundle, an API server, a realtime proxy, a geocoder, and a routing engine.
This post is a walkthrough of that stack: what each piece does, what we self-host, and where the sharp edges are.
The standards underneath
Everything starts with GTFS (General Transit Feed Specification). A GTFS feed is a zip file of CSV tables: stops, routes, trips, stop times, calendars. It is the closest thing public transit has to a universal data format.
On top of static GTFS sits GTFS-Realtime: protobuf feeds for vehicle positions, trip updates, and service alerts.
The important property of both: they are open standards. The same stack that serves BusCyprus could serve another city's feed by changing configuration, not code.
The GTFS bundle pipeline
OneBusAway does not read a GTFS zip directly at runtime. It reads a transit data bundle: pre-processed serialized objects and Lucene search indices built from the feed.
We build that bundle in Docker. The pipeline:
- fetch the GTFS zip from a configured URL
- run it through gtfstidy, a Go-based optimizer that cleans and normalizes the feed
- compile the cleaned feed into the serialized bundle and search index
- drop the result into a shared volume the API server mounts
This is a batch job, not a service. When the schedule changes, we rebuild the bundle and restart the app server against it.
The reason this matters: feed quality in the real world is messy. Headless trips, missing stop sequences, calendars that expired months ago. Having the bundle step explicit means feed problems show up as build problems, not as weird passenger-facing bugs.
OneBusAway in Docker
The API layer is OneBusAway (OBA), the open-source transit data platform originally built for Puget Sound. We run the v2 application suite as containers:
- an app server (Tomcat, Java 11) exposing the REST API
- MySQL for application state (PostgreSQL is also supported)
- the bundle volume from the build step above
- a JMX exporter for Prometheus metrics
The Docker images are multi-architecture (x86_64 and ARM64) and are published under the Open Transit Software Foundation org on Docker Hub: onebusaway-bundle-builder and onebusaway-api-webapp.
Configuration is template-driven. Database credentials, GTFS-RT endpoints, refresh intervals, and agency settings are environment variables rendered into OBA's XML config at container start. Adding a second region means a second compose stack with different env, not a code change.
The realtime layer
Static GTFS tells you where the bus should be. Realtime tells you where it is.
The Cyprus operator publishes GTFS-RT feeds, but they come with quirks: authentication headers, refresh behavior, and the usual real-world inconsistencies between what the feed claims and what trips actually exist in the static bundle.
We run a small Node gtfs-rt proxy between the source feeds and OBA. It handles the auth, normalizes the feed endpoints, and gives us a single place to log and monitor what the upstream feed is actually sending.
That last part matters more than it sounds. When riders report "the app says a bus is coming and it never arrived," the first question is always: did the feed say that, or did we? A proxy in the middle means we can answer that question in minutes.
We also keep a set of small monitoring scripts that diff GTFS-RT trip IDs against the static feed, check feed health, and alert when realtime goes stale.
Geocoding and search
Transit apps are search apps. "Bus stop near me," "how do I get to the old town," autocomplete on place names.
We self-host Photon for geocoding, backed by OpenStreetMap data for Cyprus. Stop discovery and nearby-stop lookups go through OBA's own APIs on top of the Lucene index from the bundle.
Trip planning
Journey planning uses OpenTripPlanner through the DigiTransit-style setup: OTP computes multimodal itineraries (walk, bus, transfer) over the same GTFS and OSM data.
This is the heaviest component in the stack in terms of compute, and the one with the most operational caveats. Graph builds are slow, memory-hungry, and sensitive to feed quality. It is also the piece where "the data says this transfer is possible" and "a human can actually make this transfer" diverge most often.
The apps
Two frontends consume the same OBA API:
- Wayfinder: a SvelteKit web app built on the OneBusAway SDK. Stop lookup, realtime arrivals, route maps on Leaflet/OpenStreetMap (with Google Maps as an alternative provider), trip planning, and service alerts.
- Android: a maintained build of the OneBusAway Android client pointed at our region.
One API, multiple clients, no per-client backend. That is the actual payoff of the open-standards choice.
What we self-host, and why
Everything above runs on infrastructure we control:
- GTFS bundle builds
- OBA API server + database
- GTFS-RT proxy and feed monitoring
- Photon geocoder
- OTP routing
The alternative is managed transit APIs and commercial map platforms. We use commercial integrations where they genuinely help, but the core transit data path stays open for three reasons:
- Cost shape. Per-request pricing on transit APIs scales badly with a free consumer app. A flat server cost scales fine.
- Control over the data path. When a feed misbehaves, we can inspect, patch, or proxy it ourselves instead of filing a ticket.
- The stack is the product. The same infrastructure pattern works for any GTFS-producing agency or operator. That is what Open Transit is about.
The honest tradeoffs
Self-hosting open transit is not free. The costs just move:
- You own feed quality. When the upstream GTFS is wrong, the bug is yours in the eyes of the rider, no matter whose fault it is.
- You own staleness. Expired calendars and missed bundle rebuilds become user-facing schedule errors.
- You own the graph builds. OTP is powerful and genuinely annoying to operate.
- You are upstream's QA, occasionally. We maintain patches where the real-world feed diverges from what OBA expects.
None of this is a reason not to do it. It is a reason to go in with open eyes.
Where this goes next
The stack behind BusCyprus is deliberately boring: standard formats, standard server, containers, one API. The interesting part is how much of a real passenger-facing product can be assembled from open components and open data, and operated at production scale by a small team.
If you are working with GTFS or GTFS-Realtime feeds, evaluating a self-hosted OBA deployment, or building a transit product on open standards, that is the work we do at NAYEE. See Open Transit or get in touch.
