open-productive-stack/drupal/README.md
rnsrk f8b8f53d54 Add Drupal headless stack with Next.js frontend
- Add Next.js frontend service (nextjs) with Dockerfile and source
- Update docker-compose.yml: image names, Drupal 11.3.3, nextjs service
- Add docker-compose.override.yml.disabled for dev hot-reload
- Add install-headless-modules.sh for OAuth/JSON:API module setup
- Add README.md with full setup and configuration guide
- Update nginx/Dockerfile and nginx.conf.template for cms. subdomain
- Update drupal/Dockerfile PHP-FPM build args
- Gitignore **/.vscode/ to prevent IDE workspace files from being tracked
2026-03-30 11:14:17 +02:00

13 KiB

Headless Drupal Stack

Drupal 11 backend for Next.js frontend with JSON:API, OAuth authentication, and Redis caching.

Quick Start

cd /var/deploy/drupal
./setup-aliases.sh
source ~/.bash-aliases

This creates convenient shortcuts in ~/.bash-aliases:

  • ddrush - Run Drush commands
  • dcomposer - Run Composer commands
  • dphp - Run PHP commands
  • dshell - Open bash shell in container
  • dlogs - View PHP-FPM logs (follow mode)
  • dredis - Access Redis CLI

2. Start Services

cd /var/deploy
docker compose -f drupal/docker-compose.yml up -d

3. Install Headless Modules

cd /var/deploy/drupal
./install-headless-modules.sh

4. Enable Core Modules

# With alias
ddrush en next jsonapi_extras simple_oauth consumers pathauto cors decoupled_router -y

# Without alias
docker exec drupal-fpm bash -c "cd /opt/drupal && drush en next jsonapi_extras simple_oauth consumers pathauto cors decoupled_router -y"

5. Generate OAuth Keys

docker exec drupal-fpm bash -c 'cd /opt/drupal && mkdir -p oauth/keys && openssl genrsa -out oauth/keys/private.key 2048 && openssl rsa -in oauth/keys/private.key -pubout -out oauth/keys/public.key && chown www-data:www-data oauth/keys/private.key oauth/keys/public.key && chmod 640 oauth/keys/private.key oauth/keys/public.key'

(PHP-FPM runs as www-data; keys must be readable by it.)

Add to drupal/root/web/sites/default/settings.php:

$settings['simple_oauth.keys'] = [
  'public' => '/opt/drupal/oauth/keys/public.key',
  'private' => '/opt/drupal/oauth/keys/private.key',
];

6. Configure Redis Cache

Add to drupal/root/web/sites/default/settings.php:

// Redis Configuration.
$settings['redis.connection']['interface'] = 'PhpRedis';
$settings['redis.connection']['host'] = 'drupal-redis';
$settings['redis.connection']['port'] = 6379;
$settings['cache']['default'] = 'cache.backend.redis';
$settings['cache']['bins']['bootstrap'] = 'cache.backend.chainedfast';
$settings['cache']['bins']['discovery'] = 'cache.backend.chainedfast';
$settings['cache']['bins']['config'] = 'cache.backend.chainedfast';

Stack Components

Containers

  • drupal-reverse-proxy: NGINX reverse proxy (port 80)
  • drupal-fpm: PHP 8.4 FPM with Drupal 11
  • drupal-redis: Redis 7 cache backend

Key Modules

  • drupal/next - Next.js integration
  • drupal/jsonapi_extras - JSON:API enhancements
  • drupal/simple_oauth - OAuth 2.0 authentication
  • drupal/pathauto - URL aliases
  • drupal/cors - CORS headers
  • drupal/decoupled_router - Route resolution

Common Commands

After running ./setup-aliases.sh:

# Composer commands
dcomposer require drupal/module_name
dcomposer update
dcomposer show

# Drush commands
ddrush cr                    # Clear cache
ddrush en module_name -y     # Enable module
ddrush updb -y               # Database updates
ddrush cex -y                # Export configuration
ddrush cim -y                # Import configuration
ddrush status                # Site status

# PHP commands
dphp -v                      # PHP version
dphp -m                      # PHP modules
dphp -i                      # PHP info

# Container access
dshell                       # Open bash shell
dlogs                        # View logs
dredis ping                  # Test Redis

Without Aliases (Full Commands)

Composer

# Install module
docker exec drupal-fpm bash -c "cd /opt/drupal && composer require drupal/module_name"

# Update dependencies
docker exec drupal-fpm bash -c "cd /opt/drupal && composer update"

# Show installed packages
docker exec drupal-fpm bash -c "cd /opt/drupal && composer show"

Drush

# Clear cache
docker exec drupal-fpm bash -c "cd /opt/drupal && drush cr"

# Enable module
docker exec drupal-fpm bash -c "cd /opt/drupal && drush en module_name -y"

# Database updates
docker exec drupal-fpm bash -c "cd /opt/drupal && drush updb -y"

# Export configuration
docker exec drupal-fpm bash -c "cd /opt/drupal && drush cex -y"

# Import configuration
docker exec drupal-fpm bash -c "cd /opt/drupal && drush cim -y"

# Site status
docker exec drupal-fpm bash -c "cd /opt/drupal && drush status"

Container Management

# View logs
docker logs drupal-fpm
docker logs drupal-reverse-proxy

# Access container shell
docker exec -it drupal-fpm bash

# Restart services
docker compose -f drupal/docker-compose.yml restart

# Rebuild container
docker compose -f drupal/docker-compose.yml up -d --build

Redis

# With alias
dredis ping                  # Test connection
dredis monitor               # Monitor Redis
dredis keys '*'              # Check keys
dredis flushall              # Clear cache

# Without alias
docker exec drupal-redis redis-cli ping
docker exec drupal-redis redis-cli monitor
docker exec drupal-redis redis-cli keys '*'
docker exec drupal-redis redis-cli flushall

Configuration

Next.js Site Configuration

  1. Navigate to /admin/config/services/next
  2. Add Next.js site:
    • Label: Production Frontend
    • Base URL: https://nasarek.dev
    • Preview URL: https://nasarek.dev/api/preview
    • Preview Secret: Generate secure random string
  3. The env page (/admin/config/services/next/sites/{site_id}/env) shows a template; DRUPAL_CLIENT_ID and DRUPAL_CLIENT_SECRET will say "Retrieve this from /admin/config/services/consumer" — get the actual Client ID and secret from the OAuth consumer (see OAuth Consumer section below).

Preview secret expired: If you see "The provided secret has expired", increase the secret lifetime (default is 30 seconds):

  • Via UI: /admin/config/services/next/settings → Preview URL generator → Simple OAuth → Secret expiration time → set to 300 (5 min) or 3600 (1 h)
  • Via Drush: ddrush cset next.settings preview_url_generator_configuration.secret_expiration 300 -y

CORS Configuration

  1. Navigate to /admin/config/services/cors
  2. Add configuration:
    • Enabled: ✓
    • Domains: https://nasarek.dev|*
    • Methods: GET|POST|OPTIONS
    • Headers: Authorization|Content-Type|*
    • Credentials: ✓

OAuth Consumer

1. Create a dynamic scope (required; Drupal rejects token requests without a valid scope):

  • Navigate to /admin/config/people/simple_oauth/oauth2_scope/dynamic/add
  • Role: Authenticated user (or a dedicated role)
  • Granularity: Role
  • Grant Types: Enable Client credentials
  • Description: e.g. Next.js Frontend
  • Machine-readable name: e.g. nextjs_frontend
  • Save

2. Create or edit a consumer:

  • Navigate to /admin/config/services/consumer (or add at /admin/config/services/consumer/add)
  • Label: e.g. Next.js Frontend (or use existing default_consumer)
  • Client ID: Use the Client ID column value from the consumer list (e.g. lcXmAvaCtkjDhgKsLeAfUucOibVwUxKXtla8b_4CC4w), not the UUID or label
  • Secret: Not shown in the list; edit the consumer to generate or regenerate it
  • Grant Types: Enable Client credentials
  • Scopes: Select the scope created in step 1 (e.g. nextjs_frontend)
  • Redirect URI: https://nasarek.dev
  • Is 3rd party?: Leave unchecked (Next.js is your own frontend)
  • Save

3. Add to nextjs/.env.local:

DRUPAL_CLIENT_ID=<Client ID from the consumer list>
DRUPAL_CLIENT_SECRET=<Secret from the consumer edit form>
DRUPAL_OAUTH_SCOPE=<machine name from step 1, e.g. nextjs_frontend>

4. Recreate Next.js container (env vars are read at container start):

cd /var/deploy/drupal && docker compose -f docker-compose.yml up -d nextjs --force-recreate

Verify OAuth:

cd /var/deploy/drupal && docker compose -f docker-compose.yml exec nextjs node -e "
const b=process.env.NEXT_PUBLIC_DRUPAL_BASE_URL,i=process.env.DRUPAL_CLIENT_ID,s=process.env.DRUPAL_CLIENT_SECRET,scope=process.env.DRUPAL_OAUTH_SCOPE;
const body=new URLSearchParams({grant_type:'client_credentials',client_id:i,client_secret:s});
if(scope) body.set('scope',scope);
fetch(b+'/oauth/token',{method:'POST',body}).then(r=>r.json()).then(j=>console.log(j.access_token?'OK':JSON.stringify(j)));
"

Next.js Environment Variables

Required in nextjs/.env.local (loaded via docker-compose env_file):

Variable Required Description
NEXT_PUBLIC_DRUPAL_BASE_URL Yes Drupal JSON:API base URL (e.g. https://cms.nasarek.dev)
DRUPAL_CLIENT_ID Yes* OAuth consumer Client ID (from consumer list, not UUID)
DRUPAL_CLIENT_SECRET Yes* OAuth consumer secret from edit form
DRUPAL_OAUTH_SCOPE Yes* OAuth scope machine name (e.g. nextjs_frontend)
DRUPAL_PREVIEW_SECRET No For draft/preview mode webhook
DRUPAL_REVALIDATE_SECRET No For on-demand revalidation webhook

* Without OAuth credentials, JSON:API requests run as anonymous; protected content will return "Access denied". Use --force-recreate after changing env vars.

JSON:API Configuration

  1. Navigate to /admin/config/services/jsonapi/extras
  2. Configure resource types:
    • Disable unwanted resources
    • Rename fields for frontend convenience
    • Configure includes and sparse fieldsets

JSON:API Endpoints

Examples

# Get all articles
curl https://cms.nasarek.dev/jsonapi/node/article

# Get specific article
curl https://cms.nasarek.dev/jsonapi/node/article/{uuid}

# Filter by status
curl https://cms.nasarek.dev/jsonapi/node/article?filter[status]=1

# Include related entities
curl https://cms.nasarek.dev/jsonapi/node/article?include=field_image,uid

# Sparse fieldsets
curl https://cms.nasarek.dev/jsonapi/node/article?fields[node--article]=title,body,created

# Sort and paginate
curl https://cms.nasarek.dev/jsonapi/node/article?sort=-created&page[limit]=10&page[offset]=0

With Authentication

# Get access token (client credentials - used by next-drupal)
curl -X POST https://cms.nasarek.dev/oauth/token \
  -d "grant_type=client_credentials" \
  -d "client_id=YOUR_CLIENT_UUID" \
  -d "client_secret=YOUR_SECRET"

# Use token
curl https://cms.nasarek.dev/jsonapi/node/article \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Debugging

Enable Xdebug

Xdebug is pre-configured and enabled:

  • Mode: debug
  • Client host: host.docker.internal
  • Client port: 9000
  • IDE key: VSCODE
  • Log: /var/log/xdebug.log

View Logs

# PHP-FPM logs
docker logs drupal-fpm

# NGINX logs
docker logs drupal-reverse-proxy

# Xdebug log
docker exec drupal-fpm tail -f /var/log/xdebug.log

Check PHP Configuration

# PHP version
docker exec drupal-fpm php -v

# PHP modules
docker exec drupal-fpm php -m

# PHP info
docker exec drupal-fpm php -i

# OPcache status
docker exec drupal-fpm php -r "echo json_encode(opcache_get_status(), JSON_PRETTY_PRINT);"

Troubleshooting

Module not found

# Check if module is installed
dcomposer show drupal/module_name

# Install if missing
dcomposer require drupal/module_name

Clear all caches

# Drupal cache
ddrush cr

# Redis cache
docker exec drupal-redis redis-cli flushall

# OPcache (restart PHP-FPM)
docker compose -f drupal/docker-compose.yml restart drupal-fpm

Permissions issues

# Fix file permissions
docker exec drupal-fpm chown -R www-data:www-data /opt/drupal/web/sites/default/files

# Settings.php permissions
docker exec drupal-fpm chmod 444 /opt/drupal/web/sites/default/settings.php

File Structure

drupal/
├── docker-compose.yml          # Service definitions
├── install-headless-modules.sh # Module installation script
├── drupal/
│   ├── Dockerfile             # PHP-FPM image
│   └── root/                  # Drupal codebase (mounted volume)
│       ├── composer.json
│       ├── web/               # Document root
│       │   ├── core/
│       │   ├── modules/
│       │   │   ├── contrib/
│       │   │   └── custom/
│       │   ├── themes/
│       │   └── sites/
│       └── oauth/
│           └── keys/          # OAuth RSA keys
└── nginx/
    ├── Dockerfile
    └── nginx.conf.template    # NGINX configuration

Development Workflow

  1. Make changes to code in drupal/root/
  2. Clear cache: ddrush cr (or docker exec drupal-fpm bash -c "cd /opt/drupal && drush cr")
  3. Export config: ddrush cex -y (or docker exec drupal-fpm bash -c "cd /opt/drupal && drush cex -y")
  4. Commit changes to git
  5. Deploy: Pull changes and run ddrush cim -y on production

Production Checklist

  • Set opcache.validate_timestamps=0 in production
  • Configure Redis cache
  • Generate OAuth keys
  • Configure CORS restrictively
  • Set up content revalidation webhooks
  • Configure rate limiting
  • Enable HTTPS only
  • Set secure session cookies
  • Configure backup strategy
  • Monitor logs and performance
  • Test JSON:API endpoints with different user roles

Resources