Installation
edc-core ships as a small set of containers: a PostgreSQL system of record, a Node/TypeScript API, a browser SPA, and sandboxed R and Python engines. One compose file brings up the whole stack; rootless Podman is the recommended path, and Docker works identically.
Requirements
Section titled “Requirements”- Podman (or Docker) with the compose plugin
- Node ≥ 22 and pnpm ≥ 9 for the one-time setup scripts and local development
- ~2 GB free disk for images and volumes
Try it with zero install
Section titled “Try it with zero install”The badge boots the full dev stack in a free GitHub Codespace; no local
tools needed, just a GitHub account. After ~3 minutes the web UI opens on
the forwarded port; sign in as admin / edc-demo-admin, or as any seeded
clinical-role user with the password edc-demo-users and take the
five-minute tour. The codespace is yours alone and the study is
fictional; delete it when you’re done.
Quick start
Section titled “Quick start”git clone https://github.com/tgerke/edc-corecd edc-corepnpm install
# Postgres + API + web + R and Python enginespodman compose -f infra/compose.yaml up --buildThe API applies database migrations automatically on startup. When the stack is up:
- Web UI: http://localhost:5173
- API: http://localhost:3000/health
Create the first admin
Section titled “Create the first admin”pnpm --filter @edc-core/api db:bootstrap-adminThis creates a root administrator account and prints its generated credentials. Sign in with them at http://localhost:5173.

Seed the demo study (optional, recommended)
Section titled “Seed the demo study (optional, recommended)”pnpm --filter @edc-core/api db:seed-demoThis creates a complete, CDASH-aligned demo study (ST.CDASH.DEMO) with two
sites, one user per clinical role, enrolled subjects, and an open data query:
everything the five-minute tour uses. The script prints the shared
demo password (override it with the EDC_DEMO_PASSWORD environment variable)
and is idempotent: it exits without changes if the demo study already exists.
Single sign-on (OIDC)
Section titled “Single sign-on (OIDC)”edc-core signs users in with a local username and password by default, and can additionally (or exclusively) use your organization’s identity provider via OpenID Connect; Microsoft Entra ID, Okta, and Keycloak all work with the standard authorization-code flow. Configure it with environment variables on the API container:
| Variable | Meaning |
|---|---|
EDC_OIDC_ISSUER_URL | The provider’s issuer URL. Setting this enables SSO. |
EDC_OIDC_CLIENT_ID / EDC_OIDC_CLIENT_SECRET | The registered client. |
EDC_OIDC_REDIRECT_URI | Must match the registration, e.g. https://edc.example.org/api/auth/oidc/callback. |
EDC_OIDC_SCOPES | Default openid profile email. |
EDC_OIDC_PROVIDER_LABEL | Button label on the sign-in page, e.g. MSK SSO. |
EDC_OIDC_ONLY | Set to 1 to disable password login entirely. |
EDC_OIDC_REAUTH_MAX_AGE_SECONDS | Freshness window for signature re-authentication (default 120). |
First-time SSO users are provisioned automatically (matched to an existing account by verified email when one exists, created otherwise) but arrive with no study roles: an administrator grants those in the app, exactly as for local accounts. SSO accounts never receive system administration from the identity provider.
Part 11 e-signatures re-execute authentication at signing. Password users
re-enter their credentials; SSO users complete a fresh interactive login with
the identity provider (prompt=login), verified against the token’s
auth_time.
If EDC_OIDC_ONLY is set and the identity provider is misconfigured, recovery
is to unset the variable and restart the API; password login resumes.
Notifications and email
Section titled “Notifications and email”Query activity and forms awaiting signature raise in-app notifications (the bell in the header) out of the box. Two optional extras are env-driven on the API container:
| Variable | Meaning |
|---|---|
EDC_NOTIFY_SCAN_MINUTES | Minutes between background scans (default 15; 0 disables the scheduler). |
EDC_FORM_OVERDUE_DAYS | Days after a visit is created before an unfinished form counts as overdue (default 0 = off). A deliberately crude signal; enable it once the cadence suits your protocol. |
EDC_SMTP_HOST / EDC_SMTP_PORT / EDC_SMTP_SECURE | SMTP relay; setting the host enables email delivery of notifications. |
EDC_SMTP_USER / EDC_SMTP_PASS | SMTP credentials, if the relay needs them. |
EDC_SMTP_FROM | From address, e.g. edc-core <no-reply@example.org>. |
EDC_BASE_URL | Web origin used in email deep links. |
The scheduler runs inside the API process and assumes the single-instance deployment the compose file provides; email failures are retried a few times and never block the in-app notification.
Published images
Section titled “Published images”Tagged releases publish versioned images to GitHub Container Registry:
ghcr.io/tgerke/edc-core-apighcr.io/tgerke/edc-core-webghcr.io/tgerke/edc-core-r-engineghcr.io/tgerke/edc-core-py-engineEach release also attaches a validation pack: the regulatory traceability matrix joined to that exact release’s automated test results. See Compliance.
Production install
Section titled “Production install”For a networked deployment, don’t harden the dev stack; use the production one. On any host with Docker (or Podman) and a DNS name pointed at it:
git clone --depth 1 --branch v0.5.1 https://github.com/tgerke/edc-corecd edc-core/infracp .env.example .env # set EDC_DOMAIN, EDC_VERSION, POSTGRES_PASSWORDdocker compose -f compose.prod.yaml up -ddocker compose -f compose.prod.yaml exec api pnpm --filter @edc-core/api db:bootstrap-admincompose.prod.yaml runs the pinned release images from GHCR, terminates TLS
at Caddy (certificates are provisioned and renewed automatically), publishes
nothing else, and sets the production hardening variables for you. The
remaining operational work (encrypted volumes, paired backups, retention
policy) is the deployment guide.
Provisioning the VM
Section titled “Provisioning the VM”If the host doesn’t exist yet, infra/cloud-init.yaml builds it on any
provider that accepts cloud-init user data (AWS, Azure, DigitalOcean,
Hetzner, Proxmox, …): Docker, the release-pinned stack above, SSH
hardening, and a firewall, unattended. Render the placeholders and paste
the result into the provider’s user data field:
sed -e 's/${domain}/edc.example.org/' \ -e 's/${app_version}/0.5.1/' \ -e "s/\${postgres_password}/$(openssl rand -hex 24)/" \ -e 's/${compose_profiles}/local-db,engines/' \ -e 's/${extra_env}//' \ infra/cloud-init.yaml > user-data.yamlA VM with 2 vCPUs and 4 GB RAM is comfortable for the full stack including
the analytics engines. First boot takes a few minutes
(cloud-init status --wait on the host); once DNS points at it, Caddy
obtains the certificate on its own, and the first admin is created with the
db:bootstrap-admin command above.
Prefer infrastructure-as-code? infra/terraform/{aws,azure,digitalocean}
are three self-contained Terraform roots with an identical variable
contract. Each provisions the VM, firewall (SSH restricted to your admin
CIDR), static IP, and encrypted disk, and delegates the install to the same
cloud-init file. Each directory’s README is a complete walkthrough.
Local development
Section titled “Local development”The repo is a pnpm monorepo. Useful commands from the repository root:
pnpm check # biome lint + typecheck + all testspnpm --filter @edc-core/api dev # API with hot reload on :3000pnpm --filter @edc-core/web dev # Vite dev server on :5173pnpm validation-pack # regenerate the validation pack locallySee CONTRIBUTING.md for ground rules, most importantly that every clinical-data write path goes through the audit layer.