Back to catalog

Host your own EDC connector

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.

How it works

Your connector (provider) publishes, the XR2INDUSTRY federated catalog crawls it, and the XR2INDUSTRY consumer negotiates and pulls. The journey of one content item:

  1. 1

    Publish

    You create an asset (metadata + where the file lives) on your provider's Management API, covered by a policy and a contract definition.

  2. 2

    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.

  3. 3

    Negotiation

    A user picks your content; the XR2INDUSTRY consumer sends a Contract Request to your /protocol, authenticated with its did:web identity (DCP).

  4. 4

    Agreement

    Your provider evaluates the policy and signs a Contract Agreement.

  5. 5

    Transfer

    The consumer starts an HttpData-PULL transfer; your embedded data plane issues an EDR (public URL on /public + token).

  6. 6

    Download

    The consumer pulls through /public; your data plane fetches the file from the asset's baseUrl (your backend) and streams it.

Glossary

Provider / Consumer / Catalog
The three connector roles, three images. Provider publishes and serves data; consumer negotiates and pulls; catalog crawls providers and aggregates their offers.
DSP
Dataspace Protocol — the HTTP protocol between connectors (catalog, negotiation, transfer). Served on /protocol.
DCP
Decentralized Claims Protocol — the identity layer: did:web + Verifiable Presentations. Used for negotiation and transfer.
did:web
Your connector's identity, derived from its hostname: did:web:edc.your-company.example. Generated on first boot, served at /.well-known/did.json.
Asset
A shared content item: a dataAddress (where the file lives) + free-form metadata (title, format, thumbnail…).
Policy / Contract Definition
ODRL usage rules, and the link between a policy and a set of assets (assetsSelector). Determines what appears in your catalog.
Contract Agreement
The contract signed after a successful negotiation.
Data plane / EDR
The component that moves the bytes via /public. The Endpoint Data Reference is the temporary URL + token the consumer pulls with.
Management API
Private admin API (/management) to create assets, policies and contract definitions and to read negotiations and transfers.
Federated catalog
A catalog connector that crawls providers' /protocol and exposes one aggregated, queryable catalog (/catalog).
Registration
Your provider announcing itself to the XR2INDUSTRY registry (name, DSP URL, DID). An admin approves it once.
STS
Secure Token Service, embedded in every connector. Issues the self-signed identity tokens used by DCP; no external service.

Prerequisites

  • A Linux server with Docker Engine and Docker Compose v2. 2 vCPU / 4 GB RAM is enough for one connector; plan 8 GB for the full node (three JVMs).
  • One public hostname per connector, with a DNS A record pointing at the server before the first start (Caddy needs it to obtain the TLS certificate).
  • Inbound ports 80 and 443 open. Nothing else needs to be exposed.
  • An HTTP backend that serves your files (the asset baseUrl): a web server, object storage with pre-signed URLs, or your own API.
  • Nothing to build or install: the images are public on ghcr.io/xr2industry. No Java, no key generation, no Keycloak account.

Roles, images & ports

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.

RoleImageWhat it does
providerghcr.io/xr2industry/edc-provider:latestControl plane + embedded data plane. Publishes assets, negotiates, serves the bytes. The only role you need to publish.
consumerghcr.io/xr2industry/edc-consumer:latestBrowses catalogs, negotiates, pulls data. Only needed if your own application must consume content.
catalogghcr.io/xr2industry/edc-catalog:latestFederated catalog: crawls providers and exposes one aggregated catalog. Only needed to run your own catalog.

Provider ports (container-internal) and how Caddy exposes them:

ContextPortPathExposureRole
management19193/managementPrivate / API keyAdministration: assets, policies, contract definitions
protocol19194/protocolPublicDSP — the URL you share
public19291/publicPublicData plane — consumers pull here (EDR)
credentials13131/api/credentialsPublicDCP credential service — required for negotiation
did19180/.well-known/did.jsonPublicYour did:web document
sts19184/api/stsPublicEmbedded token service
identity19181/api/identityPublicIdentity API
api19191/apiInternalHealth 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.

Step 1 — Quick start A — provider only (publish content)

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.

  1. 1

    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.

    bash
    mkdir edc-provider && cd edc-provider
    nano .env
  2. 2

    .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 -Xms512m
  3. 3

    docker-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.

    docker-compose.yml
    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:
  4. 4

    Caddyfile

    Caddy terminates TLS (Let's Encrypt, automatic) and routes only the public paths to the right internal port. Anything else returns 404.

    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
      }
    }
  5. 5

    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.

    bash
    docker compose pull
    docker compose up -d
    docker compose logs -f provider     # "[entrypoint] ROLE=provider DID=did:web:..." then the EDC boot log
  6. 6

    Check

    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).

    bash
    # 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"
    }'
Expected folder layout
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.

Step 1 — Quick start B — full node (provider + consumer + catalog)

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.

  1. 1

    DNS

    Three A records pointing at the server: the provider, consumer and catalog hostnames. Caddy obtains one certificate per hostname.

  2. 2

    .env

    .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 -Xms256m
  3. 3

    init-databases.sql

    Runs once, on the first start of the Postgres volume. EDC creates its own tables but never databases.

    init-databases.sql
    -- 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;
  4. 4

    docker-compose.yml

    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:
  5. 5

    Caddyfile

    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
      }
    }
  6. 6

    Start

    bash
    docker compose pull
    docker compose up -d
    docker compose logs -f provider consumer catalog
  7. 7

    Point 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.

    bash
    # 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"
    }'
Expected folder layout
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.

Environment variables (reference)

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:

VariableRoleMeaning
ROLEallconsumer, provider or catalog. Selects ports, paths and extensions.
EDC_PUBLIC_HOSTallPublic hostname. The did:web identity is derived from it. Changing it changes your DID.
EDC_PUBLIC_SCHEMEallhttps for a real domain; http only for a local try-out (localhost:8080).
DB_HOST / DB_NAME / DB_USER / DB_PASSWORDallPostgreSQL connection. Tables are auto-created at startup; databases are not.
MGMT_API_KEYprovider (Caddy)Value of the X-Api-Key header that unlocks https://<host>/management. Empty = closed (fails closed).
REGISTRATION_URL / REGISTRATION_NAMEproviderRegistry 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_URIproviderIssuer 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_SECRETcatalogClient-credentials a catalog uses to authenticate its crawls. Only for a catalog in keycloak mode; obtained from the XR2INDUSTRY admin.
EDC_CATALOG_IDENTITY_MODEcatalogkeycloak (default: shared realm token) or dcp (own did:web identity, no Keycloak).
REGISTRY_SYNC_URL / REGISTRY_SYNC_INTERVALcatalogPull the approved provider list from the registry every N seconds (default 300) into the catalog's target-node directory.
JAVA_TOOL_OPTIONSallJVM heap, default -Xmx1024m -Xms512m. Lower it when several connectors share a small server.
EDC_* / WEB_HTTP_*allEscape 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.

Step 2 — Management API & MGMT_API_KEY

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.

bash
# 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 caddy

Treat 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.

Step 3 — Identity (did:web) & keys

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.

bash
# 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.

Step 4 — Publish content: policy, contract definition & assets

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.

bash
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.json

Use 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.

POST /management/v3/policydefinitions
{
  "@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.

POST /management/v3/contractdefinitions
{
  "@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.

POST /management/v3/assets
{
  "@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.

POST /management/v3/assets
{
  "@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.

POST /management/v3/assets/request
{
  "@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.

POST {catalogUrl}/catalog/v1alpha/catalog/query
{
  "@context": { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" },
  "@type": "QuerySpec",
  "offset": 0,
  "limit": 50
}

Asset properties read by the XR2INDUSTRY dashboard

@id + dcterms:identifier
Unique id, e.g. urn:xr:asset:<slug> or urn:xr:application:<slug>. Same value in both. Used for negotiation and transfer
dcterms:title
Card title: { value, language } or a plain string
dcterms:description
Description, same shape as the title
dcterms:type
DCMI type: InteractiveResource for 3D models, Software for applications (also StillImage, MovingImage, Sound)
dcterms:format
MIME type: model/gltf-binary, model/gltf+json, model/fbx, model/obj, model/usdz, application/vnd.android.package-archive…
dcat:mediaType
Same value as dcterms:format
dcterms:creator / dcterms:publisher
schema:Organization with schema:name. The dashboard shows the publisher
dcat:byteSize
Size in bytes, as a number. Used for the download progress bar
schema:thumbnailUrl
Optional, recommended. Public URL of the preview image, loaded directly by each viewer's browser: it must work without any authentication
thumbnail
Optional alternative: the preview embedded as a data:image/...;base64 URI
xr:assetInfo
xr:type (3D model, application…) sets the dashboard category and type filter. For 3D: xr:complexityLevel (Low / Medium / High Poly), xr:hasAnimations, xr:animationCount, xr:vertexCount, xr:textureCount, xr:coordinateSystem, xr:scaleUnit, xr:rigged
dcat:theme / dcterms:issued
Optional: themes used as filters, and the publication date (ISO 8601)
policyTag
public = listed, private = hidden (matched by the contract definition)

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.

Your download 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).

Express
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);
});

Step 5 — Register with XR2INDUSTRY

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.

  1. 1

    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.

  2. 2

    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.

  3. 3

    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.

How trust works

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.

Step 6 — Verify

From the server, after the stack is up:

bash
# 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 ✅

Manage contents (update / remove)

  • Update: send the same body with PUT (same @id). Title, format, thumbnail and size are refreshed on the next crawl.
  • Remove from the catalog: set policyTag to private. Do not delete an asset that has already been negotiated (EDC returns 409).
  • Delete permanently: only possible when no contract agreement references the asset.
  • Update the connector: docker compose pull && docker compose up -d. Identity and data live in named volumes and survive.

Troubleshooting (common errors)

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.

FAQ

Do I need Keycloak?
Not as a provider: no account, client or secret. You only set two public URLs (issuer + JWKS) so XR2INDUSTRY catalogs can crawl you. A catalog in keycloak mode is the only role that needs a client, handed to you by the XR2INDUSTRY admin.
Can I try it locally, without a domain?
Yes. Set EDC_PUBLIC_HOST=localhost:8080, EDC_PUBLIC_SCHEME=http, use the Caddy site address :80 published as 8080:80; your DID becomes did:web:localhost%3A8080. Or run the interactive installer shipped inside the image: docker run --rm --entrypoint cat ghcr.io/xr2industry/edc-provider:latest /opt/selfhost/install.sh | sh
Where do I see my registration status?
Admins see it in the dashboard (Protected › Admin › Connector registrations). Once approved, your content appears in the catalog automatically within a crawl cycle.
Must the Management API be public?
No. Keep it on 127.0.0.1 and use SSH, unless an external system must publish; then set MGMT_API_KEY and send X-Api-Key.
Can I use nginx or Traefik instead of Caddy?
Yes. Route the same paths to the same ports (see the ports table), keep /management private or key-gated, and disable response buffering on /public for large files.
How do I remove a content item from the catalog?
Set its asset to policyTag=private (update). Never delete an already negotiated asset (EDC refuses).
How do I update a content item's info?
Send the same body with PUT (same @id): title, format, thumbnail, size are refreshed. A second POST returns 409.
Maximum file size?
No EDC limit. Caddy streams responses; make sure your source backend tolerates long downloads.
Can several providers share one catalog?
Each provider exposes its own /protocol. The XR2INDUSTRY federated catalog crawls all approved providers into one view; a full node (quick start B) does the same for your own catalog.

Your URL for XR2INDUSTRY

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:

API URL
https://edc.your-company.example/protocol