This repository builds a container image for deploying sites to Drupal Forge. This provides a safe environment to develop, preview, and share changes.
The deployment image is built on devpanel/php base images (PHP 8.2 and 8.3) and adds the following deployment capabilities:
- Database Import from S3 - Automatically downloads and imports a database dump from an S3 bucket on container startup
- File Proxy Configuration - Retrieves digital assets on-demand from the origin site using:
- Stage File Proxy module (auto-detected and preferred)
- Apache reverse proxy (fallback for sites without the module)
The initialization runs automatically before Apache starts, ensuring the application is ready immediately.
Container Start
↓
Deployment Entrypoint (deployment-entrypoint.sh)
├─→ Bootstrap App (bootstrap-app.sh)
│ ├─→ Initialize Git submodules recursively
│ ├─→ Run composer install (if composer.json exists)
│ ├─→ Create settings.php from default.settings.php (if needed)
│ └─→ Ensure Drupal settings.php includes DevPanel configuration
│
├─→ Database Import (if S3_BUCKET + S3_DATABASE_PATH set)
│ └─→ Download from S3 → MySQL import
│
├─→ File Proxy Setup (if ORIGIN_URL set)
│ ├─→ Detect Stage File Proxy module
│ ├─→ Fall back to Apache proxy if not found
│ └─→ Configure proxy caching for locally-added assets (setup-cache.sh)
│
└─→ Apache Startup (apache-start.sh from base)
├─→ Configure templates with environment variables
├─→ Load custom PHP config (if present)
└─→ Start Apache with rewrite rules (+ optional code-server)
Extends devpanel/php:{8.2,8.3}-base with:
- Application bootstrap script (
bootstrap-app.sh) - Database import script (
import-database.sh) - Proxy configuration script (
setup-proxy.sh) - Deployment entrypoint (
deployment-entrypoint.sh) - Apache proxy configuration template with conditional rewrite rules
The image installs AWS CLI v2 using the official bundled installer (architecture-aware) rather than distro package managers. This avoids Python package post-install instability seen in emulated cross-platform Docker builds while keeping S3 import support consistent.
CMD Inheritance: The deployment image dynamically inherits the CMD from the base image at build time. This is achieved by:
- Extracting the base image's CMD using
docker inspect - Passing it as a
BASE_CMDbuild argument - Setting it as an environment variable in the container
- Using it in the entrypoint when no command is explicitly provided
This ensures compatibility with future base image updates without hardcoding the startup command.
Code is mounted into the container at $APP_ROOT (default: /var/www/html).
DevPanel handles:
- Cloning the repository from Git
- Checking out the specified branch
Deployment image handles (on startup):
- Initializing and updating Git submodules recursively
- Running
composer installifcomposer.jsonexists - Creating Drupal
settings.phpfromdefault.settings.phpif:default.settings.phpdid not exist before bootstrap (likely was added via Git submodules or Composer)default.settings.phpnow exists after bootstrap
- Ensuring Drupal
settings.phpincludes DevPanel configuration (from/usr/local/share/drupalforge/settings.devpanel.php) if the project hasweb/sites/default/settings.php
Permission Handling: The bootstrap script uses non-interactive sudo for settings file operations.
- When
sites/defaultorsettings.phpare read-only, operations are attempted viasudo -nonly - If
sudocredentials are unavailable, the bootstrap step fails with an error - This allows configuration to work when files are owned by a different user or have restricted permissions
A database dump is stored in an S3 bucket and imported on container startup:
- Must be a valid MySQL dump (
.sqlor.sql.gz) - Stored at the path specified by
S3_DATABASE_PATH - Downloaded using AWS credentials (from environment or instance role)
- Automatically skipped if database already has tables
Digital assets are retrieved on-demand from the origin site using one of two methods:
-
Stage File Proxy (preferred if module is installed):
- Drupal module enables transparent file proxy
- No additional configuration needed if installed
-
Apache Reverse Proxy with Conditional Serving (fallback):
- Serves local files if they exist at the requested path
- Proxies to origin only if the file doesn't exist locally
- Allows users to add/modify files locally that are served directly
- Works for any paths specified by
FILE_PROXY_PATHS - Useful for sites without Stage File Proxy module
- Rewrite rules must run in the configured web root directory context (
<Directory "${WEB_ROOT}">) so proxy routing applies to vhost-served requests
| Variable | Description | Example |
|---|---|---|
S3_BUCKET |
S3 bucket name (required for import) | my-deployment-bucket |
S3_DATABASE_PATH |
Path to database dump in S3 (required for import) | dumps/site-prod.sql.gz |
AWS_REGION |
AWS region (optional, default: us-east-1) |
us-east-1 |
AWS_ACCESS_KEY_ID |
AWS access key (optional, uses instance role if not provided) | - |
AWS_SECRET_ACCESS_KEY |
AWS secret key (optional, uses instance role if not provided) | - |
Database Connection Variables: DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME, and DB_DRIVER are required by settings.devpanel.php. DevPanel should provide these automatically on Drupal Forge.
When settings.php includes /usr/local/share/drupalforge/settings.devpanel.php, the image configures Drupal database settings from environment variables and applies safe defaults for DevPanel environments.
hash_saltis derived deterministically from the configured$databases['default']['default']connection values.config_sync_directorydefaults to../config/syncunless already defined.- If
config_sync_directorydoes not exist, bootstrap creates it recursively after ensuringsettings.phpincludessettings.devpanel.php. file_private_pathdefaults to../privateunless already defined.trusted_host_patternsincludesDP_HOSTNAMEwhen provided, otherwise.*.
| Variable | Description | Example |
|---|---|---|
ORIGIN_URL |
Origin site URL for file proxy (required to enable proxy) | https://prod-site.example.com |
FILE_PROXY_PATHS |
Comma-separated paths to proxy (optional, default: /sites/default/files) |
/sites/default/files,/sites/all/themes/custom/assets |
USE_STAGE_FILE_PROXY |
Force Stage File Proxy or Apache proxy (yes/no, optional auto-detect) |
yes |
WEB_ROOT |
Web root path (optional, default: /var/www/html/web) |
/var/www/html/web |
| Variable | Description | Example |
|---|---|---|
APP_ROOT_TIMEOUT |
Seconds to wait for APP_ROOT to be populated before proceeding (optional, default: 300; set to 0 to disable) |
300 |
BOOTSTRAP_REQUIRED |
Exit container if bootstrap fails (yes/no, optional, default: yes) |
yes |
COMPOSER_INSTALL_FLAGS |
Extra flags appended to composer install during bootstrap (optional) |
--ignore-platform-req=php |
When using Apache reverse proxy (fallback when Stage File Proxy not available), requests are handled intelligently based on file existence:
How it works:
- User requests file at a proxied path (e.g.,
/sites/default/files/image.jpg) - Apache checks if the file exists locally
- If it exists: Serves it directly from disk
- If it doesn't exist: Routes to PHP handler for download
- Handler downloads file from origin to its real path
- File is now stored at the expected location under
WEB_ROOT(e.g.,${WEB_ROOT}/sites/default/files/image.jpg) - File is served to the user
- Subsequent requests for the same file:
- Always served from disk (file now exists locally)
- Users can add/modify files locally at any time—local files always take precedence
Result: Origin files are downloaded on first access and saved to their real paths. Users can add new local files anytime. Downloaded files are discoverable and editable in the filesystem.
Drupal Image Styles Support:
The proxy handler has special support for Drupal image styles. When a styled image is requested (e.g., /sites/default/files/styles/thumbnail/public/image.jpg) and doesn't exist locally, the handler automatically retrieves the original file (e.g., /sites/default/files/image.jpg) from the origin server. This allows Drupal to generate the styled version on-demand. The original file is saved to disk for future use, and Drupal can create all necessary image style derivatives from it.
APP_ROOT- Application root directoryPHP_MEMORY_LIMIT- PHP memory limitPHP_MAX_EXECUTION_TIME- PHP maximum execution timeCODES_ENABLE- Enable code-server (yes/no)- And many more PHP configuration options...
docker run \
-e DB_HOST=mysql \
-e DB_USER=drupal \
-e DB_PASSWORD=secret \
-e DB_NAME=drupaldb \
-e S3_BUCKET=my-deployments \
-e S3_DATABASE_PATH=dumps/site.sql.gz \
-e AWS_ACCESS_KEY_ID=AKIA... \
-e AWS_SECRET_ACCESS_KEY=... \
-v /path/to/app:/var/www/html \
-p 80:80 \
drupalforge/deployment:php-8.3Note: When deployed on Drupal Forge, database connection variables (DB_HOST, DB_USER, etc.) are automatically provided by DevPanel.
docker run \
-e DB_HOST=mysql \
-e DB_USER=drupal \
-e DB_PASSWORD=secret \
-e DB_NAME=drupaldb \
-e S3_BUCKET=my-deployments \
-e S3_DATABASE_PATH=dumps/site.sql.gz \
-e ORIGIN_URL=https://prod-site.example.com \
-v /path/to/app:/var/www/html \
-p 80:80 \
drupalforge/deployment:php-8.3docker run \
-e DB_HOST=mysql \
-e DB_USER=drupal \
-e DB_PASSWORD=secret \
-e DB_NAME=drupaldb \
-e S3_BUCKET=my-deployments \
-e S3_DATABASE_PATH=dumps/site.sql.gz \
-e ORIGIN_URL=https://prod-site.example.com \
-e FILE_PROXY_PATHS=/sites/default/files,/modules/contrib/custom_module/assets \
-v /path/to/app:/var/www/html \
-p 80:80 \
drupalforge/deployment:php-8.3docker run \
-e CODES_ENABLE=yes \
-e CODES_AUTH=no \
-e DB_HOST=mysql \
-e DB_USER=drupal \
-e DB_PASSWORD=secret \
-e DB_NAME=drupaldb \
-e S3_BUCKET=my-deployments \
-e S3_DATABASE_PATH=dumps/site.sql.gz \
-v /path/to/app:/var/www/html \
-p 80:80 \
-p 8080:8080 \
drupalforge/deployment:php-8.3When deployed to AWS EC2/ECS with proper IAM role:
docker run \
-e DB_HOST=mysql \
-e DB_USER=drupal \
-e DB_PASSWORD=secret \
-e DB_NAME=drupaldb \
-e S3_BUCKET=my-deployments \
-e S3_DATABASE_PATH=dumps/site.sql.gz \
-v /path/to/app:/var/www/html \
-p 80:80 \
drupalforge/deployment:php-8.3Floating tags updated on every successful merge to main:
drupalforge/deployment:php-8.2- PHP 8.2 (latest build)drupalforge/deployment:php-8.3- PHP 8.3 (latest build)
Immutable tags created for each version release, one per PHP variant:
drupalforge/deployment:1.2.3-php-8.2- version 1.2.3, PHP 8.2drupalforge/deployment:1.2.3-php-8.3- version 1.2.3, PHP 8.3
Build the deployment images for local development:
# Build PHP 8.3 image
docker build --build-arg PHP_VERSION=8.3 -t drupalforge/deployment:php-8.3 .
# Build PHP 8.2 image
docker build --build-arg PHP_VERSION=8.2 -t drupalforge/deployment:php-8.2 .Note: The BASE_CMD is dynamically extracted from the base image in CI/CD workflows. For local builds, the Dockerfile provides a default value that matches the current base image.
The GitHub Actions workflows include several performance optimizations for building Docker images:
- Registry-based caching: Uses Docker Hub registry for build cache instead of GitHub Actions cache, providing better cache reuse across builds
- Aggressive cache mode: Uses
mode=maxfor cache-to to maximize layer caching - Build visibility: Uses
BUILDKIT_PROGRESS=plainfor detailed build output - Multi-platform support:
- QEMU emulation for cross-platform builds
- Docker Buildx Cloud builder for multi-platform builds (automatically enabled when building for multiple platforms or ARM)
- Defaults to
linux/amd64(uses standard buildx for optimal performance) - Easy ARM support: add
linux/arm64to the platform matrix in the workflow file
These optimizations can significantly reduce build times, especially for rebuilds with minimal changes.
To build for ARM architecture when the base image supports it, edit .github/workflows/docker-publish-image.yml:
jobs:
build-and-push:
strategy:
matrix:
platform:
- linux/amd64
- linux/arm64 # Add ARM platformThe cloud builder will automatically activate for ARM builds for better multi-platform build performance.
Parallel Multi-Architecture Builds: When multiple platforms are specified in the matrix, the workflow:
- Builds each architecture in parallel as separate jobs for faster builds
- Each platform job runs independently with its own cache
- Platform-specific images are pushed by digest during the build
- A final merge job creates and pushes a manifest list combining all platforms
- Total build time = max(platform build times) instead of sum of all platform build times
Multi-Architecture Manifests: The workflow automatically creates manifest lists for multi-platform builds:
- Each platform is built and pushed independently
- Platform images are referenced by digest
- Manifest list is created referencing all platform digests
- The manifest list allows Docker to automatically pull the correct image for the host architecture
No manual manifest creation is required. You can verify a multi-arch image with:
docker buildx imagetools inspect drupalforge/deployment:php-8.3- Code Volume: Application code is mounted at
$APP_ROOT - Bootstrap Application: On startup, initialize Git submodules recursively and run composer install if needed
- Database Initialization: Database is imported from S3 (skipped if already populated)
- Proxy Configuration: File proxy configured with conditional rewrite rules (if using Apache proxy)
- Apache Start: Web server starts and application is ready to serve requests
- Code Server: Optional development interface available on port 8080
For contributor and agent workflow policies (including Linux/macOS compatibility expectations), use AGENTS.md as the single source of truth.
This repository includes a comprehensive test suite to validate all components:
# Run all unit tests
bash tests/unit-test.sh
# Run specific test suite
bash tests/test-bootstrap-app.sh
bash tests/test-import-database.sh
bash tests/test-setup-proxy.sh
bash tests/test-proxy-handler.sh
bash tests/test-dockerfile.shEnd-to-end testing with Docker Compose validates the complete deployment flow:
# Run full integration test
bash tests/integration-test.shThis validates:
- Database import from S3 (using MinIO for local testing)
- Application database connectivity
- Git bootstrap and Composer installation
- Drupal install state detection against imported fixture data
- File proxy setup and configuration
- File proxy downloads from origin server
- Downloaded files persist locally
Setup: The integration test automatically:
- Builds the deployment image
- Starts MinIO, MySQL, and a mock origin server
- Runs the deployment container with full initialization
- Executes 15 validation checks
- Cleans up resources
See INTEGRATION_TESTING.md for detailed manual testing instructions.
- Unit Tests: 40+ individual tests covering all scripts and configuration
- Integration Tests: 15 end-to-end validation checks
- CI/CD: Automated tests on every PR and push to main branch
- Tests run automatically on pull requests that are ready for review
- Draft pull requests are skipped (tests don't run)
See .github/workflows/tests.yml for details.
All deployment initialization output is written both to stdout (visible via docker logs) and to /tmp/drupalforge-deployment.log inside the container.
From outside the container (requires Docker access):
# Follow live logs while the container starts
docker logs -f <container>
# View all logs since container start
docker logs <container>From inside the container (e.g. via a web terminal):
cat /tmp/drupalforge-deployment.logThe first lines always include the entrypoint path and startup command:
[DEPLOYMENT] Entrypoint: /usr/local/bin/deployment-entrypoint
[DEPLOYMENT] Deployment initialization complete, executing: sudo -E /bin/bash /scripts/apache-start.sh
To inspect the entrypoint and command without reading logs:
docker inspect --format '{{.Config.Entrypoint}} {{.Config.Cmd}}' <container>Check logs for:
- S3 bucket/path accessibility (AWS credentials, permissions)
- MySQL connectivity (host, port, credentials)
- Database file integrity
- Stage File Proxy: Verify module is installed via Composer (
composer show drupal/stage_file_proxy) - Apache Proxy: Check
FILE_PROXY_PATHSmatches actual file locations in your app - Origin URL: Ensure
ORIGIN_URLis accessible from container
Optional features (database import, file proxy) gracefully skip if not configured. Only required variables are APP_ROOT and the database/proxy info matched to your setup.