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.
| Component | Version | Details |
|---|---|---|
| OS | Ubuntu (6.17.0-aws) | AWS EC2, IP 18.117.142.144 |
| Web Server | Apache 2.4.58 | mod_php, mod_rewrite, mod_proxy, mod_ssl |
| PHP | 8.3.6 | mod_php (not FPM), OPcache enabled |
| Drupal | 11.3.2 | Multisite, Composer-managed, Drush 13.7 |
| Node.js | v20.20.0 | npm 10.8.2 |
| Next.js | 14.2.x | React 18, standalone output |
| PM2 | 6.0.14 | Process manager for Next.js, systemd-integrated |
| Database | MySQL/MariaDB | localhost:3306, two databases |
| SSL | Let's Encrypt | Certbot, auto-renew via systemd timer |
| Domain | Backend | Database |
|---|---|---|
gasandelectric-utility.ai-poc.app | Drupal (site dir: gasandelectric) | drupal |
gas-utility.ai-poc.app | Drupal (site dir: gasutility) | drupal_gas_utility |
control.ai-poc.app | Next.js (port 3001) + deploy.php | N/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 responseControl 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 responseDeployment 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.logApache Configuration
Apache handles all inbound traffic. Six virtual host files define the routing for three domains (HTTP redirect + HTTPS each).
Virtual Host Files
| File | Port | Purpose |
|---|---|---|
drupal.conf | 80 | HTTP→HTTPS redirect for gasandelectric-utility |
drupal-le-ssl.conf | 443 | Drupal HTTPS (both Drupal domains via ServerAlias) |
gas-utility.conf | 80 | HTTP→HTTPS redirect for gas-utility |
gas-utility-le-ssl.conf | 443 | HTTPS for gas-utility (ServerName match) |
control-plane.conf | 80 | HTTP→HTTPS redirect for control panel |
control-plane-le-ssl.conf | 443 | HTTPS: 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/CDsites.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 byopenssl 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 resurrectAPI Routes
| Method | Path | Description |
|---|---|---|
GET | /api/logs?type=webhook | Returns last 100 lines of /var/log/deploy-webhook.log |
GET | /api/logs?type=deploy | Returns last 100 lines of /var/log/deploy-output.log |
POST | /api/deploy | Writes 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:
- Rejects non-POST requests (405)
- Reads the raw request body
- Extracts the signature from
X-Hub-Signature-256orX-Webhook-Signature - Computes
sha256=HMAC(body, secret)and compares usinghash_equals()(constant-time) - Logs ACCEPTED or REJECTED to
/var/log/deploy-webhook.log - 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.
| Step | Action | Details |
|---|---|---|
| 1 | Git pull | chown ubuntu → git fetch + reset --hard origin/main → chown www-data |
| 2 | Multisite config | Recreates sites.php if missing (git reset may remove it) |
| 3 | DB config | Generates settings.php with DB credentials if missing |
| 4 | Hash salt | Generates hash_salt if empty |
| 5 | DB import | Per-site dumps first (db/gasandelectric.sql.gz, db/gasutility.sql.gz), shared fallback (db/db.sql.gz), or skip |
| 6 | Cache clear | drush 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.
| Database | Used By | Drupal Site Dir |
|---|---|---|
drupal | gasandelectric-utility.ai-poc.app | gasandelectric |
drupal_gas_utility | gas-utility.ai-poc.app | gasutility |
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:
db/{site_dir}.sql.gz— site-specific dump (preferred)db/db.sql.gz— shared fallback- 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
| Path | Owner | Purpose |
|---|---|---|
/var/www/drupal/ | www-data:www-data | Drupal codebase + web root |
/var/www/drupal/web/sites/*/files/ | www-data:www-data | User uploads (writable by web server) |
/var/www/control-panel/ | ubuntu:ubuntu | Next.js control panel |
/home/ubuntu/deploy.php | ubuntu:ubuntu | Webhook endpoint (executed by www-data via mod_php) |
/home/ubuntu/drupal-pull-and-import.sh | ubuntu:ubuntu | Deploy script (runs as ubuntu via sudo) |
/var/log/deploy-webhook.log | www-data | Webhook event log (0666) |
/var/log/deploy-output.log | ubuntu | Deploy 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.
| Area | Current State | Recommendation |
|---|---|---|
| Firewall | UFW inactive, iptables default ACCEPT | Relies entirely on AWS Security Groups. Consider enabling UFW as defense-in-depth. |
| Control Panel Auth | No authentication | Add basic auth or IP allowlist. Anyone with the URL can trigger deploys. |
| Database Backups | None automated | Add a cron job or systemd timer for mysqldump to S3. |
| Cron / Drupal Scheduler | No Drupal cron configured | Add a system cron hitting /cron/<key> for each site, or use Drush. |
| Monitoring | PM2 process restart only | Consider CloudWatch, UptimeRobot, or similar for HTTP health checks. |
| Horizontal Scaling | Single instance | Not needed for POC. For production: ALB + ASG, RDS, EFS for shared files. |
| PHP Runtime | mod_php (embedded) | PHP-FPM would allow independent process management, better resource isolation. |
| Log Rotation | No explicit logrotate config | Add logrotate rules for deploy logs to prevent unbounded growth. |
| Webhook Secret | Hardcoded in deploy.php | Move to environment variable or /etc config file. |