Skip to content

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

bash
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-project

2. Build Configuration

Multi-arch build (ARM64)

Production servers run ARM64 (AWS Graviton). Build with --platform linux/arm64:

yaml
- 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

TagWhen usedMutable?
{sha}Every build (commit SHA)No
latestEvery build on mainYes
v{x.y.z}Release tagNo

ECR copy step

yaml
- 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 }}:latest

AWS credentials come from 1Password vault CI-AWS via op run.

3. Deploy to EC2

Deploy flow

  1. SCP config files to server
  2. SSH: pull images, inject secrets, restart services

Files deployed

FilePurpose
docker-compose.ymlService definitions
.env.production.tplSecret template with op:// references
MakefileDeploy commands (make deploy, make logs)

Secret injection on server

EC2 instances run 1Password Connect locally. Secrets are injected at deploy time:

bash
# 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/url

Connect resolves op:// references using the local Connect server — secrets never exist in CI logs or GitHub.

Deploy script (CI)

yaml
- 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 $&#123;&#123; vars.DEPLOY_USER &#125;&#125;@$&#123;&#123; vars.DEPLOY_HOST &#125;&#125; << 'EOF'
      cd $&#123;&#123; vars.DEPLOY_PATH &#125;&#125;
      docker compose pull
      op inject -i .env.production.tpl -o .env
      docker compose up -d --remove-orphans
      docker image prune -f
    EOF

4. 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.yml

Makefile targets

makefile
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 ps

5. Operations Workflow

Reusable workflow for common server operations: backup, cache flush, WP-CLI.

Available operations

OperationDescriptionMake target
backup-dbExport database, download as artifactmake backup-db
backup-fullDatabase + uploads archivemake backup
cache-flushFlush Redis + WP object cachemake cache-flush
wp-cliRun arbitrary WP-CLI commandmake wp CMD='...'

Caller workflow setup

Each docker-wp repo needs a thin operations.yml:

yaml
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: $&#123;&#123; inputs.operation &#125;&#125;
      wp-command: $&#123;&#123; inputs.wp-command &#125;&#125;
      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: inherit

1Password items required

The reusable workflow reads two 1Password items:

InputFieldsExample
op-item-ec2EC2/host, EC2/user, EC2/folderCI-MYPROJECT/AWS-EC2-docker-wp-myproject
op-item-ssh-keyprivate_keyCI-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:

yaml
- name: Validate build
  run: |
    docker run --rm --platform linux/arm64 \
      $&#123;&#123; env.GHCR_REPO &#125;&#125;/wordpress:$&#123;&#123; env.IMAGE_TAG &#125;&#125; \
      bash scripts/validate-build.sh

The script checks: PHP extensions, WordPress core, Composer autoloader, required/premium/custom plugins, themes, mu-plugins, object-cache drop-in, and system tools.

6. Troubleshooting

ProblemCauseFix
GHCR push deniedToken lacks write:packagesAdd permissions: packages: write to job
ECR login failedAWS credentials expired in 1PasswordUpdate credentials in CI-AWS vault
crane copy failsAuth to target registry missingLogin to both registries before copy
op inject fails on EC2Connect server not runningdocker compose up -d op-connect-api op-connect-sync
ARM64 build failsNo buildx builder for ARMdocker buildx create --use --platform linux/arm64
Image not found on pullWrong registry or tagVerify GHCR image exists: crane ls ghcr.io/middag-io/{repo}

MIDDAG Tecnologia