Hourly-ingested, geocoded, queryable dataset of Budapest public-space-use permits, served over a public FastAPI bbox endpoint and visualised on an internationalised Vue + MapLibre map.
The source — einfoszab.budapest.hu
— only exposes a paginated DataTables feed with Hungarian free-text fields and no
geometry. This project ingests it, enriches each permit, and stores it in PostGIS:
- City / client identity from Wikidata (P1448 official name, P939 KSH code, P421 timezone).
- Geometry from the OENY parcel registry (conscription number → parcel outline, reprojected EOV→WGS84), falling back to QLever/OSM address geocoding.
- Timezone-correct dates (00:00 in the city's timezone).
backend/ FastAPI + SQLAlchemy + GeoAlchemy2 ingestion/enrichment API (Python 3.14, uv)
frontend/ Vue 3 + Vite + MapLibre viewer (pnpm), hosted on AWS Amplify
infra/
tofu/ OpenTofu: Hetzner k3s cluster (CX23 / AMD64)
k8s/ Manifests: PostgreSQL StatefulSet, backend, CronJobs, ingress, autoscaler
compose.yaml Local stack: db + backend + frontend
| column | type | notes |
|---|---|---|
id |
bigint PK | |
queried_at |
timestamptz | budapest.hu query time |
city_wikidata_id |
text | Q1781 (Budapest) |
city_ksh_code |
text | budap for Budapest (Wikidata P939 otherwise) |
reference_number |
text (unique) | regNum; dedup key |
client_is_natural_person |
bool | true when requester is Magánszemély |
client |
text | requester, NULL for natural persons |
client_wikidata_id |
text | Wikidata P1448 match |
location_source_text |
text | raw place |
location_conscription_number |
text | parcelNum |
location |
geometry(Geometry, 4326) | MultiPolygon (OENY) or Point (QLever) |
usage_type |
enum | translated purposeOfUse; Egyéb/unknown → uncategorized |
occupied_area_in_square_metres |
int | size |
time_from / time_to |
timestamptz | startOfUse/endOfUse at 00:00 city time |
The whole hourly import runs in one transaction: if any enrichment lookup keeps
failing past its tenacity retry budget (PERMITS_ENRICH_TIMEOUT, default 600 s) the
transaction rolls back, so the DB is never left with partially-enriched data. A no
match (empty lookup) is not a failure — that field is simply left NULL.
GET /permits?bbox=minLon,minLat,maxLon,maxLat[&in_effect_on=YYYY-MM-DD][&usage_type=…][&client=…]— every column of all permits whose geometry intersects the bbox;in_effect_onomitted ⇒ all dates. Public, no auth.GET /permits/coverage— earliestqueried_at/time_from, latesttime_to, count (drives the timeline).POST /permits/fetch— runs one import. Not exposed publicly (the ingress allows onlyGET); invoked by the in-cluster CronJob.
The backend is configured entirely through environment variables (see .env.example).
| variable | description |
|---|---|
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB |
Database credentials. Compose builds the backend's async SQLAlchemy DSN (DB_CONNECTION_STRING) from these. |
PERMITS_CORS_ORIGINS_DEV / PERMITS_CORS_ORIGINS_PROD |
CORS origins for the public read API (comma-separated, or *). Compose passes _DEV to the backend, infra/k8s/20-backend.yaml passes _PROD; both arrive as PERMITS_CORS_ORIGINS. Keep localhost out of _PROD for security reasons. |
PERMITS_ENRICH_TIMEOUT |
Per-lookup total retry budget (seconds) before the enrichment — and the whole import transaction — fails. |
PERMITS_WIKIDATA_SPARQL_API_URL |
Wikidata SPARQL endpoint used for client / KSH code / timezone lookups. |
PERMITS_OSM_SPARQL_API_URL |
OSM SPARQL endpoint used for the address geocoding fallback. |
PERMITS_BACKUP_S3_URI |
Destination for POST /permits/backup dumps, e.g. s3://my-permits-backups (an optional key prefix may follow the bucket). AWS credentials are read from the standard AWS_* environment variables. Empty disables the endpoint. |
cp .env.example .env # set POSTGRES_PASSWORD etc.
docker compose up --build- Backend: http://localhost:8000 (
/docsfor Swagger). Migrations run on start. - Frontend: http://localhost:5173
- Trigger a first import:
curl -X POST http://localhost:8000/permits/fetch
curl "http://localhost:8000/permits?bbox=19.0,47.45,19.12,47.55&in_effect_on=$(date +%F)"Backend tests:
cd backend && uv sync --no-install-project --extra dev && uv run pytestCI (.github/workflows/build-images.yaml) builds permits-backend for both
linux/amd64 and linux/arm64, and pushes it to GHCR tagged with the
release version and latest — triggered by pushing a vMAJOR.MINOR.PATCH tag
(see §6.1). PostGIS uses the official
postgis/postgis:18-3.6 image from Docker Hub — nothing to build.
The OpenTofu inputs live in .env (the single source of truth) as TF_VAR_* entries
— tofu picks them up automatically once they're exported. Fill them in:
TF_VAR_hcloud_token— Hetzner Cloud API token (read/write). Create an account and a project at https://console.hetzner.com, open the project, go to Security → API tokens → Generate API token, descriptionTerraform, permissions Read & Write, generate, and copy the token.TF_VAR_cloudflare_api_token— Cloudflare API token scoped to Zone:DNS:Edit. Buy a domain and point its nameservers at Cloudflare, then in https://dash.cloudflare.com go to Manage account → Account API tokens → Create token, name itpermits_terraform, select Entire Account → Specified Domains → Select domains… and your domain, enable DNS & Zones → DNS → Edit, review, create, and copy the token. (cert-manager's DNS-01 solver reuses this token.)TF_VAR_cloudflare_zone_id— in https://dash.cloudflare.com open Domains → Overview, select your domain, scroll to the bottom, and copy the Zone ID.TF_VAR_ssh_public_key— generate an ed25519 key and paste the contents of the.pubfile (used to access the nodes).TF_VAR_k3s_token— a strong random secret joining k3s agents to the server.TF_VAR_lb_ipv4/TF_VAR_lb_ipv6— leave empty for now; set them after the ingress LoadBalancer exists (see step 5).
cd infra/tofu
set -a; . ../../.env; set +a # export TF_VAR_* (and the rest of .env)
tofu init
tofu applyThis creates the private network, firewall, and a CX23 master running k3s (Traefik
and servicelb disabled; cloud-controller external). Fetch the kubeconfig (see the
kubeconfig_hint output):
scp root@$(tofu output -raw master_ipv4):/etc/rancher/k3s/k3s.yaml ./kubeconfig
sed -i '' "s/127.0.0.1/$(tofu output -raw master_ipv4)/" ./kubeconfig
export KUBECONFIG=$PWD/kubeconfigInstall, in order (Helm):
# Hetzner Cloud Controller Manager (LoadBalancers + node lifecycle)
kubectl -n kube-system create secret generic hcloud --from-literal=token=$TF_VAR_hcloud_token --from-literal=network=permits
helm repo add hcloud https://charts.hetzner.cloud && helm repo update
helm install hccm hcloud/hcloud-cloud-controller-manager -n kube-system --set networking.enabled=true
# Hetzner CSI (PVCs for the PostgreSQL StatefulSet)
helm install hcloud-csi hcloud/hcloud-csi -n kube-system
# ingress-nginx (fronted by a Hetzner LB) + cert-manager.
# `tcp.5432` streams PostgreSQL through the *same* LB as HTTP/HTTPS
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm install ingress-nginx ingress-nginx/ingress-nginx -n ingress-nginx --create-namespace \
--set controller.service.annotations."load-balancer\.hetzner\.cloud/location"=fsn1 \
--set controller.service.annotations."load-balancer\.hetzner\.cloud/use-private-ip"="true" \
--set tcp.5432="permits/permits-db-public:5432"
helm repo add jetstack https://charts.jetstack.io
helm install cert-manager jetstack/cert-manager -n cert-manager --create-namespace --set crds.enabled=trueSet TF_VAR_hostname in .env to the hostname for the backend API. Use a dedicated
subdomain like api.permits.example.com: a single hostname resolves to exactly one
origin, so the API needs its own host, leaving the human-facing permits.example.com
free for the Amplify frontend (see §9).
- Get the ingress LB IPs once the CCM provisions them:
kubectl -n ingress-nginx get svc ingress-nginx-controller -o wide. - Via OpenTofu: set
TF_VAR_lb_ipv4(andTF_VAR_lb_ipv6) in.env, re-export (set -a; . ./.env; set +a), andtofu apply— this creates DNS-only (grey-cloud)A/AAAArecords forTF_VAR_hostname. Or manually in the Cloudflare dashboard: DNS → Add record →A→ your hostname (or@for the root domain) → the LB IPv4, Proxy status: DNS only. (Grey-cloud keeps the LB + DNS-01 simple.) - Create a Cloudflare API token scoped Zone → DNS → Edit for your domain; it is used both by OpenTofu and by cert-manager's DNS-01 solver.
# Secrets come entirely from .env (the single source of truth).
set -a; . ./.env; set +a
kubectl apply -f infra/k8s/00-namespace.yaml
envsubst < infra/k8s/10-postgres.yaml | kubectl apply -f -
envsubst < infra/k8s/20-backend.yaml | kubectl apply -f -
kubectl apply -f infra/k8s/30-cronjobs.yaml
envsubst < infra/k8s/40-ingress.yaml | kubectl apply -f -
# The cluster-autoscaler needs the worker cloud-init base64-encoded. Render it
# from worker-cloud-init.yaml (envsubst fills in $TF_VAR_k3s_token) and encode it —
# no hand-encoding or pasting into .env required.
export HCLOUD_CLOUD_INIT=$(envsubst < infra/k8s/worker-cloud-init.yaml | base64 | tr -d '\n')
envsubst < infra/k8s/50-cluster-autoscaler.yaml | kubectl apply -f -Verify:
kubectl -n permits get pods
curl https://$TF_VAR_hostname/permits?bbox=19.0,47.45,19.12,47.55 # valid Let's Encrypt certThe backend HPA targets 90% CPU; its stabilization windows are tuned so scaling reacts to sustained (~30 min) load (Kubernetes cannot express an exact "for 30 minutes" rule). When new pods don't fit, the cluster-autoscaler adds CX23 worker nodes up to 3 (cluster total ≤ 4 CX23) and removes them when idle.
The PostgreSQL StatefulSet is exposed publicly on port 5432 of the ingress LoadBalancer
— ingress-nginx proxies the raw TCP stream to the permits-db-public
ClusterIP Service (infra/k8s/10-postgres.yaml). Connect with:
-
Username and password: the
POSTGRES_USER/POSTGRES_PASSWORDdefined in.env. -
Host:
TF_VAR_hostname— its DNS record is grey-cloud (§5), so it resolves straight to the LB. The IP works too;export KUBECONFIG=infra/tofu/kubeconfigfirst, then:kubectl -n ingress-nginx get svc ingress-nginx-controller \ -o jsonpath='{range .status.loadBalancer.ingress[*]}{.ip}{"\n"}{end}'psql "postgresql://$POSTGRES_USER@$TF_VAR_hostname:5432/$POSTGRES_DB"
Note: PostgreSQL sees the ingress-nginx pod's IP as the client address
so it makes the server log's connection from lines useless,
and the database is unreachable from outside while ingress-nginx is down.
export KUBECONFIG=infra/tofu/kubeconfig first.
Push a release tag:
git tag v1.2.3 && git push origin v1.2.3CI builds permits-backend for amd64+arm64 and pushes both the version tag
and latest to GHCR (§2). The Deployment tracks ghcr.io/gy-mate/permits-backend:latest,
so once CI finishes, roll the pods to pull the new image:
kubectl -n permits rollout restart deployment/permits-backend
kubectl -n permits rollout status deployment/permits-backendRe-export .env and re-apply the affected manifest. A Secret change does not
restart pods on its own, so follow a 20-backend.yaml re-apply with a rollout restart:
set -a; . ./.env; set +a
envsubst < infra/k8s/20-backend.yaml | kubectl apply -f - # Secret + Deployment
kubectl -n permits rollout restart deployment/permits-backend # pick up Secret changesThe CronJobs, ingress, and autoscaler update the same way — re-apply the file you
changed (wrap it in envsubst if it contains ${…} placeholders, per §6). Roll back a
bad e.g. backend release with kubectl -n permits rollout undo deployment/permits-backend.
Nautik is a native Kubernetes client for Mac/iOS.
- Add cluster: Nautik → Clusters → Add → Import kubeconfig and select
infra/tofu/kubeconfig(it already points at the master's public IP). - Watch workloads: open the
permitsnamespace to see thepermits-backendDeployment,permits-dbStatefulSet, and the HPA's current replica count. - Logs / exec: tap a pod → Logs (e.g. tail the backend during a fetch) or
Terminal to
psqlinto the DB pod. - Trigger a manual fetch: Nautik →
permits-fetchCronJob → Run now, orkubectl -n permits create job --from=cronjob/permits-fetch fetch-manual. - Scaling: watch nodes appear/disappear under Nodes as the cluster-autoscaler
reacts; inspect the
cluster-autoscalerDeployment logs inkube-system. - Backups: inspect the
permits-backupCronJob and its last job's logs.
A permits-backup CronJob runs daily at 02:53 UTC. It just calls
POST /permits/backup on the in-cluster backend, which runs pg_dumpall, wraps the
dump in a .tar.zst and uploads it to S3 (PERMITS_BACKUP_S3_URI + AWS_* from the
permits-backend Secret; infra/k8s/30-cronjobs.yaml). Restore:
aws s3 cp s3://your-permits-backups/permits-YYYYMMDDThhmmssZ.tar.zst .
zstd -d permits-*.tar.zst -c | tar -xO permits.sql | \
kubectl -n permits exec -i permits-db-0 -- psql -U permitsVue 3 + Vite + MapLibre, package-managed with pnpm. It reuses the self-hosted
VersaTiles style + OSM assets (frontend/public/OSM/, copied from the homepage project)
and the loadStyle/colour-scheme logic.
Features:
- i18n (vue-i18n): Hungarian by default, English available; usage-type labels and the legend localised.
- Opens on the Budapest City Hall; queries permits in effect today in a buffered bbox (larger than the viewport) so panning/zooming rarely refetches.
- Geometries coloured by
usage_type; large polygons get the client name label (or the permit type for natural persons); too-small extents become circles, and dense circles collapse into a counter marker (cluster). - Overlapping permits: the areas are cut into disjoint cells, so an overlap is painted once rather than as stacked translucent fills. A shared patch is filled with diagonal stripes alternating the usage colours covering it, divided by thin black lines so neighbouring stripes stay apart even when their colours match. The stripes take whichever diagonal cuts across the patch — 315° when its edges mostly run at a 0°–90° bearing, 45° otherwise — so they never run parallel to the outline and read as lines. The cut depends on the permits alone, so it is computed once per fetch and reused while panning/zooming.
- Legend + type/client filters (persisted automatically via pinia + localStorage).
- Click a permit → all fields + the client's logo (Wikidata P154) when available; clicking a striped overlap lists every permit covering it in one panel.
- Timeline (date resolution, logarithmic scale) drags back to the earliest
in-effect date; opening it queries all dates, and dates before the earliest
queried_atare flagged (and coloured) as partial.
Runs as the frontend service in compose.yaml, or:
cd frontend && pnpm install && pnpm devThe frontend lives at permits.example.com (Amplify) and calls the backend directly at
its own host api.permits.example.com (TF_VAR_hostname). Cross-origin, so the backend
must allow the frontend origin via PERMITS_CORS_ORIGINS_PROD (step 6 in §5 / .env).
- Amplify Console → New app → Host web app → connect this GitHub repo.
- Monorepo: set the app root to
frontend(Amplify readsfrontend/amplify.yml, which runspnpm install && pnpm run buildand publishesdist/). - Set the env var
VITE_API_BASE_URL=https://api.permits.example.com(yourTF_VAR_hostname). Ensure that origin is in the backend'sPERMITS_CORS_ORIGINS_PROD(e.g.PERMITS_CORS_ORIGINS_PROD=https://permits.example.com). - Custom domain (Cloudflare, i.e. a third-party DNS provider — see the
AWS docs):
Amplify Console → Hosting → Custom domains → Add domain → enter the root domain
example.com. Since DNS lives at Cloudflare, choose Manual configuration (not "Create hosted zone on Route 53"), then Configure domain.- Amplify pre-fills two subdomain entries (
example.com+www.example.com). On this screen, edit that mapping to point thepermitssubdomain → themainbranch and remove the default entries, since we only want the subdomain. - Leave Amplify managed certificate selected (a free ACM cert, valid 13 months and auto-renewed), then Add domain.
- Actions → View DNS records. Amplify shows two CNAME records; add both in
Cloudflare as DNS-only (grey cloud) — CloudFront terminates TLS, and proxying
would break ACM's CNAME validation:
- the verification CNAME (a
_xxxx….permitsname → a…acm-validations.awstarget) — enter only the_xxxx…permitslabel as the record name; - the subdomain CNAME (
permits→ a…cloudfront.nettarget).
- the verification CNAME (a
- Add these records promptly — ACM starts checking immediately and backs off over time, so a late record can leave the domain stuck in pending verification. No ANAME/ALIAS is needed here because we only map a subdomain, not the root.
- Wait for Amplify to validate and issue the cert. Verification and DNS propagation for third-party domains can take up to 48 hours.
- Amplify pre-fills two subdomain entries (
- Rewrites & redirects: remove Amplify's default SPA rewrite rule
(
</^[^.]+$|\.(?!…)…/>→/index.html,200) under Hosting → Rewrites and redirects. - Pushes to
mainauto-deploy via Amplify's GitHub integration.