CitCom.ai — Deployment
This is the operational guide for the CitCom data space at
https://citcom.dataspaceready.eu. It assumes you've read
architecture.md for the what; this doc focuses
on the how.
1. Topology
flowchart LR
CF[Cloudflare DNS<br/>*.dataspaceready.eu]
subgraph host[citcom-ds host — 145.239.127.76]
direction TB
Traefik["Traefik :443<br/>LE DNS-01"]
NM[nginx-main]
NA[nginx-admin]
Nt1[nginx-libelium]
Nt2[nginx-valencia]
Nt3[nginx-upv]
NMP[nginx-mailpit]
OA[onboarding-api]
KC[Keycloak<br/>+ Postgres]
DSRA[admin DS-Manager<br/>+ EDC + Postgres]
T1[libelium DS-Manager<br/>+ EDC + Postgres]
T2[valencia DS-Manager<br/>+ EDC + Postgres]
T3[upv DS-Manager<br/>+ EDC + Postgres]
MIN[MinIO + storage-portal]
Sync[sync-fuseki / sync-dome]
Loki[Loki + Alloy]
Mailpit[Mailpit web UI<br/>SMTP relays to buzondecorreo]
end
CF -->|443| Traefik
Traefik --> NM & NA & Nt1 & Nt2 & Nt3 & NMP
- Server:
145.239.127.76(citcom-ds), Ubuntu, userubuntu. - Deploy directory:
~/citcom/(rsync target — not a git worktree). - Single Docker network:
edc-net(external; created on first deploy). - Persistent state: Docker volumes for each Postgres and MinIO instance + the Traefik ACME store.
- DNS: Wildcard
*.citcom.dataspaceready.euin Cloudflare → host IP. Thetraefik-cloudflare-companionsidecar registers any new Traefik host label automatically, so a new tenant subdomain resolves without manual DNS work. - TLS: Per-host Let's Encrypt certs via the Cloudflare DNS-01 challenge (no port-80 challenge → wildcard-friendly + does not break HTTP→HTTPS redirect).
2. The deploy command
A full deploy from a developer machine is one line:
What it does:
- rsync the repo (excluding
.git/,active/, secrets) toubuntu@citcom-ds:~/citcom/. - ssh into the host and run
make deploy-prod PROJECT=citcom, which is a thin wrapper formake deploy ENV=production PROJECT=citcom SSL=skip. - smoke test with
curl https://citcom.dataspaceready.eu/.
The make deploy target itself does, in order:
make activate— renderactive/fromplatform/+projects/citcom/env.production+tenants.yml. This regeneratesactive/keycloak/realm-export.json, every tenant compose underactive/tenants/, the Keycloak theme, the navbar config, etc.- Fix file permissions (
chmod -R o+rX active/) so containers can read bind-mounted assets. - Pull all images referenced by every compose file. Hopu
re-publishes
1.3.x-generic-dsregularly with the same tag, so a blinddocker compose up -dwould silently keep the old digest; we explicitly pull first. - Bring up the platform: Traefik (with
docker-compose.cloudflare.ymlanddocker-compose.traefik-le.ymloverlays whenCLOUDFLARE_ENABLED=true), thendocker-compose.yml, then MinIO (docker-compose-minio.yml), then the admin connector (docker-compose-tenant-admin.yml), then every per-tenant compose underactive/tenants/. - Force-recreate every nginx gateway. Their config is bind-mounted
from
active/nginx/*.conf.templateso a vanillaup -ddoesn't pick up template changes. - Force-recreate Keycloak. Same bind-mount problem (the
themes directory and
realm-export.jsonare mounted;make activateremoves and recreates those files, so the inode under the old container is stale). - Bring up the audit pipeline (Loki + Alloy).
- Run
keycloak_sync.py(self-heal). Reconciles realm-level fields, mappers and per-client redirect URIs againstactive/keycloak/realm-export.json. Self-heals admin password drift via fallback-and-rotate. Idempotent — second run is a no-op. - Run
register-admin-connector.pyto ensure theadminconnector exists in the admin DS-Manager'sconnector_registry.ds_connector. - Print URLs and exit.
3. Provisioning a new tenant
Tenants are declared in tenants.yml. Each entry
covers identity, EDC, contact, users and (optional but recommended)
org_details for the DS-Manager Organisation registry:
- name: libelium # used as connector / db / k8s-style identifier
subdomain: libelium # https://libelium.citcom.dataspaceready.eu
org: Libelium # display name
sector: Technology
contact:
name: Mateo Ferri
email: m.ferri@libelium.com
role: Data Engineer
edc:
api_key: api-key-libelium # X-Api-Key for the connector's management API
public_port: 9005 # internal port for the connector's data plane
org_details:
vat: ESB99065815
street: Calle Bari 19, Edificio CEEI
city: Zaragoza
postal: "50197"
subdivision: ES-Z # ISO-3166-2 — the DS-Manager backend rejects anything else
country: ES
users:
- email: m.ferri@libelium.com
password: ...
first_name: Mateo
last_name: Ferri
roles: [dsr:asset-owner, dsr:operator]
Then:
# After editing tenants.yml on a deployed host:
cd ~/citcom
ln -sf active/env env # provision-tenant.py reads cwd/env
ln -sf active/tenants.yml tenants.yml # ... and cwd/tenants.yml
python3 platform/scripts/provision-tenant.py <tenant-name> --skip-verify
# or do all at once:
python3 platform/scripts/provision-tenant.py --all --skip-verify
provision-tenant.py performs 9 idempotent steps per tenant:
- Generate
active/tenants/docker-compose-tenant-<name>.yml. - Add the tenant subdomain to the
dsr-appredirect URIs. - Create or self-heal the per-tenant OIDC client (
dsr-app-<name>) — re-applies all 5 protocol mappers and copies the 8 default + 1 optional client scopes fromdsr-appeven when the client already exists, fixing accumulated drift. - Start the tenant containers (or
--skip-containersif they're already up after amake deploy). - Create the Keycloak group + each declared user, attach roles.
- Register the EDC connector in the tenant DS-Manager's
connector_registry.ds_connector(HTTP API first, falls back to direct Postgres insert). - Identity setup (DID + Organisation + LEAR designation). This step
runs inside the
onboarding-apicontainer so all the HTTP calls resolve through the internal Docker DNS — running on the host silently fails because the public domain doesn't loop back. - Add the new connector's catalogue endpoints to the sync-service compose so the federated catalogue picks it up.
- Endpoint smoke check (skipped with
--skip-verify).
4. Self-healing
Two self-healers run on every deploy:
platform/scripts/keycloak_sync.py— reconciles the running Keycloak realm with the renderedactive/keycloak/realm-export.json(themes, SMTP, locale, mappers, redirect URIs). Auto-rotates the admin password if the value inenv.productionno longer authenticates and a known fallback (e.g.admin) does.platform/onboarding-api/register-admin-connector.py— ensures the admin EDC connector is registered in the admin DS-Manager'sconnector_registry.ds_connector. SQL-direct (the previous/test-auth/create-sessionendpoint was removed in dsr-backend 1.3.4).
Both are idempotent and non-fatal: a deploy succeeds even if either fails.
5. Service inventory
| Container | Image | Purpose |
|---|---|---|
traefik |
traefik:latest |
TLS termination, host-based routing, ACME (LE) via Cloudflare DNS-01 |
cloudflare-companion |
tiredofit/traefik-cloudflare-companion:latest |
Auto-register DNS records for every Traefik host label |
nginx-main |
nginx:1.25-alpine |
Onboarding portal, /catalog/, /registry/, DS-Manager fallback |
nginx-admin |
nginx:1.25-alpine |
admin.<domain> — admin DS-Manager proxy |
nginx-mailpit |
nginx:1.25-alpine |
mailpit.<domain> (debug catcher; SMTP egress is real) |
nginx-{tenant} |
nginx:1.25-alpine |
One per tenant subdomain |
tenant-validator |
custom (FastAPI) | nginx auth_request subhandler — validates session against DS-Manager backend, gates /storage/ strictly |
onboarding-api |
custom (Flask) | Participant registry, /auth/* portal, activation emails (SMTP), provision API |
dsr-keycloak-dev + dsr-keycloak-db-dev |
quay.io/keycloak/keycloak:26.4 + postgres:15-alpine |
OIDC issuer |
dsr-frontend-{tenant} |
registry.hopu.eu/dsr-frontend:1.3.3-generic-ds |
Per-tenant DS-Manager UI |
dsr-backend-{tenant} |
registry.hopu.eu/dsr-backend:1.3.4-generic-ds |
Per-tenant DS-Manager API (assets, policies, identity) |
cp-{tenant} |
ghcr.io/sovity/edc-ce:16.4.2 |
Eclipse EDC connector per tenant |
dsr-postgres-{tenant} + db-{tenant} |
postgres:15-alpine |
DS-Manager + EDC databases per tenant |
dsr-redis-{tenant} |
redis:7-alpine |
DS-Manager session store per tenant |
dsr-did-init-{tenant} |
one-shot | Generates DID material on first start |
cp-admin + db-admin |
EDC + postgres | Shared admin connector |
dsr-backend-dev + dsr-postgres-dev + dsr-redis-dev |
DS-Manager | Admin DS-Manager (compliance / sync centre) |
dsr-frontend-dev |
registry.hopu.eu/dsr-frontend:1.3.3-generic-ds |
Admin DS-Manager UI |
minio + minio-init |
MinIO | Object storage with per-bucket per-tenant separation |
storage-portal |
custom (Flask + minio-py) | Authenticated upload/list UI per tenant; X-Participant header gates the bucket |
http-source, http-sink |
nginx + node | Test-data servers used in transfer demos |
fuseki |
Apache Jena | DCAT catalogue index |
scorpio + dome-postgres + tmforum-{product,resource,service}-catalog |
DOME | TMForum API stack |
bae-frontend |
DOME marketplace UI | Browseable product catalogue |
sync-service (a.k.a. sync-fuseki) |
node:20-alpine |
Polls every connector's DCAT catalogue → Fuseki SPARQL |
sync-dome |
node:20-alpine |
Polls every connector → TMForum |
mailpit |
axllent/mailpit:latest |
Local SMTP catcher (debug) |
dsr-loki-dev + dsr-alloy-dev |
Loki + Grafana Alloy | Audit log aggregation |
6. Self-issued certificates
The Traefik service is configured to issue per-host certs through the Cloudflare DNS-01 challenge. The flag chain:
--certificatesresolvers.le.acme.email=...
--certificatesresolvers.le.acme.storage=/acme/acme.json
--certificatesresolvers.le.acme.dnschallenge=true
--certificatesresolvers.le.acme.dnschallenge.provider=cloudflare
--certificatesresolvers.le.acme.dnschallenge.resolvers=1.1.1.1:53,8.8.8.8:53
Each routed service ships a label traefik.http.routers.<name>.tls.certresolver=${TRAEFIK_CERTRESOLVER:-},
which evaluates to le in production (where the env sets it) and to
empty in local dev (where Traefik falls back to the file provider's
self-signed wildcard). Adding a new tenant therefore needs no manual
cert work — Traefik issues one on the first request to its host.
7. SMTP / email
onboarding-api sends activation emails directly through SMTP (it does
not delegate to Keycloak's mailing). Production points at
smtp.buzondecorreo.com:465 SSL, authenticated as
info@geospace.es — recipients see the message as
From: CitCom.ai <info@geospace.es>. The HTML template lives at
platform/onboarding-api/templates/activation-email.html
and is parametrised entirely from env (EMAIL_PRIMARY_COLOR,
EMAIL_ACCENT_COLOR, EMAIL_LOGO_FILE, …) so each project's emails
match its login + portal palette.
Caveat — sender domain (
geospace.es) does not match the data- space domain (citcom.dataspaceready.eu), so a strict DMARC policy at the recipient's MX may quarantine. Long-term we'll set upnoreply@citcom.dataspaceready.euwith proper SPF/DKIM in Cloudflare; short-term Mailpit onmailpit.citcom.dataspaceready.euremains as a debug surface.
8. Common operational tasks
Add a tenant
- Append entry to
projects/citcom/tenants.yml. make deploy-prod-remote PROJECT=citcom(or, if you only want the tenant change without a full redeploy, ssh in andpython3 platform/scripts/provision-tenant.py <new-tenant>).- Activation emails are NOT sent automatically by provisioning — they
are sent by an explicit call to the onboarding-api's
send_activationhelper. We dispatch them in batch after provisioning completes (see the in-session ad-hoc script we used the day of go-live).
Rotate the Keycloak admin password
- Edit
KEYCLOAK_ADMIN_PASSWORDinprojects/citcom/env.production. make deploy-prod-remote PROJECT=citcom.keycloak_sync.pydetects the env doesn't authenticate, falls back to the prior known password, resets the master admin user to the new env value.- Save the new password to a secrets store (memory, vault, …) and
update
memory/citcom_keycloak_admin.md.
Refresh DS-Manager images
Because Hopu re-tags 1.3.x-generic-ds, a redeploy is enough — the
deploy target now does an explicit docker compose pull before
up -d. Force-recreate of the relevant containers happens
automatically.
Diagnose a failing transfer
- Provider EDC log:
docker logs cp-<provider-tenant> --tail 200— look forContractNegotiation: ID … Fatal errorlines. The most common one is the JSON-LD policy mismatch onPOST /contractnegotiations. - Consumer side:
GET /api/management/v3/contractnegotiations/<id>shows the state machine;state=TERMINATEDpluserrorDetailtells you what the consumer rejected. - EDR phase:
GET /api/management/v3/edrs/{transferProcessId}/dataaddressreturns the bearer token + endpoint. If that endpoint 401s, the token expired or the data-plane URL is wrong.
9. Known runtime drift / things to watch
- The realm-import lifecycle in Keycloak applies once. Without
keycloak_sync.py, every theme / SMTP / mapper change after first import is silently ignored. - Tag-pinned images can drift under the same tag. The deploy target
now
docker compose pulls explicitly to defeat this. - nginx + Keycloak both bind-mount files that
make activateregenerates →--force-recreateis on by default for those services. org_details.subdivisionMUST match^[A-Z]{2}-[A-Z0-9]{1,3}$or the DS-Manager Organisation API rejects with a cryptic 422.
10. Evidence the data space is operational (last verified 2026-04-29)
- Federated DCAT catalogue from libelium → valencia DSP returns the Albufera dataset.
- Contract negotiation
019dd641-…reachesFINALIZED. - Transfer process
019dd641-…reachesSTARTED; EDR endpointhttp://cp-valencia:9006/api/publicissued with a 1-time bearer. - Consumer
GETof that endpoint returns the 2 022 698-byte CSV (dateObserved,numValue,…) — i.e. the actual asset bytes flow end-to-end through Eclipse EDC's DSP + data plane.