Hrafnsyn: Unified Air and Sea Tracking with Phoenix LiveView, PostGIS, and Nix
9 min readI like projects that are easy to explain in one sentence and then keep getting more interesting the closer you look.
Hrafnsyn starts with a very simple pitch: it puts aircraft and vessels on one living map. ADS-B on the plane side, AIS on the boat side, Phoenix LiveView in front, PostgreSQL/PostGIS underneath, and realtime updates the whole way through.
That is already a fun project. But the part that makes it satisfying to me is that it is not just a map widget or a repo full of half-connected experiments. It is a composed operational system. Collectors, a stable ingest boundary, normalized persistence, a useful UI, auth modes, monitoring, and Nix-native deployment all line up in one codebase.
And now it is not just local anymore. Hrafnsyn is deployed from a real NixOS configuration, consumed through my NUR package/module path, monitored as a real service, and using a packaged aircraft-enrichment dataset that materially improves the aircraft side. That changes the story from “interesting Phoenix side project” to “this is actually coherent end to end.”
One map, two feed types, one ingest boundary
The UI headline is obvious: aircraft and vessels share one dashboard. But the architectural point is the seam behind it.
Hrafnsyn is intentionally split into three layers:
- source collection
- normalized ingest and persistence
- realtime presentation
Each upstream feed gets its own long-lived collector process. Right now the contracts are deliberately boring:
- aircraft via
GET /data/aircraft.json - vessels via
GET /api/ships_array.json
That is exactly what I want here. One process per source, isolated restart behavior, clear source identity for logs and metrics, and no special cases once the payload has been normalized.
The interesting part is that both collectors converge on a very small shared ingest path:
def ingest_batch(source, observations) do
touched_ids =
observations
|> Enum.reduce([], fn observation, acc ->
with {:ok, normalized} <- Observation.new(observation),
{:ok, track} <- upsert_track(source, normalized),
{:ok, _point} <- insert_track_point(track, source, normalized) do
[track.id | acc]
else
_ -> acc
end
end)
|> Enum.uniq()
if touched_ids != [] do
Phoenix.PubSub.broadcast(Hrafnsyn.PubSub, @topic, {:tracks_updated, touched_ids})
end
{:ok, touched_ids}
end
That is the core of the whole app. HTTP collectors use it. Future publishers can use it. The gRPC ingest surface can use it too. Once observations cross that boundary, the rest of the system does not need to care where they came from.
The merge model is what makes it useful
The storage layout is where the “unified tracker” idea turns into something more than a single map layer.
Hrafnsyn keeps two related tables:
tracksfor the latest merged state of a vehicletrack_pointsfor the append-only history
The schema makes the merge rules explicit:
create unique_index(:tracks, [:vehicle_type, :identity])
create unique_index(:track_points, [:track_id, :source_id, :observed_at])
That means a plane is uniquely identified by its hex code and a vessel by its MMSI, scoped by vehicle type. If multiple sources report the same vehicle, they merge into one current track. But every source can still leave behind its own historical points.
That combination is the right one for this kind of system:
- the dashboard shows a clean current state
- search works against a normalized identity surface
- route history stays durable instead of evaporating with the last websocket message
The LiveView side stays pleasantly direct because persistence and fan-out are already solved. When tracks update, Phoenix PubSub pushes the change and the dashboard refreshes the map and side panels without inventing a custom frontend state machine for everything.
The aircraft side got meaningfully better
One of the newer pieces, and one of the most worthwhile ones, is the packaged static aircraft database.
Live ADS-B feeds are useful, but they are often incomplete. Registration might be missing. Aircraft type may be absent or inconsistent. Some of the details you actually want for a good operational dashboard are available, just not reliably from the live stream itself.
Hrafnsyn now supports a dump1090-derived NDJSON metadata artifact keyed by ICAO hex. It adds:
- registration overrides
- aircraft type
- type description
- wake turbulence category
The loader is intentionally simple:
@default_record %{
registration: nil,
aircraft_type: nil,
type_description: nil,
wake_turbulence_category: nil
}
And the enrichment precedence is exactly what you would hope:
%__MODULE__{
observation
| registration: observation.registration || static.registration || derived.registration,
aircraft_type: observation.aircraft_type || static.aircraft_type,
type_description:
observation.type_description || normalize_type_description(static.type_description),
wake_turbulence_category:
observation.wake_turbulence_category || static.wake_turbulence_category,
country: observation.country || derived.country
}
In other words:
- live feed values win first
- packaged static metadata fills the gaps second
- ICAO-derived fallbacks come last
That is a strong model because it improves the plane side without pretending the static dataset is the source of truth for everything.
More importantly, this database is not a random side file I copy into place by hand. It is a first-class flake output:
aircraftDb = pkgs.runCommand "hrafnsyn-aircraft-db" {
nativeBuildInputs = [ pkgs.python3 ];
} ''
mkdir -p "$out/share/hrafnsyn"
python ${./scripts/build_aircraft_db.py} \
${dump1090Src} \
"$out/share/hrafnsyn/aircraft-db.ndjson" \
--metadata-output "$out/share/hrafnsyn/aircraft-db-metadata.json" \
--source-revision ${dump1090Rev}
'';
packages."aircraft-db" = aircraftDb;
packages.aircraftDb = aircraftDb;
That makes a real difference. The app package and the enrichment package can move through the same Nix-native pipeline instead of one being declarative and the other being “well, remember to put this file on disk somewhere.”
The local developer loop is unusually friendly
I care a lot about whether a project feels easy to pick back up after not touching it for a week. Hrafnsyn does.
The shortest local path is:
nix develop
app
And app is not magic. It is just a small helper that does the right boring things in the right order:
app = pkgs.writeShellScriptBin "app" ''
set -euo pipefail
pg-reset
pg-start
eval "$(pg-env)"
mix ecto.setup
exec mix phx.server
'';
There is still a manual path if I want it:
nix develop
pg-start
eval "$(pg-env)"
mix setup
mix phx.server
But the helper matters. The flake does not just build the release; it makes the local development loop pleasant too.
Two clean consumption paths
This is one of the reasons I think Hrafnsyn is more interesting than a normal “here is my Phoenix app” project. It is packaged to be consumed the same way I want to consume other software myself.
The first route is direct: add the project as a flake input and use its package or NixOS module directly.
{
inputs.hrafnsyn.url = "github:ijohanne/hrafnsyn";
outputs = { nixpkgs, hrafnsyn, ... }: {
nixosConfigurations.tracker = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
hrafnsyn.nixosModules.default
({ pkgs, ... }: {
services.hrafnsyn = {
enable = true;
package = hrafnsyn.packages.${pkgs.system}.default;
aircraftDbPackage = hrafnsyn.packages.${pkgs.system}.aircraftDb;
};
})
];
};
};
}
The second route is through ijohanne/nur-packages, which exposes both the application and the aircraft metadata package:
legacyPackages = forPackSystems (system:
(import ./default.nix {
pkgs = import nixpkgs { inherit system; };
inherit sources;
}) // {
hrafnsyn = inputs.hrafnsyn.packages.${system}.default;
hrafnsyn-aircraft-db = inputs.hrafnsyn.packages.${system}.aircraftDb;
});
nixosModules = (import ./modules) // {
hrafnsyn = inputs.hrafnsyn.nixosModules.default;
};
That gives consumers both paths:
- use
inputs.hrafnsyndirectly - use the curated NUR path for packages and module imports
I like that because it mirrors how I actually reuse personal infrastructure projects. Some are easier to consume directly. Some fit better through a curated package collection. Hrafnsyn does not force the choice.
The NixOS module grew into a real deployment story
This is the biggest reason the project feels complete now.
The module is no longer just enough to say “yes, you could probably run this on NixOS.” It supports the actual pieces that make a deployment feel native:
- structured
databasesettings withhost,name,user, and optionalpasswordFile aircraftDbPackageas the preferred way to inject the packaged metadata artifactaircraftDbPathas a raw-path escape hatch when needed- structured
sourcesrendered intoHRAFNSYN_SOURCES_JSON - optional nginx helper
- optional dedicated metrics port
- readonly-public versus authenticated-private mode
The runtime contract in config/runtime.exs matches that shape cleanly. For PostgreSQL, it accepts either a traditional DATABASE_URL or structured DATABASE_HOST, DATABASE_NAME, DATABASE_USER, and DATABASE_PASSWORD. For aircraft enrichment, it consumes HRAFNSYN_AIRCRAFT_DB_PATH. Nothing here feels bolted on.
On NixOS, the structured local PostgreSQL path is especially nice because the module can create the database and user automatically, then enable citext, pg_trgm, and postgis before hrafnsyn.service starts. The nginx helper also does the right thing when the app binds wildcard addresses like 0.0.0.0: nginx still proxies back over loopback instead of trying to hairpin through the public interface.
Here is the sort of representative configuration I mean:
{
services.postgresql.enable = true;
services.hrafnsyn = {
enable = true;
package = nurPackages.hrafnsyn;
aircraftDbPackage = nurPackages.hrafnsyn-aircraft-db;
host = "tracks.example.com";
port = 4020;
metricsPort = 4022;
listenAddress = "0.0.0.0";
autoMigrate = true;
publicReadonly = false;
database = {
host = "/run/postgresql";
name = "hrafnsyn";
user = "hrafnsyn";
};
secretKeyBaseFile = /run/secrets/hrafnsyn-secret-key-base;
sources = [
{
id = "planes-main";
name = "Airplane SDR";
vehicleType = "plane";
adapter = "dump1090";
baseUrl = "http://collector-a.internal";
pollIntervalMs = 1000;
}
{
id = "boats-main";
name = "Boat SDR";
vehicleType = "vessel";
adapter = "ais_catcher";
baseUrl = "http://collector-b.internal:8100";
pollIntervalMs = 2500;
}
];
};
}
That is not hypothetical anymore. I am running a deployment in this style now, via the NUR module path, with managed secrets, local PostgreSQL socket auth, the packaged aircraft DB wired in declaratively, and the dashboard in authenticated/private mode rather than public-readonly mode.
I am being intentionally vague about hostnames, addresses, and secret wiring, because there is no value in dumping private infrastructure details into a blog post. But the important part is that the deployment is real enough to prove the package and module interfaces are not decorative.
Public display mode and private ops mode both make sense
I also like the auth split here because it matches the actual use cases.
publicReadonly = true is good for a publicly visible tracking screen or a household dashboard where anonymous viewers can look but not touch anything.
publicReadonly = false flips the posture. Anonymous web users are redirected to login, tracking gRPC calls require JWTs, and ingestion can require authenticated admin tokens. That is the mode I am using for the live deployment right now.
This is a nice example of a small configuration flag doing real design work. It is not “auth later.” The public and private operating modes are part of the application model.
Monitoring is part of the project, not an afterthought
Hrafnsyn exposes /metrics on the main endpoint and can split metrics onto a dedicated port with metricsPort. The module can also append a Prometheus scrape job and provision the bundled Grafana dashboard.
That matters because it pushes the project over the line from neat UI into real service. I can deploy it, scrape it, and drop the shipped dashboard into Grafana instead of promising myself I will “add observability later.”
Again, that is not theoretical for me anymore. The live deployment is scraped by Prometheus on its dedicated metrics port, and the bundled Hrafnsyn Grafana dashboard is provisioned from the repo.
Why I think this project is satisfying
The fun part of Hrafnsyn is not just that it shows planes and boats together. It is that several small systems decisions reinforce each other cleanly:
- collectors stay simple
- ingest stays centralized
- storage keeps both current state and history
- LiveView gets realtime updates without frontend contortions
- packaging covers both the app and the aircraft metadata artifact
- deployment works directly or through NUR
- auth can be public-readonly or intentionally private
- monitoring is already part of the story
That is the kind of software project I enjoy most. Not a giant platform. Not a throwaway demo. Just one coherent codebase where UI, ingest, storage, enrichment, auth, monitoring, and deployment all belong to the same idea.
And now that it is deployed for real, I find it much easier to argue that Hrafnsyn is interesting for the right reasons. It is not simply “look, I put a map on the web.” It is a practical Phoenix and Nix system with a clean boundary design, a nice operational shape, and just enough ambition to stay fun.