Deploy a federated contentspace connector (Eclipse Dataspace Components 0.14.1, pre-built images from ghcr.io/xr2industry) with Docker Compose, publish your content, and get it listed in XR2INDUSTRY. Two minimal setups: provider only, or a full provider + consumer + catalog node.
Your connector (provider) publishes, the XR2INDUSTRY federated catalog crawls it, and the XR2INDUSTRY consumer negotiates and pulls. The journey of one content item:
Publish
You create an asset (metadata + where the file lives) on your provider's Management API, covered by a policy and a contract definition.
Crawl
The XR2INDUSTRY federated catalog crawls your /protocol endpoint about every 60 s and caches your offers. The dashboard reads that cache, not your connector directly.
Negotiation
A user picks your content; the XR2INDUSTRY consumer sends a Contract Request to your /protocol, authenticated with its did:web identity (DCP).
Agreement
Your provider evaluates the policy and signs a Contract Agreement.
Transfer
The consumer starts an HttpData-PULL transfer; your embedded data plane issues an EDR (public URL on /public + token).
Download
The consumer pulls through /public; your data plane fetches the file from the asset's baseUrl (your backend) and streams it.
Every connector is one container from the same image family plus PostgreSQL and Caddy (TLS + routing). The role is chosen with the ROLE variable; ports are fixed per role, so proxies and firewall rules always agree.
| Role | Image | What it does |
|---|---|---|
| provider | ghcr.io/xr2industry/edc-provider:latest | Control plane + embedded data plane. Publishes assets, negotiates, serves the bytes. The only role you need to publish. |
| consumer | ghcr.io/xr2industry/edc-consumer:latest | Browses catalogs, negotiates, pulls data. Only needed if your own application must consume content. |
| catalog | ghcr.io/xr2industry/edc-catalog:latest | Federated catalog: crawls providers and exposes one aggregated catalog. Only needed to run your own catalog. |
Provider ports (container-internal) and how Caddy exposes them:
| Context | Port | Path | Exposure | Role |
|---|---|---|---|---|
| management | 19193 | /management | Private / API key | Administration: assets, policies, contract definitions |
| protocol | 19194 | /protocol | Public | DSP — the URL you share |
| public | 19291 | /public | Public | Data plane — consumers pull here (EDR) |
| credentials | 13131 | /api/credentials | Public | DCP credential service — required for negotiation |
| did | 19180 | /.well-known/did.json | Public | Your did:web document |
| sts | 19184 | /api/sts | Public | Embedded token service |
| identity | 19181 | /api/identity | Public | Identity API |
| api | 19191 | /api | Internal | Health check, approval API |
The consumer uses the 29xxx range (management 29193, DSP 29194, credentials 29185, STS 29184, DID 29180) and the catalog the 30xxx range (target-nodes API 30191, management/query 30193, DSP 30194, federated catalog 30195; in dcp mode also DID 30180, STS 30184, credentials 30185). Only Caddy publishes 80/443; management ports are bound to 127.0.0.1.
Three containers: PostgreSQL, the provider, Caddy. This is all you need to publish content to XR2INDUSTRY. Replace edc.your-company.example with your hostname everywhere.
Create a folder and the .env file
One folder, three files. The .env holds the only values you have to change. Everything else (DID, ports, JDBC URL, callback addresses) is derived by the container's entrypoint.
mkdir edc-provider && cd edc-provider
nano .env.env
# ---- Your provider -----------------------------------------------------------
# Public hostname. A DNS A record must point to this server BEFORE first start.
# Your identity becomes did:web:edc.your-company.example
PROVIDER_HOST=edc.your-company.example
# Name shown in the XR2INDUSTRY catalog and on the admin approval page
PARTNER_NAME=Your Company
# ---- PostgreSQL --------------------------------------------------------------
DB_USER=edc
DB_PASSWORD=change-me
# ---- Management API ----------------------------------------------------------
# Empty = /management stays private (127.0.0.1:19193 on the server only).
# Set = /management is also served at https://<PROVIDER_HOST>/management,
# for requests carrying the header X-Api-Key: <this value>
MGMT_API_KEY=
# ---- XR2INDUSTRY federation --------------------------------------------------
# Self-registration: the provider announces itself on boot, an XR2INDUSTRY
# admin approves it once, then the federated catalog crawls it.
REGISTRATION_URL=https://api.xr2industry.tkorp.com
# Trust the XR2INDUSTRY realm so its catalogs may crawl you. Public URLs only:
# a provider needs NO Keycloak account, client or secret.
DATASPACE_KEYCLOAK_ISSUER=https://keycloak.xr2industry.tkorp.com/realms/XR2INDUSTRY
DATASPACE_KEYCLOAK_JWKS_URI=https://keycloak.xr2industry.tkorp.com/realms/XR2INDUSTRY/protocol/openid-connect/certs
# ---- JVM ---------------------------------------------------------------------
JAVA_TOOL_OPTIONS=-Xmx1024m -Xms512mdocker-compose.yml
Nothing is built; the image is pulled from GHCR. The connector derives its whole EDC configuration from ROLE, EDC_PUBLIC_HOST and EDC_PUBLIC_SCHEME.
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: edc
POSTGRES_USER: ${DB_USER:-edc}
POSTGRES_PASSWORD: ${DB_PASSWORD:-edc}
volumes:
- edc-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-edc} -d edc"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
provider:
image: ghcr.io/xr2industry/edc-provider:latest
depends_on:
postgres:
condition: service_healthy
environment:
ROLE: provider
EDC_PUBLIC_HOST: ${PROVIDER_HOST}
EDC_PUBLIC_SCHEME: https
DB_HOST: postgres
DB_NAME: edc
DB_USER: ${DB_USER:-edc}
DB_PASSWORD: ${DB_PASSWORD:-edc}
# XR2INDUSTRY registration
REGISTRATION_URL: ${REGISTRATION_URL:-}
REGISTRATION_NAME: ${PARTNER_NAME:-}
# XR2INDUSTRY realm (public URLs)
EDC_KEYCLOAK_ISSUER: ${DATASPACE_KEYCLOAK_ISSUER:-}
EDC_KEYCLOAK_JWKS_URI: ${DATASPACE_KEYCLOAK_JWKS_URI:-}
JAVA_TOOL_OPTIONS: ${JAVA_TOOL_OPTIONS:--Xmx1024m -Xms512m}
volumes:
- edc-provider-identity:/data/identity # did:web keys
ports:
- "127.0.0.1:19193:19193" # management API
restart: unless-stopped
caddy:
image: caddy:2-alpine
depends_on: [provider]
environment:
PROVIDER_HOST: ${PROVIDER_HOST}
MGMT_API_KEY: ${MGMT_API_KEY:-}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
ports:
- "80:80"
- "443:443"
restart: unless-stopped
volumes:
edc-db:
edc-provider-identity:
caddy-data:
caddy-config:Caddyfile
Caddy terminates TLS (Let's Encrypt, automatic) and routes only the public paths to the right internal port. Anything else returns 404.
{$PROVIDER_HOST} {
# did:web document
@did path /.well-known/did.json
handle @did {
reverse_proxy provider:19180
}
# DSP
@protocol path /protocol /protocol/*
handle @protocol {
reverse_proxy provider:19194
}
# data plane
@public path /public /public/*
handle @public {
reverse_proxy provider:19291
}
@identity path /api/identity /api/identity/*
handle @identity {
reverse_proxy provider:19181
}
@sts path /api/sts /api/sts/*
handle @sts {
reverse_proxy provider:19184
}
# credential service (required for negotiation)
@credentials path /api/credentials /api/credentials/*
handle @credentials {
reverse_proxy provider:13131
}
# management API, gated by X-Api-Key (closed when MGMT_API_KEY is empty)
@mgmt_denied {
path /management /management/*
not expression `{http.request.header.X-Api-Key} == "{$MGMT_API_KEY}" && "{$MGMT_API_KEY}" != ""`
}
handle @mgmt_denied {
respond "Unauthorized" 401
}
@management path /management /management/*
handle @management {
reverse_proxy provider:19193
}
handle {
respond "Not found" 404
}
}Start
First boot generates your EC P-256 keypair and did:web identity into the identity volume, creates the database tables, then (REGISTRATION_URL set) submits your registration to XR2INDUSTRY.
docker compose pull
docker compose up -d
docker compose logs -f provider # "[entrypoint] ROLE=provider DID=did:web:..." then the EDC boot logCheck
The DID document must be reachable over HTTPS. If it is not, check DNS and that ports 80/443 reach the server (docker compose logs caddy).
# 1. Your identity is published (Caddy + TLS + connector are up)
curl -s https://edc.your-company.example/.well-known/did.json
# 2. Health, from the server
docker compose exec provider curl -s http://localhost:19191/api/check/health
# 3. Management API, from the server (private port)
curl -sS -X POST http://127.0.0.1:19193/management/v3/assets/request \
-H "Content-Type: application/json" -d '{
"@context": { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" },
"@type": "QuerySpec",
"offset": 0,
"limit": 50
}'
# -> [] on a fresh node
# 4. Ask your own connector for its catalog over DSP (what XR2INDUSTRY will see)
curl -s -X POST http://127.0.0.1:19193/management/v3/catalog/request \
-H "Content-Type: application/json" -d '{
"@context": { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" },
"counterPartyAddress": "https://edc.your-company.example/protocol",
"counterPartyId": "did:web:edc.your-company.example",
"protocol": "dataspace-protocol-http"
}'edc-provider/
├── .env # your values (hostname, DB password, MGMT_API_KEY, XR2 URLs)
├── docker-compose.yml # postgres + provider + caddy
└── Caddyfile # mounted as ./Caddyfile — exact name, same folder
# created by Docker at first start (not files):
# volumes edc-db, edc-provider-identity, caddy-data, caddy-config (prefixed with the folder name)Next: publish content (step 4) and get approved by XR2INDUSTRY (step 5). Steps 2 and 3 explain the Management API and your identity.
Same images, one PostgreSQL with one database per connector, one Caddy routing three hostnames. Use it when your own application must also consume content, or when you want your own federated catalog. The catalog runs in dcp identity mode, so no Keycloak client is needed.
DNS
Three A records pointing at the server: the provider, consumer and catalog hostnames. Caddy obtains one certificate per hostname.
.env
# ---- Public hostnames (three DNS A records -> this server) -------------------
PROVIDER_HOST=edc.your-company.example
CONSUMER_HOST=consumer.your-company.example
CATALOG_HOST=catalog.your-company.example
PARTNER_NAME=Your Company
# ---- PostgreSQL (one instance, one database per connector) -------------------
DB_USER=edc
DB_PASSWORD=change-me
# ---- Provider management API (see the Management API section) ---------------
MGMT_API_KEY=
# ---- Catalog identity --------------------------------------------------------
# dcp = the catalog crawls with its OWN did:web identity -> no Keycloak needed.
CATALOG_IDENTITY_MODE=dcp
# ---- XR2INDUSTRY federation (provider side) ----------------------------------
REGISTRATION_URL=https://api.xr2industry.tkorp.com
DATASPACE_KEYCLOAK_ISSUER=https://keycloak.xr2industry.tkorp.com/realms/XR2INDUSTRY
DATASPACE_KEYCLOAK_JWKS_URI=https://keycloak.xr2industry.tkorp.com/realms/XR2INDUSTRY/protocol/openid-connect/certs
# ---- Optional: let YOUR catalog crawl every approved XR2INDUSTRY provider ----
# Needs CATALOG_IDENTITY_MODE=keycloak and a client id/secret from the
# XR2INDUSTRY admin. In dcp mode these three MUST stay empty.
# REGISTRY_SYNC_URL=https://api.xr2industry.tkorp.com/registrations/target-nodes
# REGISTRY_SYNC_INTERVAL=300
# DATASPACE_KEYCLOAK_TOKEN_URL=https://keycloak.xr2industry.tkorp.com/realms/XR2INDUSTRY/protocol/openid-connect/token
# DATASPACE_KEYCLOAK_CLIENT_ID=edc-catalog-<your-company>
# DATASPACE_KEYCLOAK_CLIENT_SECRET=
# ---- JVM (three JVMs on one box: keep the heaps modest) ----------------------
JAVA_TOOL_OPTIONS=-Xmx768m -Xms256minit-databases.sql
Runs once, on the first start of the Postgres volume. EDC creates its own tables but never databases.
-- Runs ONCE, on the first start of the Postgres volume.
-- POSTGRES_DB already creates edc_provider; add one database per extra connector.
-- EDC creates its own tables at startup, but never databases.
CREATE DATABASE edc_consumer;
CREATE DATABASE edc_catalog;docker-compose.yml
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: edc_provider
POSTGRES_USER: ${DB_USER:-edc}
POSTGRES_PASSWORD: ${DB_PASSWORD:-edc}
volumes:
- edc-db:/var/lib/postgresql/data
- ./init-databases.sql:/docker-entrypoint-initdb.d/init-databases.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-edc} -d edc_provider"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
provider:
image: ghcr.io/xr2industry/edc-provider:latest
depends_on:
postgres:
condition: service_healthy
environment:
ROLE: provider
EDC_PUBLIC_HOST: ${PROVIDER_HOST}
EDC_PUBLIC_SCHEME: https
DB_HOST: postgres
DB_NAME: edc_provider
DB_USER: ${DB_USER:-edc}
DB_PASSWORD: ${DB_PASSWORD:-edc}
# XR2INDUSTRY registration
REGISTRATION_URL: ${REGISTRATION_URL:-}
REGISTRATION_NAME: ${PARTNER_NAME:-}
# XR2INDUSTRY realm (public URLs)
EDC_KEYCLOAK_ISSUER: ${DATASPACE_KEYCLOAK_ISSUER:-}
EDC_KEYCLOAK_JWKS_URI: ${DATASPACE_KEYCLOAK_JWKS_URI:-}
JAVA_TOOL_OPTIONS: ${JAVA_TOOL_OPTIONS:--Xmx768m -Xms256m}
volumes:
- edc-provider-identity:/data/identity # did:web keys
ports:
- "127.0.0.1:19193:19193" # management API
restart: unless-stopped
consumer:
image: ghcr.io/xr2industry/edc-consumer:latest
depends_on:
postgres:
condition: service_healthy
environment:
ROLE: consumer
EDC_PUBLIC_HOST: ${CONSUMER_HOST}
EDC_PUBLIC_SCHEME: https
DB_HOST: postgres
DB_NAME: edc_consumer
DB_USER: ${DB_USER:-edc}
DB_PASSWORD: ${DB_PASSWORD:-edc}
JAVA_TOOL_OPTIONS: ${JAVA_TOOL_OPTIONS:--Xmx768m -Xms256m}
volumes:
- edc-consumer-identity:/data/identity
ports:
- "127.0.0.1:29193:29193" # management API
restart: unless-stopped
catalog:
image: ghcr.io/xr2industry/edc-catalog:latest
depends_on:
postgres:
condition: service_healthy
environment:
ROLE: catalog
EDC_PUBLIC_HOST: ${CATALOG_HOST}
EDC_PUBLIC_SCHEME: https
DB_HOST: postgres
DB_NAME: edc_catalog
DB_USER: ${DB_USER:-edc}
DB_PASSWORD: ${DB_PASSWORD:-edc}
# dcp | keycloak
EDC_CATALOG_IDENTITY_MODE: ${CATALOG_IDENTITY_MODE:-dcp}
# keycloak mode only
REGISTRY_SYNC_URL: ${REGISTRY_SYNC_URL:-}
REGISTRY_SYNC_INTERVAL: ${REGISTRY_SYNC_INTERVAL:-300}
EDC_KEYCLOAK_TOKEN_URL: ${DATASPACE_KEYCLOAK_TOKEN_URL:-}
EDC_KEYCLOAK_CLIENT_ID: ${DATASPACE_KEYCLOAK_CLIENT_ID:-}
EDC_KEYCLOAK_CLIENT_SECRET: ${DATASPACE_KEYCLOAK_CLIENT_SECRET:-}
JAVA_TOOL_OPTIONS: ${JAVA_TOOL_OPTIONS:--Xmx768m -Xms256m}
volumes:
- edc-catalog-identity:/data/identity
ports:
- "127.0.0.1:30191:30191" # target-nodes API
- "127.0.0.1:30193:30193" # management / catalog query
restart: unless-stopped
caddy:
image: caddy:2-alpine
depends_on: [provider, consumer]
environment:
PROVIDER_HOST: ${PROVIDER_HOST}
CONSUMER_HOST: ${CONSUMER_HOST}
CATALOG_HOST: ${CATALOG_HOST}
MGMT_API_KEY: ${MGMT_API_KEY:-}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
ports:
- "80:80"
- "443:443"
restart: unless-stopped
volumes:
edc-db:
edc-provider-identity:
edc-consumer-identity:
edc-catalog-identity:
caddy-data:
caddy-config:Caddyfile
{$PROVIDER_HOST} {
# did:web document
@did path /.well-known/did.json
handle @did {
reverse_proxy provider:19180
}
# DSP
@protocol path /protocol /protocol/*
handle @protocol {
reverse_proxy provider:19194
}
# data plane
@public path /public /public/*
handle @public {
reverse_proxy provider:19291
}
@identity path /api/identity /api/identity/*
handle @identity {
reverse_proxy provider:19181
}
@sts path /api/sts /api/sts/*
handle @sts {
reverse_proxy provider:19184
}
# credential service (required for negotiation)
@credentials path /api/credentials /api/credentials/*
handle @credentials {
reverse_proxy provider:13131
}
# management API, gated by X-Api-Key (closed when MGMT_API_KEY is empty)
@mgmt_denied {
path /management /management/*
not expression `{http.request.header.X-Api-Key} == "{$MGMT_API_KEY}" && "{$MGMT_API_KEY}" != ""`
}
handle @mgmt_denied {
respond "Unauthorized" 401
}
@management path /management /management/*
handle @management {
reverse_proxy provider:19193
}
handle {
respond "Not found" 404
}
}
{$CONSUMER_HOST} {
@did path /.well-known/did.json
handle @did {
reverse_proxy consumer:29180
}
@protocol path /protocol /protocol/*
handle @protocol {
reverse_proxy consumer:29194
}
@credentials path /api/credentials /api/credentials/*
handle @credentials {
reverse_proxy consumer:29185
}
@sts path /api/sts /api/sts/*
handle @sts {
reverse_proxy consumer:29184
}
handle {
respond "Not found" 404
}
}
{$CATALOG_HOST} {
@protocol path /protocol /protocol/*
handle @protocol {
reverse_proxy catalog:30194
}
# federated catalog query
@catalog path /catalog /catalog/*
handle @catalog {
reverse_proxy catalog:30195
}
# dcp mode only
@did path /.well-known/did.json
handle @did {
reverse_proxy catalog:30180
}
@credentials path /api/credentials /api/credentials/*
handle @credentials {
reverse_proxy catalog:30185
}
@sts path /api/sts /api/sts/*
handle @sts {
reverse_proxy catalog:30184
}
handle {
respond "Not found" 404
}
}Start
docker compose pull
docker compose up -d
docker compose logs -f provider consumer catalogPoint your catalog at your provider and check
Crawl targets are registered through the catalog's target-nodes API (bound to 127.0.0.1:30191), stored in Postgres and crawled every 60 s. Your own application reads the federated catalog and drives the consumer through its Management API on 127.0.0.1:29193.
# 1. Tell your catalog to crawl your provider (stored in Postgres, survives restarts)
curl -s -X POST http://127.0.0.1:30191/api/target-nodes -H "Content-Type: application/json" -d '{
"name": "Your Company",
"targetUrl": "https://edc.your-company.example/protocol",
"trustedIssuerDid": "did:web:edc.your-company.example"
}'
curl -s http://127.0.0.1:30191/api/target-nodes # lists the crawl targets
# 2. After ~60 s: the federated catalog (public, read-only). Your own app reads this.
curl -s -X POST https://catalog.your-company.example/catalog/v1alpha/catalog/query \
-H "Content-Type: application/json" \
-d '{"@context":{"@vocab":"https://w3id.org/edc/v0.0.1/ns/"},"@type":"QuerySpec","limit":50}'
# 3. Consumer: your application drives it through http://127.0.0.1:29193/management/v3
curl -s -X POST http://127.0.0.1:29193/management/v3/catalog/request \
-H "Content-Type: application/json" -d '{
"@context": { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" },
"counterPartyAddress": "https://edc.your-company.example/protocol",
"counterPartyId": "did:web:edc.your-company.example",
"protocol": "dataspace-protocol-http"
}'edc-node/
├── .env # three hostnames, DB password, MGMT_API_KEY, catalog mode, XR2 URLs
├── docker-compose.yml # postgres + provider + consumer + catalog + caddy
├── Caddyfile # mounted as ./Caddyfile
└── init-databases.sql # mounted into /docker-entrypoint-initdb.d — runs once
# created by Docker at first start (not files):
# volumes edc-db, edc-provider-identity, edc-consumer-identity, edc-catalog-identity, caddy-data, caddy-config (prefixed with the folder name)To let your own catalog crawl every approved XR2INDUSTRY provider (registry sync), the catalog must use the shared Keycloak realm: set CATALOG_IDENTITY_MODE=keycloak, REGISTRY_SYNC_URL and the DATASPACE_KEYCLOAK_TOKEN_URL / CLIENT_ID / CLIENT_SECRET you receive from the XR2INDUSTRY admin. Never mix the two: in dcp mode those three variables must stay empty, otherwise the Keycloak extension takes over and DCP is silently bypassed.
The container's entrypoint expands a handful of high-level inputs into the ~50 EDC_* / WEB_HTTP_* settings the runtime needs. You only ever touch these:
| Variable | Role | Meaning |
|---|---|---|
| ROLE | all | consumer, provider or catalog. Selects ports, paths and extensions. |
| EDC_PUBLIC_HOST | all | Public hostname. The did:web identity is derived from it. Changing it changes your DID. |
| EDC_PUBLIC_SCHEME | all | https for a real domain; http only for a local try-out (localhost:8080). |
| DB_HOST / DB_NAME / DB_USER / DB_PASSWORD | all | PostgreSQL connection. Tables are auto-created at startup; databases are not. |
| MGMT_API_KEY | provider (Caddy) | Value of the X-Api-Key header that unlocks https://<host>/management. Empty = closed (fails closed). |
| REGISTRATION_URL / REGISTRATION_NAME | provider | Registry base URL (https://api.xr2industry.tkorp.com) and display name. On boot the provider POSTs {name, dspUrl, did} to <url>/registrations as pending. |
| EDC_KEYCLOAK_ISSUER / EDC_KEYCLOAK_JWKS_URI | provider | Issuer and JWKS URL of the XR2INDUSTRY realm: lets the provider validate crawl tokens of authorised catalogs. Public URLs, no credentials. |
| EDC_KEYCLOAK_TOKEN_URL / _CLIENT_ID / _CLIENT_SECRET | catalog | Client-credentials a catalog uses to authenticate its crawls. Only for a catalog in keycloak mode; obtained from the XR2INDUSTRY admin. |
| EDC_CATALOG_IDENTITY_MODE | catalog | keycloak (default: shared realm token) or dcp (own did:web identity, no Keycloak). |
| REGISTRY_SYNC_URL / REGISTRY_SYNC_INTERVAL | catalog | Pull the approved provider list from the registry every N seconds (default 300) into the catalog's target-node directory. |
| JAVA_TOOL_OPTIONS | all | JVM heap, default -Xmx1024m -Xms512m. Lower it when several connectors share a small server. |
| EDC_* / WEB_HTTP_* | all | Escape hatch: any explicit EDC_* / WEB_HTTP_* value overrides the derived default (e.g. EDC_IAM_STS_OAUTH_TOKEN_URL). |
Derived automatically, do not set: EDC_PARTICIPANT_ID, EDC_DSP_CALLBACK_ADDRESS, EDC_DATAPLANE_API_PUBLIC_BASEURL, every WEB_HTTP_*_PORT / _PATH, the JDBC URL and the vault seeds. The entrypoint prints the resolved ROLE, DID and BASE on the first line of the container log; docker exec <container> cat /proc/1/environ shows the full effective configuration.
The Management API (/management, port 19193) is how you administer your provider: create assets, policies and contract definitions, read negotiations and transfers. In this build it has no authentication of its own, so exposure is controlled at the edge.
By default it is only published on 127.0.0.1 of the server: use it from a shell on the machine or through an SSH tunnel. Caddy never routes it.
Setting MGMT_API_KEY makes Caddy also serve https://<PROVIDER_HOST>/management, but only for requests whose X-Api-Key header equals the key. The rule fails closed: with an empty key every request gets 401 and nothing reaches the connector. This is how an external publishing system (a CMS, a pipeline, your own backend) creates and updates assets remotely.
To see what is already on your connector, send a QuerySpec to the /request endpoint of each type: policy definitions, contract definitions and assets. The commands below run on the host where the connector runs; offset and limit are optional.
# On the server: always available, bound to 127.0.0.1 (or via ssh -L 19193:localhost:19193 user@server)
MGMT="http://127.0.0.1:19193/management"
Q='{
"@context": { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" },
"@type": "QuerySpec",
"offset": 0,
"limit": 50
}'
# List all policy definitions
curl -sS -X POST "$MGMT/v3/policydefinitions/request" -H "Content-Type: application/json" -d "$Q"
# List all contract definitions
curl -sS -X POST "$MGMT/v3/contractdefinitions/request" -H "Content-Type: application/json" -d "$Q"
# List all assets
curl -sS -X POST "$MGMT/v3/assets/request" -H "Content-Type: application/json" -d "$Q"
# From anywhere, once MGMT_API_KEY is set in .env (header name: X-Api-Key)
MGMT="https://edc.your-company.example/management"
curl -sS -X POST "$MGMT/v3/assets/request" \
-H "X-Api-Key: $MGMT_API_KEY" -H "Content-Type: application/json" -d "$Q"
# Generate a strong key, then rotate: edit .env and recreate only Caddy
openssl rand -hex 32
docker compose up -d caddyTreat the key like a password. Anyone holding it can publish, alter or delete your offers. Use a long random value, keep it out of git, rotate it by editing .env and recreating Caddy. You can additionally restrict /management to your publisher's IP with a remote_ip matcher in the Caddyfile.
You do not generate keys or write a DID document. On first boot the entrypoint creates an EC P-256 keypair and an STS secret in the identity volume, loads them into the connector's vault, and serves the DID document at /.well-known/did.json. On restarts the same identity is reused, so your DID stays stable.
Your DID is did:web:<EDC_PUBLIC_HOST>. Counterparties resolve it over HTTPS at your hostname, which is why the DID document and the credential service must be public.
# Your DID document (generated on first boot, served by the connector through Caddy)
curl -s https://edc.your-company.example/.well-known/did.json | jq .id
# -> "did:web:edc.your-company.example"
# What is inside the identity volume
docker compose exec provider ls /data/identity
# private-key.pem public-key.pem sts-secret
# Back it up (volume name = <folder>_edc-provider-identity, see: docker volume ls)
docker run --rm -v edc-provider_edc-provider-identity:/data -v "$PWD":/backup alpine \
tar czf /backup/edc-identity-backup.tgz -C /data .The volume is your identity. Deleting it (docker compose down -v) or changing EDC_PUBLIC_HOST gives you a new DID: your registration must be approved again and past agreements no longer match. Back the volume up.
Once per node, create a policy and a contract definition (which offers every asset tagged policyTag=public). Then create one asset per content item. $MGMT is your Management API URL (local port, or the public one with X-Api-Key).
Save each body below to a file and send it with curl. Create the policy and the contract definition once; after that, every asset tagged policyTag = public is offered automatically. To update an asset, send the same body with PUT: a second POST with an existing @id returns 409.
MGMT="http://127.0.0.1:19193/management"
# remote: MGMT="https://edc.your-company.example/management" and add -H "X-Api-Key: $MGMT_API_KEY"
curl -X POST "$MGMT/v3/policydefinitions" -H "Content-Type: application/json" -d @policy.json
curl -X POST "$MGMT/v3/contractdefinitions" -H "Content-Type: application/json" -d @contract.json
curl -X POST "$MGMT/v3/assets" -H "Content-Type: application/json" -d @asset.json
# update an existing asset: same body, PUT
curl -X PUT "$MGMT/v3/assets" -H "Content-Type: application/json" -d @asset.jsonUse these exact namespace IRIs. dcterms is http://purl.org/dc/terms/ (http, not https) and dcat is http://www.w3.org/ns/dcat# (ending in #). Assets published earlier with https://purl.org/dc/terms/ or http://www.w3.org/ns/dcat/ still display in the dashboard, but send them again with PUT when you can.
1. Policy definition (once)
An open policy: one use permission, no constraints.
{
"@context": {
"@vocab": "https://w3id.org/edc/v0.0.1/ns/",
"odrl": "http://www.w3.org/ns/odrl/2/"
},
"@id": "public-policy",
"policy": {
"@context": "http://www.w3.org/ns/odrl.jsonld",
"@type": "Set",
"permission": [{ "action": "use" }],
"prohibition": [],
"obligation": []
}
}2. Contract definition (once)
Offers every asset tagged policyTag = public under public-policy.
{
"@context": { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" },
"@type": "ContractDefinition",
"@id": "public-contract",
"accessPolicyId": "public-policy",
"contractPolicyId": "public-policy",
"assetsSelector": [
{
"@type": "Criterion",
"operandLeft": "https://w3id.org/edc/v0.0.1/ns/policyTag",
"operator": "=",
"operandRight": "public"
}
]
}3a. Asset: 3D model (.glb)
The dataAddress, including authCode, stays on your connector: it is never published in the catalog.
{
"@context": {
"@vocab": "https://w3id.org/edc/v0.0.1/ns/",
"dcterms": "http://purl.org/dc/terms/",
"dcat": "http://www.w3.org/ns/dcat#",
"schema": "https://schema.org/",
"xr": "https://xr2industry.eu/ns/xr/"
},
"@id": "urn:xr:asset:trash-bag",
"@type": "Asset",
"properties": {
"dcterms:identifier": "urn:xr:asset:trash-bag",
"dcterms:title": { "value": "Trash Bag", "language": "en" },
"dcterms:description": { "value": "Low-poly trash bag prop.", "language": "en" },
"dcterms:type": "InteractiveResource",
"dcterms:format": "model/gltf-binary",
"dcterms:creator": { "@type": "schema:Organization", "schema:name": "Your Company" },
"dcterms:publisher": { "@type": "schema:Organization", "@id": "urn:xr:org:your-company", "schema:name": "Your Company" },
"dcat:mediaType": "model/gltf-binary",
"dcat:byteSize": 9990968,
"schema:thumbnailUrl": "https://cdn.your-company.example/thumbs/trash-bag.webp",
"xr:assetInfo": {
"xr:type": "3D model",
"xr:complexityLevel": "Low Poly",
"xr:hasAnimations": false,
"xr:animationCount": 0,
"xr:vertexCount": 1482,
"xr:textureCount": 3,
"xr:coordinateSystem": "Y-up",
"xr:scaleUnit": "meter",
"xr:rigged": false
},
"policyTag": "public"
},
"dataAddress": {
"@type": "DataAddress",
"type": "HttpData",
"name": "Trash-Bag.glb",
"baseUrl": "https://files.your-company.example/assets/trash-bag.glb",
"authKey": "x-api-key",
"authCode": "<secret, stays on your connector, never in the catalog>"
}
}3b. Asset: application (.apk)
authKey and authCode are optional. When present, the data plane sends that header when it fetches baseUrl, so the file URL can stay private.
{
"@context": {
"@vocab": "https://w3id.org/edc/v0.0.1/ns/",
"dcterms": "http://purl.org/dc/terms/",
"dcat": "http://www.w3.org/ns/dcat#",
"schema": "https://schema.org/",
"xr": "https://xr2industry.eu/ns/xr/"
},
"@id": "urn:xr:application:maintenance-trainer",
"@type": "Asset",
"properties": {
"dcterms:identifier": "urn:xr:application:maintenance-trainer",
"dcterms:title": { "value": "Maintenance Trainer", "language": "en" },
"dcterms:description": { "value": "VR maintenance training for Quest 3.", "language": "en" },
"dcterms:type": "Software",
"dcterms:format": "application/vnd.android.package-archive",
"dcterms:publisher": { "@type": "schema:Organization", "@id": "urn:xr:org:your-company", "schema:name": "Your Company" },
"dcat:mediaType": "application/vnd.android.package-archive",
"dcat:byteSize": 152043520,
"schema:thumbnailUrl": "https://cdn.your-company.example/thumbs/maintenance-trainer.webp",
"xr:assetInfo": { "xr:type": "application" },
"policyTag": "public"
},
"dataAddress": {
"@type": "DataAddress",
"type": "HttpData",
"name": "maintenance-trainer.apk",
"baseUrl": "https://files.your-company.example/assets/maintenance-trainer.apk"
}
}Check: your connector knows the asset
The response should list it.
{
"@context": { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" },
"@type": "QuerySpec"
}Check: the federated catalog lists it
The asset appears under dcat:dataset with an odrl:hasPolicy offer. The catalog crawls periodically, so allow a few minutes.
{
"@context": { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" },
"@type": "QuerySpec",
"offset": 0,
"limit": 50
}Asset properties read by the XR2INDUSTRY dashboard
Thumbnails are optional, and must be publicly reachable. Two options. Recommended: schema:thumbnailUrl with an image URL (about 400 px, webp or jpeg). Each viewer's browser loads that URL directly, so it must work without any authentication: no login, API key, cookie or custom header, and no pre-signed URL that expires. Test it by opening it in a private browser window. Alternative: put the image in thumbnail as a base64 data URI; it always works, but it makes every catalog response heavier. Without a thumbnail, the dashboard shows a placeholder.
baseUrl must be public and reachable by the data plane (no internal IP, no Docker gateway); otherwise the transfer fails at pull time (500). To change it later, send the asset again with PUT.
Avoid the word “approval” in an asset @id. This build ships a manual approval gate: transfers of such assets are blocked until approved through the connector-internal /api/approval endpoint.
The asset's baseUrl points at your backend. When the data plane pulls the data, it calls that endpoint with the declared auth header. Your endpoint must verify the secret, then return the file (or 302-redirect to a pre-signed S3 URL).
app.get("/contents/:id/download", async (req, res) => {
if (req.header("x-api-key") !== process.env.FILE_SECRET) {
return res.status(401).send("Unauthorized");
}
// Option A: stream the file
// return fileStream.pipe(res);
// Option B: redirect to a pre-signed URL (S3 / Scaleway / GCS)
const url = await getPresignedUrl(req.params.id);
return res.redirect(302, url);
});Appearing in the XR2INDUSTRY dashboard is a two-step handshake: your provider registers itself, an XR2INDUSTRY administrator approves it. Nothing to paste on either side afterwards.
Self-registration
With REGISTRATION_URL set, the provider waits until it is healthy and POSTs {name, dspUrl, did} to https://api.xr2industry.tkorp.com/registrations. The request is idempotent on your DID: a reboot updates the endpoint without resetting an approval. A failed POST only logs a warning and never blocks startup.
Approval
An admin opens the dashboard's “Connector registrations” page (Protected › Admin) and approves the request shown under your PARTNER_NAME. Approval adds your /protocol URL and DID to the federated catalog's target-node directory.
Crawl
Within about a minute the XR2INDUSTRY catalog crawls your /protocol and your public assets appear in the dashboard. New assets show up on the next crawl, no further action needed.
Two independent lanes. Negotiation and transfer use DCP: the XR2INDUSTRY consumer proves its did:web identity with a Verifiable Presentation, your provider verifies it through the DID document, and vice versa. Trust is did:web based with a wildcard trusted-issuer model, so no DID exchange and no allow-list is needed: any participant with a cryptographically valid presentation is accepted, and policies decide what they get.
Catalog crawling uses the shared XR2INDUSTRY Keycloak realm as an identifier: an authorised catalog presents a client-credentials token, and your provider validates its signature against the realm's JWKS URL. As a provider you only configure the issuer and JWKS URLs (EDC_KEYCLOAK_ISSUER, EDC_KEYCLOAK_JWKS_URI). The token URL, client id and client secret are only needed by a catalog that crawls, so a provider-only node or a dcp-mode catalog never needs a Keycloak client.
Honest caveat: with no issuer configured, your catalog is readable by any DSP client. Negotiation and transfer remain policy-gated either way.
From the server, after the stack is up:
# 1. Your identity is published (Caddy + TLS + connector are up)
curl -s https://edc.your-company.example/.well-known/did.json
# 2. Health, from the server
docker compose exec provider curl -s http://localhost:19191/api/check/health
# 3. Management API, from the server (private port)
curl -sS -X POST http://127.0.0.1:19193/management/v3/assets/request \
-H "Content-Type: application/json" -d '{
"@context": { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" },
"@type": "QuerySpec",
"offset": 0,
"limit": 50
}'
# -> [] on a fresh node
# 4. Ask your own connector for its catalog over DSP (what XR2INDUSTRY will see)
curl -s -X POST http://127.0.0.1:19193/management/v3/catalog/request \
-H "Content-Type: application/json" -d '{
"@context": { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" },
"counterPartyAddress": "https://edc.your-company.example/protocol",
"counterPartyId": "did:web:edc.your-company.example",
"protocol": "dataspace-protocol-http"
}'
# Did the registration go out? (provider log, right after the health check passes)
docker compose logs provider | grep -i registration
# [entrypoint] Submitted registration request to https://api.xr2industry.tkorp.com/registrations (pending approval)
# Re-send it manually if needed (idempotent on your DID)
curl -s -X POST https://api.xr2industry.tkorp.com/registrations -H "Content-Type: application/json" -d '{
"name": "Your Company",
"dspUrl": "https://edc.your-company.example/protocol",
"did": "did:web:edc.your-company.example"
}'If the catalog response lists your dcat:Dataset and the registration log line is there, you are done ✅
docker compose pull → denied / unauthorized
Cause: A stale ghcr.io login on the machine. The images are public and need no login.
Fix: docker logout ghcr.io, then pull again.
Caddy: no certificate, or connection refused on 443
Cause: DNS does not resolve to this server yet, or ports 80/443 are filtered. Let's Encrypt validates over HTTP.
Fix: Fix DNS / firewall, then docker compose restart caddy. Read docker compose logs caddy.
Catalog browsing works but negotiation fails with 401
Cause: The credential service (/api/credentials, port 13131) is not routed publicly. Counterparties resolve it from your DID document and POST presentation queries there.
Fix: Add the @credentials block to your Caddyfile (present in the files above) and reload Caddy.
/management returns 401 from outside
Cause: MGMT_API_KEY is empty (fail closed), the header is missing or misspelled, or Caddy was not recreated after editing .env.
Fix: Set the key, send X-Api-Key: <key>, then docker compose up -d caddy.
Transfer reaches STARTED, then the download fails with 500
Cause: The data plane cannot reach the asset's baseUrl (internal IP, Docker gateway, firewall).
Fix: Put a public HTTPS URL in baseUrl and send the asset again with PUT.
401 when the data plane fetches the source
Cause: The authCode declared in the asset (e.g. x-api-key) does not match what your backend expects.
Fix: Align the asset's authKey/authCode with the check in your backend.
Cannot delete an asset — 409 ObjectConflict
Cause: EDC forbids deleting an asset already referenced by a Contract Agreement (traceability).
Fix: Do not delete it: hide it with policyTag=private (asset update).
Catalog empty although assets exist
Cause: No contract definition selects your assets, or they lack policyTag=public.
Fix: Check the contract definition's assetsSelector and the assets' policyTag.
Transfer never completes for an asset whose id contains “approval”
Cause: The provider ships a manual approval gate: agreements for such assets are blocked until approved.
Fix: Rename the asset, or approve it: docker compose exec provider curl -X POST http://localhost:19191/api/approval/<contractAgreementId>/approve
New DID after a redeploy — registration back to pending
Cause: The identity volume was removed (docker compose down -v) or EDC_PUBLIC_HOST changed.
Fix: Restore the volume backup. Otherwise ask the XR2INDUSTRY admin to approve the new registration.
Catalog in dcp mode still behaves like keycloak mode / crawls fail
Cause: DATASPACE_KEYCLOAK_TOKEN_URL / CLIENT_ID / CLIENT_SECRET are set: the Keycloak extension wins and DCP is bypassed.
Fix: Blank the three variables in dcp mode. If the catalog's STS calls through Caddy fail, set EDC_IAM_STS_OAUTH_TOKEN_URL=http://localhost:30184/api/sts/token on the catalog service.
Once approved, there is nothing to paste: the federated catalog crawls you. For a direct connection, the API URL field of the “Manage catalogs” dialog expects your DSP endpoint:
https://edc.your-company.example/protocol