Docker Build & Deploy — middag-io
Multi-arch build, GHCR/ECR push, EC2 deploy, 1Password Connect integration. References: ADR-007, ADR-008, ADR-005.
Architecture
GitHub Actions
│
├── docker buildx build --platform linux/arm64
│ │
│ ▼
│ ghcr.io/middag-io/{repo}/{service}:{tag} ← GHCR (always)
│ │
│ ├── (if PUSH_TO_ECR=true)
│ │ crane copy → ECR ← ECR (optional)
│ │
│ SCP: docker-compose.yml, .env.tpl, Makefile
│ │
│ SSH: docker compose pull
│ op inject .env.tpl → .env ← 1Password Connect
│ docker compose up -d
│
▼
EC2 (production/staging)1. Registry Strategy
See ADR-007 — Docker Registry Strategy for the complete rationale, naming patterns, and when to use ECR.
Summary: GHCR by default (ghcr.io/middag-io/{repo}/{service}:{tag}), ECR optional per-repo via PUSH_TO_ECR feature flag. GITHUB_TOKEN handles GHCR auth automatically.
Enable ECR on a repo
gh variable set PUSH_TO_ECR --body "true" --repo middag-io/docker-wp-my-project
gh variable set ECR_REPO --body "my-project" --repo middag-io/docker-wp-my-project2. Build Configuration
Multi-arch build (ARM64)
Production servers run ARM64 (AWS Graviton). Build with --platform linux/arm64:
- name: Build and push
run: |
docker buildx build \
--platform linux/arm64 \
-t ghcr.io/middag-io/${{ github.event.repository.name }}/wordpress:${{ github.sha }} \
-t ghcr.io/middag-io/${{ github.event.repository.name }}/wordpress:latest \
--push .Image tagging
| Tag | When used | Mutable? |
|---|---|---|
{sha} | Every build (commit SHA) | No |
latest | Every build on main | Yes |
v{x.y.z} | Release tag | No |
ECR copy step
- name: Copy to ECR
if: vars.PUSH_TO_ECR == 'true'
env:
AWS_REGION: ${{ vars.AWS_REGION || 'us-east-1' }}
run: |
aws ecr get-login-password --region ${AWS_REGION} \
| crane auth login ${ECR_REGISTRY} -u AWS --password-stdin
crane copy \
ghcr.io/middag-io/${{ github.event.repository.name }}/wordpress:${{ github.sha }} \
${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${{ vars.ECR_REPO }}:${{ github.sha }}
crane copy \
ghcr.io/middag-io/${{ github.event.repository.name }}/wordpress:latest \
${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${{ vars.ECR_REPO }}:latestAWS credentials come from 1Password vault CI-AWS via op run.
3. Deploy to EC2
Deploy flow
- SCP config files to server
- SSH: pull images, inject secrets, restart services
Files deployed
| File | Purpose |
|---|---|
docker-compose.yml | Service definitions |
.env.production.tpl | Secret template with op:// references |
Makefile | Deploy commands (make deploy, make logs) |
Secret injection on server
EC2 instances run 1Password Connect locally. Secrets are injected at deploy time:
# On the server (via SSH from CI)
op inject -i .env.production.tpl -o .env
# Template example:
DB_HOST=op://CI-MYPROJECT/database-production/host
DB_PASSWORD=op://CI-MYPROJECT/database-production/password
REDIS_URL=op://CI-MYPROJECT/redis-production/urlConnect resolves op:// references using the local Connect server — secrets never exist in CI logs or GitHub.
Deploy script (CI)
- name: Deploy
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
run: |
# Load SSH key from 1Password
eval $(op ssh agent)
# SCP files
scp docker-compose.yml .env.production.tpl Makefile ${{ vars.DEPLOY_USER }}@${{ vars.DEPLOY_HOST }}:${{ vars.DEPLOY_PATH }}/
# SSH deploy
ssh ${{ vars.DEPLOY_USER }}@${{ vars.DEPLOY_HOST }} << 'EOF'
cd ${{ vars.DEPLOY_PATH }}
docker compose pull
op inject -i .env.production.tpl -o .env
docker compose up -d --remove-orphans
docker image prune -f
EOF4. Docker Compose Structure
Standard layout
docker-wp-my-project/
├── docker-compose.yml
├── docker-compose.override.yml # local dev overrides
├── .env.production.tpl # 1Password op:// template
├── .env.staging.tpl
├── .env.example # example for devs
├── Makefile # deploy/ops commands
├── wordpress/
│ ├── Dockerfile
│ └── conf/
├── nginx/
│ ├── Dockerfile
│ └── conf/
└── .github/
└── workflows/
├── ci.yml
└── release.ymlMakefile targets
deploy:
docker compose pull
op inject -i .env.production.tpl -o .env
docker compose up -d --remove-orphans
logs:
docker compose logs -f --tail=100
restart:
docker compose restart
status:
docker compose ps5. Operations Workflow
Reusable workflow for common server operations: backup, cache flush, WP-CLI.
Available operations
| Operation | Description | Make target |
|---|---|---|
backup-db | Export database, download as artifact | make backup-db |
backup-full | Database + uploads archive | make backup |
cache-flush | Flush Redis + WP object cache | make cache-flush |
wp-cli | Run arbitrary WP-CLI command | make wp CMD='...' |
Caller workflow setup
Each docker-wp repo needs a thin operations.yml:
name: Operations
on:
workflow_dispatch:
inputs:
operation:
type: choice
options: [backup-db, backup-full, cache-flush, wp-cli]
wp-command:
type: string
default: "plugin list"
jobs:
ops:
uses: middag-io/.github-private/.github/workflows/docker-wp-operations.yml@workflows-v1
with:
operation: ${{ inputs.operation }}
wp-command: ${{ inputs.wp-command }}
op-item-ec2: CI-MYPROJECT/AWS-EC2-docker-wp-myproject
op-item-ssh-key: CI-MYPROJECT/SSH-docker-wp-myproject-dev-key
op-service-account-secret: OP_SA_MYPROJECT
secrets: inherit1Password items required
The reusable workflow reads two 1Password items:
| Input | Fields | Example |
|---|---|---|
op-item-ec2 | EC2/host, EC2/user, EC2/folder | CI-MYPROJECT/AWS-EC2-docker-wp-myproject |
op-item-ssh-key | private_key | CI-MYPROJECT/SSH-docker-wp-myproject-dev-key |
Backup artifacts
Backups are uploaded as GitHub Actions artifacts with 30-day retention. After download, backup files are cleaned up on the server to prevent disk accumulation.
Download from: Actions → Operations → Run → Artifacts.
Build validation
The build-and-deploy.yml validate step pulls the just-pushed image and runs scripts/validate-build.sh inside the container via QEMU emulation:
- name: Validate build
run: |
docker run --rm --platform linux/arm64 \
${{ env.GHCR_REPO }}/wordpress:${{ env.IMAGE_TAG }} \
bash scripts/validate-build.shThe script checks: PHP extensions, WordPress core, Composer autoloader, required/premium/custom plugins, themes, mu-plugins, object-cache drop-in, and system tools.
6. Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| GHCR push denied | Token lacks write:packages | Add permissions: packages: write to job |
| ECR login failed | AWS credentials expired in 1Password | Update credentials in CI-AWS vault |
crane copy fails | Auth to target registry missing | Login to both registries before copy |
op inject fails on EC2 | Connect server not running | docker compose up -d op-connect-api op-connect-sync |
| ARM64 build fails | No buildx builder for ARM | docker buildx create --use --platform linux/arm64 |
| Image not found on pull | Wrong registry or tag | Verify GHCR image exists: crane ls ghcr.io/middag-io/{repo} |