The official website for the Catholic Digital Commons Foundation (CDCF), built with Next.js and headless WordPress. This site serves as the public-facing portal for the foundation, showcasing projects, community resources, news, and governance information.
- Framework: Next.js 16 (App Router, React Server Components)
- CMS: WordPress (headless) with WPGraphQL + ACF + Polylang
- Styling: Tailwind CSS v4 + custom CDCF brand system
- i18n: next-intl (UI chrome) + Polylang (CMS content)
- Translation Management: Weblate (for
messages/*.jsonUI strings) - Development: Docker Compose (WordPress + MariaDB + Next.js + Nginx)
- Production: Native on Plesk (WordPress + Next.js standalone) with GitHub Actions CI/CD
- Node.js 22+
- npm 10+
- Docker & Docker Compose (for local development)
# Clone the repository
git clone https://github.com/CatholicOS-org/cdcf-website.git
cd cdcf-website
# Install frontend dependencies
npm install
# Copy environment template
cp .env.local.example .env.localEdit .env.local:
| Variable | Description |
|---|---|
WP_GRAPHQL_URL |
WordPress GraphQL endpoint (e.g. http://localhost/graphql or http://wordpress/graphql in Docker) |
WP_PREVIEW_SECRET |
Shared secret for Next.js draft mode preview |
WP_DB_ROOT_PASSWORD |
MariaDB root password |
WP_DB_NAME |
WordPress database name (default: wordpress) |
WP_DB_USER |
WordPress database user (default: wordpress) |
WP_DB_PASSWORD |
WordPress database password |
Local sign-in runs against a Zitadel in this repo's compose stack — not
the production instance at auth.catholicdigitalcommons.org.
docker compose up -d --wait zitadel-db zitadel zitadel-login zitadel-proxy--wait blocks until every service passes its healthcheck. Without it the
command returns while Zitadel is still migrating, and the provisioning run
below fails on a PAT file that does not exist yet.
Four services, mirroring how production is assembled:
| Service | Role |
|---|---|
zitadel-db |
Zitadel's own PostgreSQL — the stack's db is MariaDB |
zitadel |
the backend; publishes no host port |
zitadel-login |
the v2 sign-in UI, served under /ui/v2/login |
zitadel-proxy |
nginx owning 8090, routing /ui/v2/login* to the UI, rest to the backend |
zitadel-login runs because production requires Login V2; a local stack on V1
would exercise a sign-in UI no deployed environment serves. It is an interim
dependency — the intended end state is this project implementing sign-in
natively against the Zitadel APIs, at which point the container goes away.
First boot runs migrations and writes two tokens: a machine-user token to
.zitadel-data/automation-user.pat (used by provisioning below) and
.zitadel-data/login-client.pat (used by zitadel-login). Both are
first-instance settings, so enabling Login V2 on a stack that was already
initialised requires removing zitadel_db_data first — otherwise the login UI
starts but fails every request unauthenticated.
Provisioning the OIDC app is done from
cdcf-infra, which owns
Zitadel configuration for every property — this repo adds no provisioning
script. From cdcf-infra/auth, in a per-property env file —
.env.local.cdcf-website, not the shared .env.local — carrying:
ZITADEL_ISSUER=http://localhost:8090
ZITADEL_INTERNAL_URL=http://127.0.0.1:8090
ZITADEL_PAT_FILE=<path-to>/cdcf-website/.zitadel-data/automation-user.patthen:
ENV_FILE=.env.local.cdcf-website \
./setup-zitadel.sh --target local --create-orgs --provision-cdcf-websiteAll three of those values are specific to this repo's local Zitadel, and
every umbrella property runs its own on its own port — martyrology-api on
8080, this one on 8090. Sharing one .env.local across them means whichever
property you configured last wins, and the failure is silent rather than
loud: run --provision-martyrology while the file still points here and
Martyrology's project is created inside cdcf-website's Zitadel, because this
PAT is a valid IAM_OWNER for this instance. Keeping one file per property
makes that impossible. ENV_FILE overrides the default, and each run echoes
the instance it is about to touch:
[setup-zitadel] Target: local (issuer: http://localhost:8090, ...)
--create-orgs must come first — provisioning exits 13 without the CDCF Org.
Copy the printed AUTH_ZITADEL_ID, AUTH_ZITADEL_SECRET and Org ID into
.env.local.
Then confirm you are actually on the local instance — every other step can pass while sign-in still silently uses production:
-
Check where sign-in actually sends you, not what the file says. Auth.js does not expose the issuer via
/api/auth/providers, so read it off the authorize redirect:CSRF=$(curl -s -c /tmp/ck http://localhost:3000/api/auth/csrf \ | python3 -c "import json,sys;print(json.load(sys.stdin)['csrfToken'])") curl -s -b /tmp/ck -o /dev/null -D - -X POST \ -d "csrfToken=$CSRF&callbackUrl=http://localhost:3000/" \ http://localhost:3000/api/auth/signin/zitadel | grep -i '^location:'
The
Locationmust starthttp://localhost:8090/oauth/v2/authorize. If it startshttps://auth.catholicdigitalcommons.org,AUTH_ZITADEL_ISSUERdid not reach the running server — restartnpm run devafter editing.env.local. -
Sign in, then sign out through
/api/auth/zitadel-signout, with the browser devtools Network tab filtered oncatholicdigitalcommons. Bothlib/auth.ts's authorize/token calls and the sign-out redirect must produce no requests toauth.catholicdigitalcommons.org.
A stale AUTH_ZITADEL_ISSUER in a running dev server is the usual cause of a
green-looking setup that never left production.
These Compose variables come from .env (Compose does not read .env.local):
| Variable | Default | Notes |
|---|---|---|
ZITADEL_PORT |
8090 |
8080 collides with the LitCal and Martyrology stacks |
ZITADEL_MASTERKEY |
MasterkeyNeedsToHave32Characters |
Exactly 32 chars, and never change it — see below |
ZITADEL_DB_PASSWORD |
postgres / zitadel |
Local only |
Changing ZITADEL_MASTERKEY after first boot makes existing instance data
undecryptable; recovery means removing the Zitadel services and their
database volume, then re-provisioning, which invalidates the client IDs in
.env.local. Never change the masterkey on data you want to keep —
scope the recovery to Zitadel only, not the whole stack (docker compose down -v would also delete db_data, redis_data and wordpress_data,
wiping your local WordPress install):
docker compose rm -sf zitadel zitadel-db
docker volume ls | grep zitadel # read the real name from this output
docker volume rm <name-from-the-listing>Take the volume name from that listing rather than pasting a literal: the
cdcf-website_ prefix comes from the Compose project name and differs if
you've overridden it, so a hard-coded cdcf-website_zitadel_db_data can
silently miss the volume you meant to remove — or match one you did not.
ZITADEL_DB_PASSWORD has the same one-way property as the masterkey, for a
different reason: POSTGRES_PASSWORD initialises the Postgres role only on
an empty volume. Change it once zitadel_db_data exists and the role keeps
its old password, so Zitadel fails to authenticate against its own database
while the variable reads as correct. Recovery is the same sequence above —
stop the two services, remove the volume, bring the stack back up with the
intended password, and re-provision.
Changing ZITADEL_PORT means updating
AUTH_ZITADEL_ISSUER in .env.local and the two cdcf-infra URLs above to
match.
The recommended way to develop is with Docker, which starts WordPress, MariaDB, Next.js, and Nginx together:
docker compose up --build- Next.js frontend: http://localhost (via Nginx) or http://localhost:3000 (direct)
- WordPress admin: http://localhost/wp-admin
- GraphQL endpoint: http://localhost/graphql
If WordPress is already running elsewhere (e.g. a staging server), you can run just the Next.js frontend:
# Set WP_GRAPHQL_URL in .env.local to point at your WordPress instance
npm run devOpen http://localhost:3000.
Using Docker (automatic): The wp-init service in docker-compose.yml runs wordpress/init.sh on first boot, which automatically:
- Installs WordPress core with admin credentials from env vars
- Installs and activates all required plugins (WPGraphQL, ACF, WPGraphQL for ACF, Polylang, WPGraphQL Polylang)
- Activates the
cdcf-headlesstheme - Configures all 6 Polylang languages
- Creates all pages (Home, About, Projects, Community, Blog, Contact) with correct templates
- Seeds ACF field content and sample CPT entries (projects, team members, stat items, etc.)
- Optionally bulk-translates all content if
OPENAI_API_KEYis set in.env
The script is idempotent — if WordPress is already installed, it skips everything.
Manual setup (without Docker): If installing WordPress natively (e.g. via Plesk), you need to:
-
Install and activate required plugins:
- WPGraphQL
- Advanced Custom Fields (ACF)
- WPGraphQL for ACF (download from GitHub releases)
- Polylang
- WPGraphQL Polylang (download from GitHub releases)
-
Activate the headless theme: copy
wordpress/themes/cdcf-headless/intowp-content/themes/and activate CDCF Headless in Appearance > Themes -
Configure Polylang languages: go to Languages > Settings and add: English (default), Italian, Spanish, French, Portuguese, German
-
Create pages with templates:
- Create pages for Home, About, Projects, Community, Blog, Contact
- Assign the corresponding page template to each (e.g. Home page → "Home" template)
- Fill in the ACF fields (hero section, CTA, etc.) that appear for each template
-
Create content:
- Add projects, team members, sponsors, community channels, and stat items as CPT entries
- Link them to pages via the relationship fields in each page template's ACF group
npm run build
npm startcdcf-website/
├── app/
│ ├── [lang]/ # i18n dynamic segment
│ │ ├── layout.tsx # Root layout with providers
│ │ └── [[...slug]]/ # Catch-all page renderer
│ │ └── page.tsx
│ └── api/
│ ├── preview/route.ts # Draft mode endpoint for WP previews
│ └── revalidate/route.ts # On-demand ISR webhook
├── components/
│ ├── Header.tsx # Site header with nav + language switcher
│ ├── Footer.tsx # Multi-column footer
│ ├── Logo.tsx # SVG logo wrapper
│ ├── LanguageSwitcher.tsx # Locale dropdown
│ └── sections/ # Page section components
│ ├── PageRenderer.tsx # Template-based section orchestrator
│ ├── HeroBanner.tsx # Full-width hero section
│ ├── TextSection.tsx # Text block with heading + body
│ ├── RichContent.tsx # Two-column text + image layout
│ ├── CallToAction.tsx # CTA banner / card / inline
│ ├── StatsBar.tsx # Statistics counter row
│ ├── ProjectGrid.tsx # Project card grid
│ ├── CommunitySection.tsx # Community channel cards
│ ├── GovernanceSection.tsx # Team member grid
│ ├── BlogFeed.tsx # Blog post listing
│ └── SponsorGrid.tsx # Sponsor logos grid
├── lib/
│ └── wordpress/
│ ├── client.ts # wpQuery() GraphQL fetch wrapper
│ ├── queries.ts # GraphQL query strings
│ ├── types.ts # TypeScript interfaces for WP data
│ └── api.ts # Typed API functions (getPage, getPosts, etc.)
├── src/
│ └── i18n/
│ ├── routing.ts # Locale list + routing config
│ ├── request.ts # Per-request message loading
│ └── navigation.ts # Typed navigation helpers
├── messages/ # UI translation strings (managed via Weblate)
│ ├── en.json # English (source)
│ ├── it.json # Italian
│ ├── es.json # Spanish
│ ├── fr.json # French
│ ├── pt.json # Portuguese
│ └── de.json # German
├── css/
│ └── globals.css # Tailwind imports + brand utilities
├── public/
│ └── logo.svg # CDCF globe/cross logo
├── wordpress/
│ └── themes/
│ └── cdcf-headless/ # Headless WordPress theme
│ ├── style.css # Theme metadata
│ ├── index.php # Redirect to Next.js frontend
│ └── functions.php # CPTs, ACF fields, Polylang, CORS, preview
├── nginx/
│ └── default.conf # Nginx reverse proxy (Next.js + WordPress)
├── .github/
│ └── workflows/
│ └── deploy.yml # CI/CD pipeline
├── Dockerfile # Multi-stage Next.js Docker build
├── docker-compose.yml # WordPress + MariaDB + Next.js + Nginx
├── tailwind.config.ts # Brand colors + fonts
├── next.config.ts # Next.js configuration
└── package.json
- WordPress manages all CMS content — pages, posts, projects, team members, sponsors, etc.
- ACF field groups are registered programmatically in
functions.phpand provide structured fields for each page template (hero section, CTA, relationships to CPTs). - WPGraphQL exposes all content (including ACF fields and Polylang translations) via a
/graphqlendpoint. - Next.js fetches content from the GraphQL API at build/request time using the
lib/wordpress/client library. - PageRenderer maps page templates to fixed section layouts — each template renders its sections in a predetermined order using data from ACF fields and related CPTs.
- In development, Nginx routes requests on a single
localhostdomain: WordPress paths (/wp-admin,/graphql,/wp-content) go to WordPress; everything else goes to Next.js. In production, WordPress and Next.js run on separate subdomains managed by Plesk.
| Page Template | Sections (fixed order) |
|---|---|
| Home | Hero, Stats, Featured Projects, Sponsors, CTA |
| About | Hero, Content, Team/Governance, CTA |
| Projects | Hero, Project Grid, CTA |
| Community | Hero, Channels, Team/Governance, CTA |
| Blog | Hero, Blog Feed |
| Contact | Hero, Content, CTA |
| CPT | Purpose | Key ACF Fields |
|---|---|---|
project |
Foundation projects | status, repoUrl, projectUrl, license, category |
team_member |
Team/governance members | role, title, linkedinUrl, githubUrl |
sponsor |
Sponsors and partners | tier, sponsorUrl |
community_channel |
Community platforms | icon, channelUrl, description |
stat_item |
Statistics counters | icon, number, label |
- Navigate to
/wp-adminand log in with your WordPress credentials - Edit pages: Go to Pages, select a page, and fill in the ACF fields (hero, CTA, relationships)
- Create projects: Go to Projects > Add New, fill in title, description, featured image, and ACF fields (status, repo URL, etc.)
- Manage team: Go to Team Members > Add New, fill in name, bio, photo, and role/social links
- Publish: Save/publish in WordPress. Changes appear on the frontend after ISR revalidation (default: 60 seconds) or immediately via the revalidation webhook.
- Install and configure Polylang in WordPress
- When editing any page or CPT entry, use the Polylang language meta box to create translations
- Each translation is a separate WordPress post linked to the original
- The Next.js frontend automatically fetches the correct translation based on the URL locale
This project uses a dual i18n system:
- Source files:
messages/*.json - Source language: English (
messages/en.json) - Workflow:
- Developers modify
messages/en.jsonand push tomain - Weblate watches the repo and picks up new/changed strings
- Translators translate via the Weblate web UI
- Weblate pushes translations to the
l10n-weblatebranch - PR from
l10n-weblate→mainfor review - Merge triggers rebuild and deploy
- Developers modify
- Content translations are managed in WordPress using Polylang
- Each page/post/CPT can have independent translations per locale
- Translations are fetched at render time via WPGraphQL Polylang based on the URL locale
WordPress is configured to redirect preview links to the Next.js draft mode endpoint:
GET /api/preview?secret=YOUR_SECRET&slug=about&type=page
This enables Next.js draft mode, which fetches the latest revision from WordPress (bypassing ISR cache).
When content is published in WordPress, a webhook can trigger immediate cache invalidation:
curl -X POST http://localhost:3000/api/revalidate \
-H "Content-Type: application/json" \
-d '{"secret": "YOUR_SECRET", "path": "/about"}'You can set this up as a WordPress publish hook (e.g. via the WP Webhooks plugin or a custom save_post action).
- Add the locale code to
src/i18n/routing.tsin thelocalesarray - Create
messages/<locale>.json(copy frommessages/en.json) - Add the locale label in
components/LanguageSwitcher.tsx→localeLabels - Add the locale mapping in
lib/wordpress/api.ts→LOCALE_MAP - Add the language in WordPress via Polylang settings
- Configure the new language in Weblate for UI string translation
Production and staging both run natively on the same Plesk-managed server (no Docker) on three subdomains:
catholicdigitalcommons.org— production Next.js frontend (standalone build, Node.js)staging.catholicdigitalcommons.org— staging Next.js frontend (separate standalone build, same Node.js)cms.catholicdigitalcommons.org— WordPress admin backend (PHP-FPM managed by Plesk)
Staging shares the production WordPress backend. That keeps the staging environment lightweight (no second WP install or DB), but it also means staging-only theme/plugin testing isn't possible — both environments see the same CMS code at any moment. The deploy workflow only ships the Next.js bundle to staging for that reason.
proxy.ts sets X-Robots-Tag: noindex, nofollow on every response from any host other than catholicdigitalcommons.org / www.catholicdigitalcommons.org, so staging (and any preview / one-off subdomain) is never indexed by search engines.
The Next.js apps fetch content from WordPress via WP_GRAPHQL_URL=https://cms.catholicdigitalcommons.org/graphql. The WordPress theme's CORS headers (registered in functions.php) allow cross-origin GraphQL requests from both the production and staging frontends.
Per-environment runtime env vars (WP_GRAPHQL_URL, WP_PREVIEW_SECRET, etc.) are configured in the Plesk panel for each Node.js app, not in .env.local files on disk. NEXT_PUBLIC_SITE_URL is per-environment too but is baked into the client bundle at build time (different value per workflow run, see below).
The deploy workflow (.github/workflows/deploy.yml) is unified for both environments:
release: published— automatically deploys to productionworkflow_dispatch— user picksproductionorstaging(default:staging)
Production-only steps (WP theme + plugin tarballs, plugin activation) are gated behind an environment check. Staging deploys ship only the Next.js bundle so they can't overwrite production WordPress code.
Triggering deploys from the command line (no UI dropdown needed):
# Deploy to staging (matches the dropdown default)
gh workflow run deploy.yml --ref main -f environment=staging
# Deploy to production
gh workflow run deploy.yml --ref main -f environment=production
# No -f → uses the workflow default (staging)
gh workflow run deploy.yml --ref mainA separate workflow (.github/workflows/pr-build.yml) runs next build on every pull request as a required check, so build-only failures (file-name conflicts, missing imports, type errors) are caught pre-merge instead of at deploy time.
Required GitHub repository configuration:
Secrets (encrypted, used as credentials):
| Secret | Description |
|---|---|
VPS_HOST |
VPS IP address or hostname |
VPS_USERNAME |
SSH username |
VPS_SSH_KEY |
SSH private key for deployment |
WP_APP_USERNAME |
WordPress application-password username (for plugin activation) |
WP_APP_PASSWORD |
WordPress application password |
Repository-scoped variables (plain config, visible in workflow logs, shared across all jobs and environments):
| Variable | Description |
|---|---|
WP_GRAPHQL_URL |
WordPress GraphQL endpoint (e.g. https://cms.catholicdigitalcommons.org/graphql) |
WP_REST_URL |
WordPress REST root (e.g. https://cms.catholicdigitalcommons.org/wp-json) |
WP_THEME_DIR |
WordPress theme directory (e.g. /var/www/vhosts/.../wp-content/themes) |
WP_PLUGINS_DIR |
WordPress plugins directory (e.g. /var/www/vhosts/.../wp-content/plugins) |
Environment-scoped variables (defined per GitHub Actions Environment under Settings → Environments → production / staging; resolved automatically by the deploy job's environment: declaration):
NEXT_PUBLIC_SITE_URL— public URL of the site for this Environment (e.g.https://catholicdigitalcommons.orgonproduction,https://staging.catholicdigitalcommons.orgonstaging). Baked into the client bundle at build time, so each Environment's value is captured in its own deploy artifact.APP_DIR— Next.js app directory on the VPS for this Environment (e.g./var/www/vhosts/.../httpdocsonproduction,/var/www/vhosts/.../staging.catholicdigitalcommons.orgonstaging). Targets thetar -xzfextract step + the Plesk Passengertmp/restart.txttouch that completes the deploy.
Deprecated: the repo-level
NEXT_PUBLIC_SITE_URL_PROD,NEXT_PUBLIC_SITE_URL_STAGING,VPS_APP_DIR, andVPS_STAGING_APP_DIRvariables are no longer read by the workflow —NEXT_PUBLIC_SITE_URLandAPP_DIRon the matching GH Actions Environment supersede them. Remove the suffixed/prefixed entries from Settings → Variables → Repository once a deploy against each Environment has succeeded.
Docker Compose is used for local development to run the full stack:
# Build and run all services
docker compose up --build -d
# View logs
docker compose logs -f
# Stop all services
docker compose downData is persisted in Docker named volumes (db_data for MariaDB, wordpress_data for WordPress uploads/plugins).
- Fork the repository
- Create a feature branch (
git checkout -b feature/my-feature) - Make your changes
- Test locally with
npm run devandnpm run build - Submit a pull request
All rights reserved. Copyright Catholic Digital Commons Foundation.