Server Architecture

Multi-tenant Drupal 11 deployment on a single EC2 instance with a Next.js control plane and GitHub Actions CI/CD.

Overview

This server hosts two independent Drupal 11 sites sharing a single codebase (multisite) but operating against separate MySQL databases. A Next.js application serves as the control panel for deployment management, and a PHP webhook endpoint handles GitHub Actions triggers with HMAC-SHA256 signature verification.

ComponentVersionDetails
OSUbuntu (6.17.0-aws)AWS EC2, IP 18.117.142.144
Web ServerApache 2.4.58mod_php, mod_rewrite, mod_proxy, mod_ssl
PHP8.3.6mod_php (not FPM), OPcache enabled
Drupal11.3.2Multisite, Composer-managed, Drush 13.7
Node.jsv20.20.0npm 10.8.2
Next.js14.2.xReact 18, standalone output
PM26.0.14Process manager for Next.js, systemd-integrated
DatabaseMySQL/MariaDBlocalhost:3306, two databases
SSLLet's EncryptCertbot, auto-renew via systemd timer
DomainBackendDatabase
gasandelectric-utility.ai-poc.appDrupal (site dir: gasandelectric)drupal
gas-utility.ai-poc.appDrupal (site dir: gasutility)drupal_gas_utility
control.ai-poc.appNext.js (port 3001) + deploy.phpN/A

Request Flow

Drupal Site Request

Client → DNS (18.117.142.144)
  → Apache :443 (VirtualHost match on ServerName)
    → DocumentRoot /var/www/drupal/web
      → .htaccess (RewriteEngine, route to index.php)
        → mod_php 8.3
          → Drupal bootstrap
            → sites.php (domain → site directory mapping)
              → sites/{site_dir}/settings.php (DB credentials, hash_salt)
                → MySQL (localhost:3306, database per site)
                  → Rendered HTML response

Control Panel Request

Client → DNS (18.117.142.144)
  → Apache :443 (VirtualHost: control.ai-poc.app)
    → IF path == /deploy.php:
        → mod_php executes /home/ubuntu/deploy.php
          → HMAC-SHA256 signature check
            → shell_exec() → drupal-pull-and-import.sh (background)
    → ELSE:
        → mod_proxy → ProxyPass http://127.0.0.1:3001/
          → Next.js (PM2-managed)
            → React SSR/CSR response

Deployment Trigger (GitHub Push)

Developer pushes to main
  → GitHub Actions (deploy.yml)
    → Builds JSON payload: {"source":"github-actions","ref":"refs/heads/main"}
    → Generates HMAC-SHA256 with DEPLOY_WEBHOOK_SECRET
    → POST https://control.ai-poc.app/deploy.php
      → Header: X-Hub-Signature-256: sha256=<hash>
        → deploy.php verifies signature (constant-time comparison)
          → Logs ACCEPTED/REJECTED to /var/log/deploy-webhook.log
            → Executes drupal-pull-and-import.sh (nohup, background)
              → stdout/stderr → /var/log/deploy-output.log

Apache Configuration

Apache handles all inbound traffic. Six virtual host files define the routing for three domains (HTTP redirect + HTTPS each).

Virtual Host Files

FilePortPurpose
drupal.conf80HTTP→HTTPS redirect for gasandelectric-utility
drupal-le-ssl.conf443Drupal HTTPS (both Drupal domains via ServerAlias)
gas-utility.conf80HTTP→HTTPS redirect for gas-utility
gas-utility-le-ssl.conf443HTTPS for gas-utility (ServerName match)
control-plane.conf80HTTP→HTTPS redirect for control panel
control-plane-le-ssl.conf443HTTPS: proxy to Next.js + passthrough for deploy.php

Key Modules

mod_php8.3 (embedded PHP), mod_rewrite (Drupal routing), mod_proxy + mod_proxy_http (Next.js reverse proxy), mod_ssl (TLS termination), mod_deflate (compression), mod_headers (security headers).

Control Panel VHost (HTTPS)

The control panel VHost has a special carve-out: requests to /deploy.php are handled directly by mod_php (not proxied), so the webhook endpoint runs as a PHP script at /home/ubuntu/deploy.php. Everything else is reverse-proxied to 127.0.0.1:3001.

# Simplified control-plane-le-ssl.conf logic:
<VirtualHost *:443>
    ServerName control.ai-poc.app

    # Webhook endpoint — handled by PHP directly
    Alias /deploy.php /home/ubuntu/deploy.php
    <Location /deploy.php>
        SetHandler application/x-httpd-php
    </Location>

    # Everything else → Next.js
    ProxyPreserveHost On
    ProxyPass /deploy.php !
    ProxyPass / http://127.0.0.1:3001/
    ProxyPassReverse / http://127.0.0.1:3001/

    SSLCertificateFile /etc/letsencrypt/live/control.ai-poc.app/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/control.ai-poc.app/privkey.pem
</VirtualHost>

Drupal Multisite

Both sites share a single Drupal 11 codebase at /var/www/drupal/. Multisite routing is handled by web/sites/sites.php, which maps the incoming HTTP Host header to a site directory under web/sites/.

Directory Layout

/var/www/drupal/
├── composer.json          # Drupal + module dependencies
├── composer.lock
├── vendor/                # Composer packages (incl. Drush)
├── db/                    # Database dumps for deployment
│   ├── gasandelectric.sql.gz    # → imported into 'drupal' DB
│   ├── gasutility.sql.gz        # → imported into 'drupal_gas_utility' DB
│   └── db.sql.gz                # → shared fallback (if site-specific missing)
├── web/                   # DocumentRoot
│   ├── index.php          # Drupal front controller
│   ├── .htaccess          # Apache rewrite rules, security blocks
│   └── sites/
│       ├── sites.php      # Domain → directory mapping
│       ├── default/       # Fallback site
│       │   └── settings.php
│       ├── gasandelectric/        # Gas & Electric site
│       │   ├── settings.php       # DB: 'drupal', hash_salt, trusted_host
│       │   └── files/             # User uploads
│       └── gasutility/            # Gas Utility site
│           ├── settings.php       # DB: 'drupal_gas_utility'
│           └── files/
└── .github/
    └── workflows/
        └── deploy.yml     # GitHub Actions CI/CD

sites.php

<?php
$sites['gasandelectric-utility.ai-poc.app'] = 'gasandelectric';
$sites['gas-utility.ai-poc.app'] = 'gasutility';

Site settings.php (Key Fields)

Each site's settings.php contains:

  • $databases['default']['default'] — MySQL connection (host, port, database name, credentials)
  • $settings['hash_salt'] — unique per site, generated by openssl rand -hex 32
  • $settings['trusted_host_patterns'] — regex for allowed Host headers
  • $settings['file_scan_ignore_directories']node_modules, bower_components

Both sites use the same MySQL user (drupal@localhost) but connect to different databases.

Drush

Drush 13.7 is installed via Composer at vendor/drush/drush/drush.php. Site-specific commands use the --uri flag:

# Clear cache for Gas & Electric site
sudo -u www-data php vendor/drush/drush/drush.php --uri=gasandelectric cache:rebuild

# Export database for Gas Utility site
sudo -u www-data php vendor/drush/drush/drush.php --uri=gasutility sql:dump --gzip > db/gasutility.sql.gz

Control Panel (Next.js)

A Next.js 14 application at /var/www/control-panel/ provides a web dashboard for triggering deployments and viewing logs. It runs on port 3001, reverse-proxied by Apache.

Process Management

PM2 6.0.14 manages the Next.js process. PM2 is integrated with systemd via pm2-ubuntu.service, which calls pm2 resurrect on boot to restore the process list.

# PM2 process
Name: control-panel
Script: npm start → next start -p 3001
User: ubuntu
Logs: ~/.pm2/logs/control-panel-{out,error}.log

# systemd unit
/etc/systemd/system/pm2-ubuntu.service
Type: forking
ExecStart: pm2 resurrect

API Routes

MethodPathDescription
GET/api/logs?type=webhookReturns last 100 lines of /var/log/deploy-webhook.log
GET/api/logs?type=deployReturns last 100 lines of /var/log/deploy-output.log
POST/api/deployWrites ACCEPTED to webhook log, executes deploy script via exec()

The manual deploy endpoint (POST /api/deploy) runs the same drupal-pull-and-import.sh script as the webhook. It does not require HMAC verification — it's a trusted internal endpoint behind Apache.

CI/CD & Webhook Pipeline

GitHub Actions Workflow

Located at .github/workflows/deploy.yml in the Drupal repo. Triggers on push to main and on manual workflow_dispatch.

# Simplified deploy.yml
on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Trigger deployment webhook
        run: |
          PAYLOAD='{"source":"github-actions","ref":"$GITHUB_REF"}'
          SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)
          curl -X POST https://control.ai-poc.app/deploy.php \
            -H "X-Hub-Signature-256: sha256=$SIGNATURE" \
            -H "Content-Type: application/json" \
            -d "$PAYLOAD"

Webhook Endpoint (deploy.php)

A standalone PHP file at /home/ubuntu/deploy.php, aliased by Apache to /deploy.php on the control panel domain. It:

  1. Rejects non-POST requests (405)
  2. Reads the raw request body
  3. Extracts the signature from X-Hub-Signature-256 or X-Webhook-Signature
  4. Computes sha256=HMAC(body, secret) and compares using hash_equals() (constant-time)
  5. Logs ACCEPTED or REJECTED to /var/log/deploy-webhook.log
  6. If accepted, runs the deploy script via nohup sudo -u ubuntu ... &

Deploy Script (drupal-pull-and-import.sh)

Located at /home/ubuntu/drupal-pull-and-import.sh. Runs as the ubuntu user with sudo for privileged operations.

StepActionDetails
1Git pullchown ubuntu → git fetch + reset --hard origin/main → chown www-data
2Multisite configRecreates sites.php if missing (git reset may remove it)
3DB configGenerates settings.php with DB credentials if missing
4Hash saltGenerates hash_salt if empty
5DB importPer-site dumps first (db/gasandelectric.sql.gz, db/gasutility.sql.gz), shared fallback (db/db.sql.gz), or skip
6Cache cleardrush cache:rebuild for each site

Note: Step 1 uses git reset --hard origin/main, which discards any local changes on the server. This is intentional — the server is not a development environment. The source of truth is the Git remote.

Database

MySQL/MariaDB runs locally on 127.0.0.1:3306. Not exposed to the network.

DatabaseUsed ByDrupal Site Dir
drupalgasandelectric-utility.ai-poc.appgasandelectric
drupal_gas_utilitygas-utility.ai-poc.appgasutility

Both databases are accessed by the shared drupal@localhost MySQL user. Credentials are stored in each site's settings.php (base64-encoded password). The password was auto-generated during initial setup.

Database Import During Deployment

The deploy script checks /var/www/drupal/db/ for gzipped SQL dumps. Priority order:

  1. db/{site_dir}.sql.gz — site-specific dump (preferred)
  2. db/db.sql.gz — shared fallback
  3. No dump found — import step skipped entirely

Import command: zcat dump.sql.gz | sudo mysql {db_name}

SSL/TLS

All three domains have individual Let's Encrypt certificates managed by Certbot. HTTP (port 80) is unconditionally redirected to HTTPS (port 443) via Apache RewriteRule.

Certificate Paths

/etc/letsencrypt/live/gasandelectric-utility.ai-poc.app/
  fullchain.pem   privkey.pem

/etc/letsencrypt/live/gas-utility.ai-poc.app/
  fullchain.pem   privkey.pem

/etc/letsencrypt/live/control.ai-poc.app/
  fullchain.pem   privkey.pem

Renewal is handled by the certbot.timer systemd unit (runs twice daily). Apache reloads automatically via Certbot's deploy hook.

Filesystem & Permissions

PathOwnerPurpose
/var/www/drupal/www-data:www-dataDrupal codebase + web root
/var/www/drupal/web/sites/*/files/www-data:www-dataUser uploads (writable by web server)
/var/www/control-panel/ubuntu:ubuntuNext.js control panel
/home/ubuntu/deploy.phpubuntu:ubuntuWebhook endpoint (executed by www-data via mod_php)
/home/ubuntu/drupal-pull-and-import.shubuntu:ubuntuDeploy script (runs as ubuntu via sudo)
/var/log/deploy-webhook.logwww-dataWebhook event log (0666)
/var/log/deploy-output.logubuntuDeploy script stdout/stderr

Ownership swap during deploy: The deploy script temporarily chowns /var/www/drupal/ to ubuntu:ubuntu for the git pull (SSH keys are on the ubuntu user), then restores to www-data:www-data afterward. This is the only window where the Drupal directory is not owned by the web server user.

Gaps & Recommendations

This section documents known limitations of the current architecture. These are expected for a POC — flagged here for awareness, not as blocking issues.

AreaCurrent StateRecommendation
FirewallUFW inactive, iptables default ACCEPTRelies entirely on AWS Security Groups. Consider enabling UFW as defense-in-depth.
Control Panel AuthNo authenticationAdd basic auth or IP allowlist. Anyone with the URL can trigger deploys.
Database BackupsNone automatedAdd a cron job or systemd timer for mysqldump to S3.
Cron / Drupal SchedulerNo Drupal cron configuredAdd a system cron hitting /cron/<key> for each site, or use Drush.
MonitoringPM2 process restart onlyConsider CloudWatch, UptimeRobot, or similar for HTTP health checks.
Horizontal ScalingSingle instanceNot needed for POC. For production: ALB + ASG, RDS, EFS for shared files.
PHP Runtimemod_php (embedded)PHP-FPM would allow independent process management, better resource isolation.
Log RotationNo explicit logrotate configAdd logrotate rules for deploy logs to prevent unbounded growth.
Webhook SecretHardcoded in deploy.phpMove to environment variable or /etc config file.