Deploys the GTFS RT stack so users can register feeds and publish to them from the Owntrack mobile app manage.rt.gtfs.zone
  • Shell 60.1%
  • PLpgSQL 39.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Max Katz-Christy d379149ddc
All checks were successful
Validate / validate (push) Successful in 9s
chore: point ArgoCD applications at the GitHub repo
2026-10-01 02:16:47 +02:00
.forgejo/workflows Add sites/ tree for the three static sites 2026-08-18 01:44:26 +02:00
apps chore: point ArgoCD applications at the GitHub repo 2026-10-01 02:16:47 +02:00
gtfs deploy(geometry-car): geometry-car da0fb12 2026-09-30 19:27:50 +00:00
infra chore(dns): restore policy sync now that the apex is unowned 2026-08-27 15:26:08 +02:00
scripts fix: bumps and add check-bumps script 2026-08-03 21:58:27 +02:00
sites deploy(list.gtfs.zone): globe-of-contents v0.5.1 2026-09-30 22:44:50 +00:00
.cz.toml chore: create annotated tags on bump so --follow-tags pushes them 2026-08-15 12:40:19 +02:00
.gitignore chore: remove emoji and em-dashes 2026-08-04 20:10:11 +02:00
.gitleaks.toml chore: add gitleaks config 2026-09-30 20:41:50 +02:00
.pre-commit-config.yaml refactor: apply copier (#58) 2026-05-03 21:54:49 +00:00
.sops.yaml chore(k3s): Phase 0 prep — skeleton, SOPS age key, gitignore 2026-07-22 16:47:50 +02:00
CHANGELOG.md bump: version 0.1.0 → 0.1.1 2026-03-03 15:02:00 +01:00
CLAUDE.md feat(sites): serve sites.gtfs.zone from Garage, built by cape-flier 2026-09-28 18:34:26 +02:00
DISK_FULL_RECOVERY.md docs: add Phase 0b, the container prune fallout 2026-09-11 11:46:14 +02:00
FIRST_RUN_CHECKLIST.md deploy(producers): vehicle-poser 3272ad3, trip-updogger 6e71988, hell-gate-bridge 9ce7261 2026-09-22 10:57:13 +02:00
LICENSE.txt agpl-3 and missing files 2026-03-02 16:29:27 +01:00
README.md docs: point README repo and issue links to GitHub and list every project 2026-10-01 01:12:48 +02:00

deploy-gtfs-rt

GTFS.Zone is a "public option" for transit operators to publish real-time GTFS feeds. The goal is to make it as simple, lightweight, and inexpensive as possible: a small agency with minimal technical resources should be able to get a live feed running in an afternoon.

Operators who don't want to self-host can use an already-running instance without touching any of this. This repo is for those who want to run their own.

How it fits together

The stack is built from these open-source projects:

Project Role
cafe-car Core API, serves GTFS-RT feeds and handles admin
vehicle-poser Receives Traccar position forwards over HTTP → Redis
trip-updogger Sweeps live positions against the schedule → trip updates in Redis
hell-gate-bridge Polls upstream feeds (Amtrak, Columbia County) → rt-api
schedule-foamer Celery worker + beat scheduler for async static GTFS fetching
railroad-club Shared SQLAlchemy models and Alembic migrations
music-student Docker Compose stack for local development and testing
landing-zone Static homepage at gtfs.zone
geometry-car Dagster pipeline: the GTFS source catalog, its reachability checks and logical feeds, published to data.gtfs.zone
globe-of-contents list.gtfs.zone, the source catalog as a list and a world map
coloring-book GTFS editor at edit.gtfs.zone
test-track Realtime visualiser at viz.rt.gtfs.zone
yard-master Admin SPA at manage.rt.gtfs.zone, on cafe-car's JSON API
cape-flier Static timetable sites at sites.gtfs.zone, a Dagster code location in geometry-car's instance
interlocking Shared browser library and app shell for coloring-book, test-track, yard-master and globe-of-contents

This repo provides the Kubernetes (k3s + ArgoCD) deployment that wires them together with supporting infrastructure.

flowchart LR
    classDef repo fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
    classDef dir fill:#fef9c3,stroke:#d97706,color:#5c3d00
    classDef gitops fill:#dcfce7,stroke:#16a34a,color:#14532d

    apps[["`**apps/**<br>ArgoCD Applications`"]]:::gitops
    infra["`**infra/**<br>Helm values + CRs`"]:::dir
    gtfsd["`**gtfs/**<br>Kustomize app stack`"]:::dir
    argo(["`**ArgoCD**<br>watches this repo`"]):::gitops

    apps --> argo
    infra --> apps
    gtfsd --> apps

    subgraph registry["Container Registry"]
        cc(["**cafe-car**<br>GTFS-RT API + admin"]):::repo
        vp(["**vehicle-poser**<br>Traccar → Redis shim"]):::repo
        tu(["**trip-updogger**<br>positions → trip updates"]):::repo
        hgb(["**hell-gate-bridge**<br>upstream feed pollers"]):::repo
        sf(["**schedule-foamer**<br>GTFS Static Downloader"]):::repo
        gc(["**geometry-car**<br>source catalog pipeline"]):::repo
    end

    rc(["**railroad-club**<br>SQLAlchemy models + migrations"]):::repo
    ms(["**music-student**<br>local dev Compose"]):::repo

    rc -->|"models + migrations"| cc
    rc -->|"models"| vp
    rc -->|"models"| sf

    cc & vp & tu & hgb & sf & gc -->|"container image"| gtfsd

    gtfsd -.-|"mirrors for local dev"| ms

Issues: issue tracker


What you get

URL Service
rt.<domain> Public GTFS-RT feed API
manage.rt.<domain> Admin UI (auth-gated)
auth.<domain> oauth2-proxy sign-in
id.<domain> Keycloak OIDC provider (brokers GitHub/Google/GitLab)
traccar.<domain> Traccar console; /osmand takes phone position reports
status.<domain> Public status page (Gatus)
argocd.<domain> ArgoCD UI
data.<domain> Public source-catalog artifacts (feeds.json, sources.json, ...) from Garage's public bucket
dagster.<domain> Dagster UI for geometry-car (gated on the gtfs-admins group)
list.<domain> Source catalog list and world map (globe-of-contents)
edit.<domain> GTFS editor (coloring-book)
viz.rt.<domain> Realtime visualiser (test-track)

System Diagrams

Real-time data flow

A position update travels from a driver's phone to a GTFS-RT consumer in under a second.

sequenceDiagram
    actor driver as Driver<br>(Traccar Client app)
    actor operator as Operator
    participant mgr as cafe-car admin
    participant tc as Traccar
    participant vp as vehicle-poser
    participant tu as trip-updogger
    participant hgb as hell-gate-bridge
    participant Redis@{ "type": "database" }
    participant postgres@{ "type": "database" }
    participant pub as cafe-car public
    actor consumer as GTFS Consumer<br>(Google Maps, etc.)

    Note over driver,tc: Device provisioning
    operator->>mgr: create tracker
    mgr->>tc: POST /api/devices (admin creds)
    mgr-->>operator: QR code (contains /osmand URL + uniqueId)
    driver->>driver: scan QR into Traccar Client

    Note over driver,Redis: Real-time position update
    driver->>tc: POST /osmand?id=…&lat=…&lon= (HTTPS)
    tc->>vp: forward.type=json → POST /forward
    vp->>postgres: resolve tracker → trip
    vp->>Redis: SET vehicle:{tracker}:{vehicle} (60s TTL)

    Note over tu,Redis: Delay derivation
    tu->>Redis: sweep vehicle:*
    tu->>postgres: load the trip's stop_times
    tu->>Redis: SET trip_update:{tracker}:{trip_id} (300s TTL)

    Note over hgb,pub: Upstream feed polling
    hgb->>hgb: poll Amtrak / Columbia County
    hgb->>pub: POST /ingest/position + /ingest/trip-update (bearer)
    pub->>Redis: store positions & delays

    Note over operator,mgr: Admin
    operator->>mgr: POST /alerts
    mgr->>postgres: INSERT service_alert
    postgres-->>mgr: ok
    mgr-->>operator: 201 Created

    Note over pub,consumer: Vehicle Positions
    consumer->>pub: GET /{feed}/vehicle_positions.pb
    pub->>Redis: read vehicle:*
    Redis-->>pub: positions
    pub-->>consumer: VehiclePosition FeedMessage

    Note over pub,consumer: Trip Updates
    consumer->>pub: GET /{feed}/trip_updates.pb
    pub->>Redis: read trip_update:*
    Redis-->>pub: delays
    pub-->>consumer: TripUpdate FeedMessage

    Note over pub,consumer: Service Alerts
    consumer->>pub: GET /{feed}/service_alerts.pb
    pub->>postgres: SELECT service_alerts
    postgres-->>pub: alerts
    pub-->>consumer: Alert FeedMessage

System context

External actors and systems the stack integrates with.

flowchart LR
    driver(["Driver<br>(Traccar Client app)"])
    operator(["Transit Operator"])
    consumer(["GTFS Consumer<br>(Google Maps, etc.)"])

    traccar_app["Traccar Client<br>Free & open source GPS tracker app"]
    oauth["OAuth Provider<br>GitHub / GitLab / Google"]
    porkbun["Porkbun DNS<br>DNS-01 certs + external-dns records"]
    gtfs_src["Static GTFS Source<br>Agency schedule ZIP files"]
    upstream["Upstream RT feeds<br>Amtrak · Columbia County"]

    stack["GTFS.Zone Stack<br>(k3s + ArgoCD)"]

    driver -->|"drives with"| traccar_app
    traccar_app -->|"HTTPS /osmand"| stack
    operator -->|"admin UI"| stack
    stack -->|"GTFS-RT protobuf"| consumer
    stack -->|"DNS + cert management"| porkbun
    oauth -->|"OIDC tokens"| stack
    stack -->|"fetch schedule"| gtfs_src
    upstream -->|"polled by hell-gate-bridge"| stack

Core data model

All models defined in railroad-club and shared across services.

erDiagram
    USER {
        int id PK
        string primary_email
        string display_name
        datetime created_at
    }
    IDENTITY {
        int id PK
        int user_id FK
        string provider
        string provider_subject
        string email
        boolean email_verified
        datetime linked_at
        datetime last_seen_at
    }
    FEED_MEMBER {
        int id PK
        int feed_id FK
        int user_id FK
        int added_by_user_id FK
        datetime created_at
    }
    FEED_INVITE {
        int id PK
        int feed_id FK
        string email
        int invited_by_user_id FK
        int claimed_user_id FK
        datetime claimed_at
        datetime created_at
    }
    FEED {
        int id PK
        string feed_name
        string static_feed_url
        int owner_id FK
        int gtfs_static_feed_id FK
    }
    TRACKER {
        string id PK
        string nickname
        int feed_id FK
    }
    TRACKER_RULE {
        int id PK
        string tracker_id FK
        string trip_id
        boolean monday
        boolean tuesday
        boolean wednesday
        boolean thursday
        boolean friday
        boolean saturday
        boolean sunday
        time start_time
        time end_time
    }
    SERVICE_ALERT {
        int id PK
        int feed_id FK
        string header_text
        string description_text
        string url
        string cause
        string effect
        string severity_level
        datetime active_period_start
        datetime active_period_end
    }
    INFORMED_ENTITY {
        int id PK
        int service_alert_id FK
        string agency_id
        string route_id
        int route_type
        int direction_id
        string stop_id
        string trip_id
        string trip_route_id
        int trip_direction_id
        string trip_start_time
        string trip_start_date
    }
    GTFS_STATIC_FEED {
        int id PK
        string timezone
        string status
        string error_message
        datetime last_loaded_at
        datetime started_at
        datetime next_retry_at
    }
    GTFS_STOP {
        int id PK
        int gtfs_static_feed_id FK
        string stop_id
        string stop_name
        float stop_lat
        float stop_lon
        string stop_code
        string stop_desc
    }
    GTFS_ROUTE {
        int id PK
        int gtfs_static_feed_id FK
        string route_id
        string agency_id
        string route_short_name
        string route_long_name
        int route_type
    }
    GTFS_TRIP {
        int id PK
        int gtfs_static_feed_id FK
        string trip_id
        string route_id
        string service_id
        string trip_headsign
        int direction_id
    }
    GTFS_STOP_TIME {
        int id PK
        int gtfs_static_feed_id FK
        string trip_id
        string stop_id
        string arrival_time
        string departure_time
        int stop_sequence
    }

    USER ||--o{ IDENTITY : "signs in through"
    USER ||--o{ FEED : owns
    USER ||--o{ FEED_MEMBER : "is shared into"
    FEED ||--o{ FEED_MEMBER : "shared with"
    FEED ||--o{ FEED_INVITE : "pending invite"
    FEED }o--o| GTFS_STATIC_FEED : "loaded from"
    FEED ||--o{ TRACKER : has
    TRACKER ||--o{ TRACKER_RULE : has
    FEED ||--o{ SERVICE_ALERT : has
    SERVICE_ALERT ||--o{ INFORMED_ENTITY : targets
    GTFS_STATIC_FEED ||--o{ GTFS_STOP : contains
    GTFS_STATIC_FEED ||--o{ GTFS_ROUTE : contains
    GTFS_STATIC_FEED ||--o{ GTFS_TRIP : contains
    GTFS_STATIC_FEED ||--o{ GTFS_STOP_TIME : contains

Service routing

Hostname routing from the internet through to each service, with data-layer connections. The edge is home-docker's Traefik, which passes *.gtfs.zone through untouched by SNI; the cluster's own Traefik terminates TLS.

stateDiagram-v2
    Internet : Internet
    edge : home-docker Traefik<br>owns :80/:443 · SNI passthrough for *.&ltdomain&gt
    tr : k3s Traefik<br>websecure :8443 · TLS from cert-manager
    tc : Traccar<br>:8082 console · :5055 osmand
    gt : Gatus
    ag : ArgoCD
    gw : Garage s3_web<br>public data.&ltdomain&gt bucket

    state "Auth" as auth {
        op : oauth2-proxy<br>ForwardAuth middleware
        opa : oauth2-proxy-admin<br>requires gtfs-admins
        kc : Keycloak<br>OIDC provider · brokers GitHub/Google/GitLab
    }

    state "cafe-car" as application {
        cp : gtfs-api<br>public GTFS-RT feed
        ca : gtfs-manager<br>admin interface
    }

    state "Workers" as workers {
        vp : vehicle-poser
        tu : trip-updogger
        hgb : hell-gate-bridge ×2
        sf : schedule-foamer<br>Celery worker + beat
        gc : geometry-car<br>Dagster webserver · daemon · code server
    }

    [*] --> Internet
    Internet --> edge
    edge --> tr : *.&ltdomain&gt (passthrough)

    tr --> cp : rt.&ltdomain&gt
    tr --> op : manage.rt / auth.&ltdomain&gt
    tr --> kc : id.&ltdomain&gt
    tr --> tc : traccar.&ltdomain&gt (+ /osmand)
    tr --> gt : status.&ltdomain&gt (public)
    tr --> ag : argocd.&ltdomain&gt
    tr --> gw : data.&ltdomain&gt (public)
    tr --> opa : dagster.&ltdomain&gt

    op --> ca : manage.rt.&ltdomain&gt (authed)
    op --> kc : OIDC token check
    opa --> gc : dagster.&ltdomain&gt (gtfs-admins)
    opa --> kc : OIDC token check
    gc --> gw : publishes artifacts (S3 API)

    tc --> vp : forward.type=json
    hgb --> cp : POST /ingest/*

Object storage (Garage)

gtfs/garage.yaml runs a single-node dxflrs/garage StatefulSet with two buckets, each reached with its own access key, so a leaked pipeline credential reaches one bucket and not the other:

  • gtfs-feeds is private, reachable only over the cluster-internal S3 API. It holds the GTFS zips uploaded through manage.rt.gtfs.zone: cafe-car writes and serves them, schedule-foamer reads them to load the schedule.
  • data.gtfs.zone is public, written by geometry-car over the S3 API and served read-only over plain HTTP by Garage's s3_web endpoint at data.gtfs.zone. Garage picks the bucket from the Host header, which is why the bucket's alias is literally the hostname and why the IngressRoute must not rewrite the Host.

Every writer talks the S3 API rather than Garage's own, so swapping in AWS, R2 or B2 later is a matter of changing S3_ENDPOINT. A garage-init Job (an ArgoCD PostSync hook) applies the single-node layout, creates both buckets, imports their keys and enables website access on the public one; Garage refuses every S3 call until that runs.

Source catalog

geometry-car ingests Transitland Atlas and the Mobility Database once a day, checks whether each endpoint answers, keeps history per normalized URL in its own Postgres database, and publishes JSON to data.gtfs.zone. The frontends read those artifacts at runtime rather than shipping a catalog baked in at build time: the edit and viz load modals list feeds.json, and list.gtfs.zone draws the whole catalog on a map.

flowchart LR
    tl["Transitland Atlas"]
    mdb["Mobility Database"]
    ex["curated examples"]
    gc["geometry-car<br>Dagster, daily"]
    pg[("Postgres<br>geometry_car")]
    bucket[("data.gtfs.zone<br>public bucket")]
    edit["edit / viz<br>load modal"]
    list["list.gtfs.zone"]

    tl & mdb & ex --> gc
    gc -->|"check history"| pg
    gc -->|"feeds.json · sources.json · status.json<br>examples.json · summary.json · manifest.json"| bucket
    bucket --> edit
    bucket --> list

Catalog rows (sources.json) are kept one per catalog entry and never merged. Logical feeds (feeds.json) sit on top: one per transit system, bundling its scheduled and realtime endpoints across catalogs, with ids that stay stable across runs because they appear in shareable links.

Real-time data pipeline

A position from a driver's phone becoming a GTFS-RT protobuf response.

flowchart LR
    driver(["Driver<br>Traccar Client app"])

    subgraph ingest["Ingest"]
        traccar["Traccar<br>/osmand :5055 · console :8082"]
        vp["vehicle-poser<br>HTTP /forward"]
        hgb["hell-gate-bridge<br>Amtrak · Columbia County"]
    end

    subgraph store["State"]
        redis[("Redis DB1<br>vehicle:{tracker}:{vehicle} 60s<br>trip_update:{tracker}:{trip} 300s")]
        pg[("Postgres<br>trackers · trips · alerts")]
    end

    tu["trip-updogger<br>positions → delays"]
    api["cafe-car gtfs-api"]
    consumer(["GTFS-RT consumer"])

    driver -->|"HTTPS POST /osmand"| traccar
    traccar -->|"forward json"| vp
    vp -->|"resolve tracker → trip"| pg
    vp --> redis
    redis -->|"sweep vehicle:*"| tu
    pg -->|"scheduled stop_times"| tu
    tu -->|"trip_update:*"| redis
    hgb -->|"POST /ingest/* (bearer)"| api
    api --> redis
    api --> pg
    api -->|"protobuf"| consumer

Auth flow

How an operator reaches a protected service via Keycloak and oauth2-proxy.

stateDiagram-v2
    [*] --> Requesting: operator visits manage.rt.&ltdomain&gt

    state Requesting {
        [*] --> ForwardAuth: Traefik → oauth2-proxy
        ForwardAuth --> [*]: session valid
        ForwardAuth --> Login: no session
        Login --> [*]: cookie set
    }

    state Login {
        [*] --> Keycloak
        Keycloak --> OAuthProvider: redirect to GitHub / Google / GitLab
        OAuthProvider --> Keycloak: auth code
        Keycloak --> [*]: ID token (sub = Keycloak UUID) → session
    }

    Requesting --> Serving: authenticated

    state Serving {
        [*] --> Protected: cafe-car admin
        Protected --> [*]: 200 OK
    }

    Serving --> [*]

Development

# Install git hooks (required once per clone)
pre-commit install

There is no imperative deploy step. ArgoCD watches this repo and reconciles the cluster to match it: you change YAML, commit, and push.

Before committing changes under gtfs/, render the tree the same way ArgoCD's KSOPS plugin does:

PATH="$HOME/.local/bin:$PATH" SOPS_AGE_KEY_FILE=$PWD/age.key \
  kustomize build --enable-alpha-plugins --enable-exec gtfs

For changes under infra/, render the actual upstream chart with your values rather than trusting the documented defaults:

helm template <release> <repo>/<chart> --version <v> -n <ns> -f infra/<comp>/values.yaml

Cluster access

kubectl requires an SSH tunnel, since the kubeconfig points at 127.0.0.1:6443. Keep this running in a separate terminal:

ssh -N -L 6443:127.0.0.1:6443 kcfam

ArgoCD signs in through Keycloak ("Log in via Keycloak"), and authorizes on the argocd-admins group; argocd login argocd.gtfs.zone --sso does the same for the CLI. The local admin account stays enabled as break-glass, since Keycloak depends on the database ArgoCD deploys:

kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d

Updating ArgoCD itself

ArgoCD is deliberately not self-managed, so infra/argocd/values.yaml is not reconciled by a sync. Apply it by hand, always with --version, or the change silently becomes an ArgoCD upgrade as well:

helm upgrade argocd argo/argo-cd -n argocd --version 10.1.4 -f infra/argocd/values.yaml

Database access

Set up port-forward in a separate terminal:

kubectl -n gtfs port-forward svc/postgres-rw 5432:5432

Access via SQL (psql):

PGPASSWORD="$(kubectl -n gtfs get secret postgres-rt-api -o jsonpath='{.data.password}' | base64 -d)" \
  psql -h localhost -U rt_api -d rt_api

Prerequisites

  • A server with a public IP running k3s (installed with --disable traefik; this repo brings its own via Helm) and open-iscsi enabled for Longhorn.
  • A domain on Porkbun with API access enabled, used by both cert-manager (DNS-01) and external-dns.
  • At least one OAuth provider (GitHub, GitLab, or Google) for user login.
  • Local tooling: kubectl, helm, kustomize, sops, age, ksops.

Host tuning: k3s, Longhorn and containerd share root's inotify quota. The default fs.inotify.max_user_instances=128 is not enough and fails in confusing ways (processes silently unable to create file watchers). Set it to 1024 in /etc/sysctl.d/.

Step 1: Domain and DNS API

  1. Buy a domain at porkbun.com.
  2. In your Porkbun account go to API → enable API access for the domain.
  3. Generate an API key pair (pk1_... / sk1_...); you'll need both.

DNS records are not written by hand: external-dns creates them from the external-dns.alpha.kubernetes.io/target annotation on each IngressRoute. The bare apex is left alone deliberately.

Step 2: OAuth app

Create an OAuth app with at least one provider. Use https://id.<your-domain>/realms/gtfs/broker/github/endpoint as the authorization callback URL. (Dex is gone; a https://dex.<your-domain>/callback entry left over from before the cutover can be deleted.)

  • GitHub: Settings → Developer settings → OAuth Apps → New OAuth App
  • GitLab: User Settings → Applications
  • Google: Google Cloud Console → APIs & Services → Credentials → OAuth 2.0 Client ID (Web application)

Step 3: Secrets (SOPS + age)

Secrets live in git, encrypted with SOPS and an age key, and are decrypted inside the cluster by a KSOPS plugin sidecar on the argocd-repo-server.

age-keygen -o age.key                      # keep this OUT of git (it is gitignored)
# put the public recipient in .sops.yaml, then edit secrets with:
sops infra/secrets/porkbun-secret.enc.yaml
sops gtfs/secrets/gtfs-app-secrets.enc.yaml

sops set updates a single value without printing plaintext. The age private key must also exist in-cluster as the sops-age Secret in the argocd namespace: that is the one piece of out-of-band bootstrap this design needs.

Populate at minimum:

  • infra/secrets/: Porkbun API key/secret, once per consuming namespace (cert-manager uses PORKBUN_API_KEY/PORKBUN_SECRET_API_KEY, external-dns uses API_KEY/API_SECRET), plus argocd-oidc.enc.yaml (clientSecret), which argocd-server reads for Keycloak SSO. It must equal KEYCLOAK_ARGOCD_CLIENT_SECRET in gtfs-app-secrets, and must exist before ArgoCD starts with oidc.config set.
  • gtfs/secrets/gtfs-app-secrets.enc.yaml: session key, the two oauth2-proxy cookie secrets (OAUTH2_PROXY_COOKIE_SECRET, OAUTH2_PROXY_ADMIN_COOKIE_SECRET), the Keycloak↔oauth2-proxy, ↔Traccar, ↔cafe-car and ↔ArgoCD client secrets (KEYCLOAK_*_CLIENT_SECRET), the Keycloak bootstrap admin password, OAuth connector credentials, INGEST_API_TOKEN, the TRACCAR_ADMIN_* pair, the Garage secrets and both buckets' key pairs (S3_*, GEOMETRY_CAR_S3_*), geometry-car's MOBILITY_DB_REFRESH_TOKEN, and the Gatus Telegram and heartbeat tokens.
  • gtfs/secrets/postgres-*.enc.yaml: CNPG role passwords.

Step 4: Bootstrap

Point the hostnames and the target IP at your own domain first: they are referenced in gtfs/ingressroutes.yaml, infra/argocd/manifests/ingress.yaml, infra/cert-manager/manifests/, gtfs/keycloak/gtfs-realm.json and gtfs/traccar/traccar.xml.

# 1. install ArgoCD (once, out of band; it is deliberately NOT self-managed)
helm install argocd argo/argo-cd -n argocd --create-namespace --version 10.1.4 \
  -f infra/argocd/values.yaml

# 2. give it the age key so it can decrypt secrets
kubectl -n argocd create secret generic sops-age --from-file=keys.txt=age.key

# 3. hand it the repo; everything else follows from the app-of-apps
kubectl apply -f apps/root.yaml

Watch it converge with kubectl get app -n argocd. Sync waves bring things up in order: storage/database operators → edge and DNS → issuers and secrets → the app.

On a cold bootstrap, ArgoCD's oidc.config references the argocd-oidc Secret that step 3 is what creates, so the Keycloak login button does not work until infra-secrets (wave 2) has synced. Use the local admin account until then; the argocd-admins group membership also has to be assigned by hand, since group membership is per-user and not part of the realm import.

Step 5: First run

  1. curl https://rt.<domain>/health should be 200 over a Let's Encrypt cert issued by cert-manager (not by whatever fronts your edge).
  2. Sign in at https://manage.rt.<domain>; the first login creates the owner account that feeds are attached to.
  3. Bootstrap Traccar: the first POST /api/users against an empty tc_users becomes administrator. Use exactly the TRACCAR_ADMIN_* values from gtfs-app-secrets: rt-api reuses them for device auto-provisioning, so a different password silently breaks the integration. Then enable registration (PUT /api/server {"registration": true}) so OIDC logins auto-provision.
  4. Create feeds and their trackers, and make sure each poller's INGEST_TRACKER_ID matches a real tracker id: a mismatch produces no positions and no error.
  5. Trigger geometry-car's first run from https://dagster.<domain> rather than waiting for the schedule; data.<domain>/manifest.json appears once it finishes, and the load modals and list.<domain> are empty until then.

Updating

Edit a manifest, commit, push. ArgoCD picks it up within a few minutes, or immediately with:

kubectl annotate app <name> -n argocd argocd.argoproj.io/refresh=hard --overwrite

Image tags are pinned per workload. CI publishes :latest and :<short-sha> only; there is no :main tag, so bumping a version is a manifest edit and a commit, not a redeploy.