RouteMaster NL - Logistics Platform
A cloud-native, event-driven logistics platform for tracking shipping containers.
Architecture

Local Development Setup:
Add the following to /etc/hosts:
Requests flow: /etc/hosts → Minikube tunnel → Envoy Gateway → Services
Project Structure
logistics/
├── Makefile # Main build orchestration
├── telemetry/ # GPS ingestion service (Go, Kafka producer)
├── consumer/ # Kafka consumer service (Go, TimescaleDB writer)
├── frontend/ # React + TypeScript SPA (Vite, Keycloak auth)
├── infra/ # Kubernetes infrastructure
│ ├── kafka.yaml # Strimzi Kafka cluster
│ ├── redis.yaml # Redis cache
│ ├── keycloakx/ # Keycloak IAM (Helm values)
│ └── gateway/ # Envoy Gateway ingress
├── certs/ # TLS certificate infrastructure
├── docs/ # Documentation and diagrams
├── makefiles/ # Modular Makefile includes
├── .github/ # GitHub Actions workflows
├── ruleengine/ # Geofencing rule engine (Go, PostGIS, Kafka producer)
└── notification/ # Email notification service (Go, nikoksr/notify, Gmail SMTP)
Telemetry Service
GPS data ingestion service that receives container location data and writes to Kafka.
See Telemetry Service for:
- API specification and data format
- Configuration options
- Build, run, and test instructions
- Performance characteristics
Local Documentation
Serve the project documentation locally using MkDocs.
Prerequisites:
Commands:
| Command | Description |
|---|---|
mkdocs serve |
Start local server at http://localhost:8000 |
mkdocs build |
Build static site to /site directory |
TLS/PKI Setup
This project uses a self-signed PKI for local development with TLS.
Quick Setup:
make init-pki # Initialize Root CA and Intermediate CA
make deploy-tls DOMAIN=app.example.com NAMESPACE=app SECRET_NAME=wildcard-cert
Key Commands:
| Command | Description |
|---|---|
make init-pki |
Initialize full PKI (Root + Intermediate CA) |
make gen-tls-fast DOMAIN=xxx |
Generate cert without Root CA password |
make verify-cert |
Verify certificate validity |
make trust-ca-macos |
Trust CA on macOS |
See TLS/PKI Guide for:
- PKI trust chain architecture
- Certificate types and validity periods
- OpenSSL command reference
- Troubleshooting FAQ
Getting Started
Prerequisites
| Tool | Installation |
|---|---|
| Minikube | install |
| Docker | install |
| kubectl | install |
| Helm | install |
| npm | install |
| Go | install (for local dev) |
Gateway & JWT Troubleshooting
Understanding JWT Validation
Three URLs must align for JWT validation to work:
- Token Issuer - Written to JWT
issclaim by Keycloak - Controlled by
--hostnameflag (e.g.,--hostname https://auth.example.com/auth) - This is the external URL that clients use to access Keycloak
-
Mismatch error:
"Jwt issuer is not configured" -
SecurityPolicy Issuer - Gateway validates token against this
-
Must exactly match Token Issuer (the external URL)
-
JWKS URI - Gateway fetches public keys to verify signature
- Uses internal K8s Service URL (Gateway runs inside cluster)
- Must use Service port (default 80), not Pod port (8080)
- Wrong port error:
"Jwks remote fetch is failed"
Key Concept: Issuer = external URL (what clients see), JWKS URI = internal URL (what Gateway uses).
Example Config:
# Keycloak Helm values (keycloak-server-values.yaml)
command:
- "--hostname"
- "https://auth.example.com/auth" # External URL, sets token issuer
# SecurityPolicy (security-policy.yaml)
issuer: https://auth.example.com/auth/realms/myrealm # Must match token's iss claim
remoteJWKS:
uri: http://keycloak-keycloakx-http.app.svc.cluster.local/auth/realms/myrealm/protocol/openid-connect/certs # Internal K8s URL
Note: The /auth prefix in the path comes from the --hostname value, not from Keycloak version.
JWKS Fetch Failed
Symptom:
[warning][jwt] Jwks async fetching url=http://keycloak-keycloakx-http.app.svc.cluster.local/auth/realms/myrealm/protocol/openid-connect/certs: failed
Possible Causes:
| Cause | Solution |
|---|---|
| Wrong Keycloak path | Path depends on --hostname config. If set to https://host/auth, use /auth/realms/... |
| Config not applied | Run kubectl apply -f infra/gateway/security-policy.yaml -n app |
| Gateway needs restart | kubectl delete pod -n app -l gateway.envoyproxy.io/owning-gateway-name=gateway |
Diagnostic:
# Test JWKS endpoint from inside cluster
kubectl run curl-test --rm -it --image=curlimages/curl --restart=Never -n app -- \
curl -v http://keycloak-keycloakx-http.app.svc.cluster.local/auth/realms/myrealm/protocol/openid-connect/certs
JWT Issuer Not Configured (401 Unauthorized)
Symptom:
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="http://localhost/", error="invalid_token"
Jwt issuer is not configured
Root Cause: Token's iss claim doesn't match SecurityPolicy's issuer field.
Diagnostic - Decode token to find actual issuer:
TOKEN=$(curl -s -X POST "http://localhost:8080/auth/realms/myrealm/protocol/openid-connect/token" \
-d "grant_type=password&client_id=myclient&username=myuser&password=admin" | jq -r '.access_token')
echo $TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq '.iss'
Fix: Update infra/gateway/security-policy.yaml to match the token's issuer exactly:
jwt:
providers:
- name: keycloak
issuer: https://auth.example.com/auth/realms/myrealm # External URL, must match token's iss
remoteJWKS:
uri: http://keycloak-keycloakx-http.app.svc.cluster.local/auth/realms/myrealm/protocol/openid-connect/certs # Internal URL
500 Internal Server Error (Backend Not Found)
Symptom:
Diagnostic:
# Check if backend pods exist
kubectl get pods -n app -l app=logistics
# Check service endpoints
kubectl get endpoints logistics-svc -n app
Common Cause: Service selector doesn't match Pod labels.
# WRONG - selector and pod labels don't match
apiVersion: v1
kind: Service
spec:
selector:
app: logistics-deployment # Looking for this label
---
apiVersion: apps/v1
kind: Deployment
spec:
template:
metadata:
labels:
app: logistics-pod # But pods have this label
Fix: Ensure Service selector matches Pod labels exactly.
Deployment Selector Immutable Error
Symptom:
Fix: Delete and recreate the deployment:
K8S structure
flowchart TB
subgraph GatewayLayer["Gateway Layer"]
GC[GatewayClass: gateway-class]
GW[Gateway: gateway :80/:443]
GC --> GW
end
subgraph Routes["HTTPRoutes"]
R0[HTTPRoute: http-filter-redirect - 301]
R1[HTTPRoute: frontend-route - logistics.example.com /]
R2[HTTPRoute: api-route - logistics.example.com /api]
R3[HTTPRoute: keycloak-route - auth.example.com /auth]
end
subgraph Services["Services"]
S1[frontend-svc:80]
S2[logistics-svc:8080]
S3[keycloak-keycloakx-http:80]
end
subgraph Deployments["Deployments"]
D1[frontend]
D2[logistics-deployment]
D3[keycloakx - Helm]
end
subgraph Policy["SecurityPolicy"]
SP[SecurityPolicy: jwt - JWT Validation]
end
subgraph DataPipeline["Data Pipeline"]
K[Kafka: container.telemetry]
CS[consumer-svc:8080]
CD[consumer-deployment]
TS[(TimescaleDB - telemetry)]
RE[ruleengine-svc:8082]
RD[ruleengine-deployment]
RDB[(PostGIS - geofences)]
KE[Kafka: geofence.events]
NS[notification-svc:8083]
ND[notification-deployment]
NDB[(PostgreSQL - notification)]
EMAIL[Gmail SMTP]
end
%% Gateway connections
GW -- "HTTP" --> R0
GW -- "HTTPS" --> R1
GW -- "HTTPS" --> R2
GW -- "HTTPS" --> R3
%% Route to service connections
R1 --> S1
R2 --> S2
R3 --> S3
%% Security policy
SP -.-> R2
%% Service to deployment
S1 --> D1
S2 --> D2
S3 --> D3
%% Data pipeline
D2 -- "produce" --> K
K -- "consume" --> CS
K -- "consume" --> RE
CS --> CD
CD --> TS
RE --> RD
RD --> RDB
RD -- "produce" --> KE
KE -- "consume" --> NS
NS --> ND
ND --> NDB
ND -- "send" --> EMAIL
Quick Start
Supported: macOS, Linux
1. Start Cluster
minikube start --cpus=4 --memory=8192 --driver=docker
kubectl create namespace app
kubectl config set-context --current --namespace=app
2. Install Kafka
# Install Strimzi operator
kubectl apply -f 'https://strimzi.io/install/latest?namespace=app' --server-side --force-conflicts
# Deploy Kafka cluster
kubectl apply -f infra/kafka.yaml -n app
3. Install Gateway
Optional: Remove existing Gateway CRDs (if reinstalling)
kubectl delete crd \
backends.gateway.envoyproxy.io \
backendtlspolicies.gateway.networking.k8s.io \
backendtrafficpolicies.gateway.envoyproxy.io \
clienttrafficpolicies.gateway.envoyproxy.io \
envoyextensionpolicies.gateway.envoyproxy.io \
envoypatchpolicies.gateway.envoyproxy.io \
envoyproxies.gateway.envoyproxy.io \
gatewayclasses.gateway.networking.k8s.io \
gateways.gateway.networking.k8s.io \
grpcroutes.gateway.networking.k8s.io \
httproutefilters.gateway.envoyproxy.io \
httproutes.gateway.networking.k8s.io \
referencegrants.gateway.networking.k8s.io \
securitypolicies.gateway.envoyproxy.io \
tcproutes.gateway.networking.k8s.io \
tlsroutes.gateway.networking.k8s.io \
udproutes.gateway.networking.k8s.io \
xbackendtrafficpolicies.gateway.networking.x-k8s.io \
xlistenersets.gateway.networking.x-k8s.io \
--ignore-not-found=true
# Install Envoy Gateway
helm install eg oci://docker.io/envoyproxy/gateway-helm --version v1.5.7 -n app
# Apply gateway config
kubectl apply -k infra/gateway/ -n app
4. Install Keycloak & PostgreSQL
# Add Helm repos
helm repo add codecentric https://codecentric.github.io/helm-charts
helm repo add cnpg https://cloudnative-pg.github.io/charts
helm repo update
# Install Keycloak
helm install keycloak codecentric/keycloakx \
--values ./infra/keycloakx/keycloak-server-values.yaml -n app
# Install CloudNative PG operator
helm upgrade --install cnpg cnpg/cloudnative-pg --namespace app
# Deploy PostgreSQL cluster
kubectl apply -f infra/keycloakx/postgres-values.yaml -n app
5. Configure TLS
# Generate certificates
make init-pki
# Deploy to cluster
make deploy-tls DOMAIN=app.example.com NAMESPACE=app SECRET_NAME=wildcard-cert
Open a new terminal and run:
6. Configure Keycloak
- Go to https://auth.example.com/auth
- Follow the official tutorial to create realm, client, user
- Enable Direct access grants:

- Set redirect URIs:
- Valid redirect URIs:
https://logistics.example.com/* - Web origins:
https://logistics.example.com
7. Deploy Databases
# TimescaleDB (consumer service)
kubectl apply -f consumer/timescaledb/deployment.yaml -n app
# PostGIS (ruleengine service)
kubectl apply -f ruleengine/postgis/deployment.yaml -n app
# PostgreSQL (notification service)
kubectl apply -f notification/postgres/deployment.yaml -n app
# Wait for all CNPG clusters to be ready
kubectl wait --for=condition=Ready cluster/telemetry-timescaledb -n app --timeout=120s
kubectl wait --for=condition=Ready cluster/ruleengine-db -n app --timeout=120s
kubectl wait --for=condition=Ready cluster/notification-db -n app --timeout=120s
8. Run Database Migrations
Each service uses golang-migrate via a Kubernetes Job. The migration SQL files are stored in a ConfigMap, and the Job runs migrate up against the database.
How it works:
- A ConfigMap holds the migration SQL files (generated from
db/migrations/) - A Job mounts the ConfigMap and runs the
migrate/migrateimage - The Job reads
DATABASE_URLfrom the CNPG-generated secret (<cluster-name>-app) - The Job name includes a version tag (
${VERSION}) so each run creates a new Job
Run migrations for all services:
export VERSION=$(git rev-parse --short HEAD)
# Consumer service
kubectl apply -f consumer/migrate-jobs/ -n app
envsubst < consumer/migrate-jobs/migrate-job.yaml | kubectl apply -f -
# Ruleengine service
kubectl apply -f ruleengine/migrate-jobs/ -n app
envsubst < ruleengine/migrate-jobs/migrate-job.yaml | kubectl apply -f -
# Notification service
kubectl apply -f notification/migrate-jobs/ -n app
envsubst < notification/migrate-jobs/migrate-job.yaml | kubectl apply -f -
# Wait for all migrations to complete
kubectl wait --for=condition=complete job/consumer-migrate-${VERSION} -n app --timeout=120s
kubectl wait --for=condition=complete job/ruleengine-migrate-${VERSION} -n app --timeout=120s
kubectl wait --for=condition=complete job/notification-migrate-${VERSION} -n app --timeout=120s
To regenerate a ConfigMap after changing migration files:
9. Deploy Services
# Create notification SMTP secret
make notification-smtp-secret SMTP_USER=you@gmail.com SMTP_PASSWORD=your-app-password
# Deploy all services
make redeploy-logistics # Telemetry service
make redeploy-consumer # Consumer service
make redeploy-ruleengine # Rule engine service
make redeploy-notification # Notification service
make frontend-redeploy # Frontend
10. Verify Installation
# Check all pods are running
kubectl get pods -n app
# Wait for Kafka
kubectl wait --for=condition=Ready pod -l app=kafka -n app --timeout=120s
# Wait for Keycloak
kubectl wait --for=condition=Ready pod -l app.kubernetes.io/name=keycloakx -n app --timeout=300s
# Wait for CNPG operator
kubectl wait --for=condition=available deployment/cnpg-cloudnative-pg -n app --timeout=300s
# Test JWT auth
./test-jwt.sh
Open https://logistics.example.com to access the application.
Ref
- https://github.com/cloudnative-pg/charts/tree/main/charts/cloudnative-pg
- https://github.com/codecentric/helm-charts/tree/master/charts/keycloakx
- https://www.keycloak.org/getting-started/getting-started-kube
Frontend
React + TypeScript frontend application using Keycloak for authentication.
Install Dependencies
Keycloak Configuration
The frontend requires a .env file to connect to Keycloak for authorization.
1. Create the environment configuration file:
2. Edit frontend/.env:
VITE_KEYCLOAK_URL=https://auth.example.com/auth
VITE_KEYCLOAK_REALM=myrealm
VITE_KEYCLOAK_CLIENT_ID=myclient
| Variable | Description |
|---|---|
VITE_KEYCLOAK_URL |
Keycloak server address (including /auth path) |
VITE_KEYCLOAK_REALM |
Keycloak Realm name |
VITE_KEYCLOAK_CLIENT_ID |
Keycloak Client ID |
3. Keycloak Client Configuration (Required):
Configure the Client in Keycloak Admin Console:
- Login to
https://auth.example.com/auth/admin - Select Realm → Clients → Click your Client
- Set the following fields:
| Field | Value |
|---|---|
| Valid redirect URIs | http://localhost:5173/* |
| Web origins | http://localhost:5173 |
Without this configuration, you will get an
Invalid parameter: redirect_urierror
Start Development Server
Visit http://localhost:5173/, which will automatically redirect to the Keycloak login page.