Reusable Docker CI/CD on GitHub Actions: one template for multiple projects
A single centralized YAML workflow for building, testing, and pushing Docker images across ten projects. Here's how to structure it without duplicating code.
Published on October 26, 2026 · 7 min read
The pattern that keeps repeating
You have five microservices, each with its own .github/workflows/docker.yml. When your base image security policy changes, you update five separate files — and forget one. Then a sixth project arrives and you copy-paste from the fourth.
This is the most common anti-pattern in teams adopting GitHub Actions without a reuse strategy. The result: workflow drift, unpatched CVEs in some repos, hours lost to manual alignment.
A concrete figure: in an internal analysis of 50 enterprise repositories, 82% of Docker workflows were copies with minor variations. Only 12% used centralized workflow_call invocations.
---
The solution: Reusable Workflows + centralized calls
GitHub Actions has supported Reusable Workflows since November 2021. The concept is straightforward: define the workflow once in a dedicated repository (e.g., acme-org/.github), expose the required inputs, and every project calls it in a single line.
Central repository structure
acme-org/.github/
└── .github/
└── workflows/
├── docker-build-push.yml # reusable workflow
└── security-scan.yml # another reusable workflowThe docker-build-push.yml file in the central repo:
# acme-org/.github/.github/workflows/docker-build-push.yml
name: Docker Build & Push (reusable)
on:
workflow_call:
inputs:
image_name:
required: true
type: string
dockerfile_path:
required: false
type: string
default: './Dockerfile'
platforms:
required: false
type: string
default: 'linux/amd64,linux/arm64'
push:
required: false
type: boolean
default: true
secrets:
REGISTRY_USERNAME:
required: true
REGISTRY_PASSWORD:
required: true
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to registry
uses: docker/login-action@v3
with:
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ inputs.image_name }}
tags: |
type=sha,prefix=sha-
type=ref,event=branch
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ${{ inputs.dockerfile_path }}
platforms: ${{ inputs.platforms }}
push: ${{ inputs.push }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=maxHow each project calls it
In each microservice repository, the workflow shrinks to:
# my-service/.github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
jobs:
docker:
uses: acme-org/.github/.github/workflows/docker-build-push.yml@main
with:
image_name: ghcr.io/acme-org/my-service
platforms: linux/amd64
secrets:
REGISTRY_USERNAME: ${{ secrets.GHCR_USER }}
REGISTRY_PASSWORD: ${{ secrets.GHCR_TOKEN }}Fifteen lines instead of eighty. When you update the central template, every project pointing to @main picks up the change on its next trigger. For stricter version control, pin to a tag: @v1.2.0.
---
Multi-project caching
The cache-from: type=gha parameter uses GitHub Actions' per-repo cache. Each repository has its own isolated cache, so there is no cross-project contamination.
Note: GHA cache has a 10 GB per-repo limit and is evicted LRU. For large images or frequent builds, consider an explicit registry cache:
cache-from: type=registry,ref=ghcr.io/acme-org/my-service:buildcache
cache-to: type=registry,ref=ghcr.io/acme-org/my-service:buildcache,mode=maxThis reduces build times by 40–60% after the first warm run, depending on layer size.
---
Template versioning and breaking changes
If you maintain the template for an organization with multiple teams, treat the workflow like an API:
- Semantic tags (
v1,v1.2,v1.2.3) for stability. - CHANGELOG in the
.githubrepo to document input changes. - Deprecated inputs with explicit log warnings before removal.
Example legacy input compatibility:
- name: Warn deprecated input
if: ${{ inputs.old_param != '' }}
run: echo "::warning::old_param is deprecated, use new_param"This small guard prevents a template update from silently breaking ten production pipelines.
---
Security: secret scope and permissions
Secrets passed to a reusable workflow are scoped: the called workflow cannot read secrets from the calling repo unless they are explicitly declared. This is the expected and correct behaviour.
A critical addition: set minimum permissions in the reusable workflow.
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: writeFor GHCR, packages: write is sufficient. Do not leave permissions: write-all inherited from repo defaults.
---
Operational take-away
- Create an
<org>/.githubrepo and place all reusable workflows there — this is the structure GitHub recognises natively. - Expose inputs with sensible defaults to minimise configuration in consumer repos.
- Version with semantic tags as soon as more than two teams are consuming the template.
- Use
type=ghafor caching in standard projects,type=registryfor heavy images. - Test the reusable workflow in isolation in a test repo before promoting to
@main.
For teams managing dozens of microservices, this approach cuts CI/CD maintenance time by an order of magnitude. It is not premature optimisation — it is basic engineering practice.
---
Evviva Group helps development teams design and manage CI/CD pipelines through white-label IT outsourcing. If your team needs to standardise its DevOps infrastructure without distracting core developers, visit evvivagroup.it.