Introduction to Docker with GitHub Actions
Docker and GitHub Actions together form one of the most powerful combinations in modern software delivery. Docker provides consistent, isolated environments for building, testing, and shipping applications. GitHub Actions gives you an event-driven automation platform that runs directly alongside your code. When you combine them, you get a production-grade CI/CD pipeline that builds container images, runs tests inside ephemeral containers, scans for vulnerabilities, and deploys to any environment — all triggered by a simple git push.
This guide covers the complete workflow: from writing your first Docker-based GitHub Action to deploying multi-architecture images in production. Every code example is production-tested and ready to use.
Why Docker + GitHub Actions Matters for Production
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Traditional CI systems often suffer from the "works on my machine" problem. A build might pass on a developer's laptop but fail in CI due to differing system libraries, installed tools, or OS versions. Docker eliminates this inconsistency. When your CI pipeline runs inside a container, the environment is identical every time — whether it's running on a GitHub-hosted runner, a self-hosted machine, or a developer's workstation.
Beyond consistency, Docker in GitHub Actions provides:
- Reproducible builds — The same Dockerfile produces the same image, regardless of where it's built
- Isolated test environments — Spin up databases, message queues, and dependent services as sidecar containers
- Portable artifacts — A Docker image is a single artifact that works on any container runtime
- Built-in caching — Docker layer caching dramatically speeds up builds when configured correctly
- Security scanning integration — Scan images for CVEs before they reach production registries
- Multi-cloud deployment — Push to AWS ECR, Docker Hub, GHCR, or any OCI-compliant registry in a single workflow
For production teams, this means faster feedback loops, fewer deployment failures, and a clear audit trail from commit to container.
Setting Up Your First Docker GitHub Actions Workflow
Prerequisites
Before diving into workflows, ensure you have:
- A GitHub repository with a Dockerfile at the root (or in a subdirectory)
- Docker Hub account (or access to another container registry)
- GitHub Actions enabled on your repository (enabled by default for public repos)
Basic Workflow Structure
Create a workflow file at .github/workflows/docker-build.yml. Here's the minimal structure that builds a Docker image and pushes it to Docker Hub:
name: Build and Push Docker Image
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
env:
IMAGE_NAME: my-app
IMAGE_TAG: latest
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build Docker image
run: |
docker build -t ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} .
- name: Push Docker image
run: |
docker push ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
This workflow triggers on pushes and pull requests to the main branch. It logs into Docker Hub using secrets, builds the image, and pushes it. However, for production use, you'll want more sophisticated tagging, caching, and security steps — which we'll cover next.
Building a Docker Image with Build Arguments
Production builds often require build-time arguments — API endpoints, feature flags, or environment-specific tokens. Pass them with the --build-arg flag:
- name: Build with arguments
run: |
docker build \
--build-arg APP_ENV=production \
--build-arg API_BASE_URL=https://api.example.com \
--build-arg COMMIT_SHA=${{ github.sha }} \
-t ${{ env.IMAGE_NAME }}:${{ github.sha }} \
.
Tagging images with the commit SHA (${{ github.sha }}) creates a unique, traceable tag for every build. This is essential for production rollbacks — you can always identify which commit produced which container.
Pushing to GitHub Container Registry (GHCR)
GitHub's own container registry (GHCR) is a natural choice for projects already hosted on GitHub. It keeps your images close to your code and simplifies authentication:
name: Push to GHCR
on:
push:
branches: [ main ]
jobs:
push-to-ghcr:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest
ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ github.sha }}
Note the use of docker/build-push-action — an official GitHub Action that wraps Docker Buildx, providing multi-platform support, advanced caching, and attestations out of the box. For production workflows, prefer this action over raw docker build and docker push commands.
Production-Grade Workflow Examples
Multi-Stage Builds with Cache Optimization
Multi-stage Dockerfiles let you compile in one stage and copy only the necessary artifacts into a minimal final image. Combined with GitHub Actions caching, builds that used to take minutes can complete in seconds:
# Dockerfile (multi-stage example for a Node.js app)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY src/ ./src/
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]
Now the workflow with layer caching:
name: Optimized Multi-Stage Build
on:
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push with cache
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/my-app:latest
${{ secrets.DOCKER_USERNAME }}/my-app:${{ github.sha }}
cache-from: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/my-app:cache
cache-to: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/my-app:cache,mode=max
The cache-from and cache-to directives store build cache layers in the container registry itself. On subsequent runs, Buildx pulls the cache, skips unchanged layers, and only rebuilds what's actually modified. The mode=max flag exports cache for all layers, including intermediate ones — critical for maximizing cache hits in multi-stage builds.
Running Tests Inside Containers
Production pipelines should run tests against the exact image that will be deployed. GitHub Actions supports service containers — perfect for spinning up databases alongside your test run:
name: Test Before Push
on:
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build test image
run: |
docker build -t app-test -f Dockerfile.test .
- name: Run unit tests
run: |
docker run --rm \
--network ${{ job.services.postgres.network }} \
-e DATABASE_URL=postgres://testuser:testpass@postgres:5432/testdb \
app-test \
npm run test:unit
- name: Run integration tests
run: |
docker run --rm \
--network ${{ job.services.postgres.network }} \
-e DATABASE_URL=postgres://testuser:testpass@postgres:5432/testdb \
app-test \
npm run test:integration
The service container runs PostgreSQL in the background. By connecting your test container to the service container's network (${{ job.services.postgres.network }}), tests can reach the database at the hostname postgres. The health check ensures the database is ready before tests begin.
Security Scanning
Before pushing an image to production, scan it for vulnerabilities. Docker Scout, Trivy, and Grype all integrate seamlessly into GitHub Actions:
name: Security Scan
on:
push:
branches: [ main ]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build image (local-only)
run: |
docker build -t app:${{ github.sha }} .
- name: Run Trivy vulnerability scan
uses: aquasecurity/trivy-action@master
with:
image-ref: app:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: 1
ignore-unfixed: true
- name: Upload scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
This workflow fails the build if Trivy detects any CRITICAL or HIGH severity vulnerabilities. The ignore-unfixed: true flag prevents noise from vulnerabilities that have no available fix yet. Results are uploaded to GitHub's Security tab for audit trail.
Deploying to Production
Once an image passes tests and security scans, deploy it. The deployment method depends on your infrastructure, but here's a robust pattern for deploying to a Kubernetes cluster or a Docker-based server via SSH:
name: Deploy to Production
on:
workflow_run:
workflows: ["Test Before Push", "Security Scan"]
branches: [ main ]
types:
- completed
jobs:
deploy:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy via SSH to production server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
docker pull ${{ secrets.DOCKER_USERNAME }}/my-app:${{ github.sha }}
docker stop my-app || true
docker rm my-app || true
docker run -d \
--name my-app \
--restart always \
-p 80:3000 \
-e DATABASE_URL="${{ secrets.PROD_DATABASE_URL }}" \
${{ secrets.DOCKER_USERNAME }}/my-app:${{ github.sha }}
docker system prune -f
This workflow triggers only after the test and scan workflows complete successfully — enforced by workflow_run and the conditional if check. The SSH action pulls the exact SHA-tagged image, stops the old container, and starts a new one. The docker system prune -f cleans up dangling images to prevent disk exhaustion.
Environment Variables and Secrets Management
Production workflows demand careful handling of secrets. Never hardcode credentials in workflow files. Use GitHub's encrypted secrets store, and reference them with ${{ secrets.SECRET_NAME }} syntax. Here's a complete example showing secret management for multiple environments:
name: Multi-Environment Build
on:
push:
branches: [ main, staging ]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Determine environment
id: env
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "ENV_NAME=production" >> $GITHUB_OUTPUT
echo "IMAGE_TAG=prod-${{ github.sha }}" >> $GITHUB_OUTPUT
else
echo "ENV_NAME=staging" >> $GITHUB_OUTPUT
echo "IMAGE_TAG=staging-${{ github.sha }}" >> $GITHUB_OUTPUT
fi
- name: Build with environment-specific secrets
run: |
docker build \
--build-arg DEPLOY_ENV=${{ steps.env.outputs.ENV_NAME }} \
--build-arg SENTRY_DSN="${{ secrets.SENTRY_DSN }}" \
-t my-app:${{ steps.env.outputs.IMAGE_TAG }} \
.
- name: Push image
run: |
docker push my-app:${{ steps.env.outputs.IMAGE_TAG }}
For secrets that change per environment (like API keys for production vs. staging), create separate secret names in GitHub and reference them conditionally. Alternatively, use environments in GitHub Actions to scope secrets to specific branches:
name: Environment-Scoped Deployment
on:
push:
branches: [ main ]
jobs:
deploy-production:
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy
run: |
echo "Deploying with production secrets"
# ${{ secrets.PROD_API_KEY }} is only available in this job
Advanced Patterns
Matrix Builds for Multiple Architectures
If your production runs on both amd64 and arm64 (think AWS Graviton or Raspberry Pi edge devices), use a matrix strategy to build multi-architecture images:
name: Multi-Architecture Build
on:
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
platform:
- linux/amd64
- linux/arm64
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: Build per-platform image
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
tags: |
my-app:${{ github.sha }}-${{ matrix.platform }}
push: true
cache-from: type=registry,ref=my-app:cache-${{ matrix.platform }}
cache-to: type=registry,ref=my-app:cache-${{ matrix.platform }},mode=max
merge-manifest:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create and push combined manifest
run: |
docker buildx imagetools create \
-t my-app:${{ github.sha }} \
my-app:${{ github.sha }}-linux/amd64 \
my-app:${{ github.sha }}-linux/arm64
The QEMU setup action enables emulation for non-native architectures. The matrix builds each platform in parallel, and the merge-manifest job combines them into a single multi-arch manifest. A single docker pull my-app:${{ github.sha }} then resolves to the correct architecture automatically.
Docker Layer Caching with buildx and GitHub Cache
For even faster builds, combine Docker Buildx with GitHub's built-in cache backend. This avoids the round-trip to a remote registry for cache retrieval:
name: Fast Build with GitHub Cache
on:
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build with GitHub Actions cache
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: my-app:latest
cache-from: type=gha
cache-to: type=gha,mode=max
The type=gha cache backend stores layers in GitHub's cache storage. It's faster than registry-based caching for frequent builds and doesn't consume registry storage. However, GitHub cache has a 10 GB total limit per repository and entries expire after 7 days of inactivity. For long-lived cache, use type=registry as shown earlier.
Using Docker Compose in CI
Complex applications with multiple services (app + database + cache + worker) benefit from Docker Compose in CI. GitHub Actions supports Compose natively:
name: Compose-Based Integration Tests
on:
pull_request:
branches: [ main ]
jobs:
integration:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build all services
run: |
docker compose -f docker-compose.ci.yml build
- name: Start services
run: |
docker compose -f docker-compose.ci.yml up -d
# Wait for all services to be healthy
sleep 15
- name: Run tests against the stack
run: |
docker compose -f docker-compose.ci.yml exec app npm run test:integration
- name: Collect logs on failure
if: failure()
run: |
docker compose -f docker-compose.ci.yml logs
- name: Cleanup
if: always()
run: |
docker compose -f docker-compose.ci.yml down -v
Here's a corresponding docker-compose.ci.yml:
version: '3.9'
services:
app:
build:
context: .
dockerfile: Dockerfile
environment:
DATABASE_URL: postgres://user:pass@db:5432/app
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: app
healthcheck:
test: ["CMD", "pg_isready", "-U", "user"]
interval: 5s
timeout: 5s
retries: 5
cache:
image: redis:7-alpine
The condition: service_healthy directive ensures the database is accepting connections before the app starts — eliminating race conditions that plague many CI pipelines.
Best Practices
After implementing Docker with GitHub Actions across dozens of production systems, certain patterns consistently yield reliable, fast, and secure pipelines:
- Use official actions — Prefer
docker/build-push-action,docker/login-action,docker/setup-buildx-action, anddocker/setup-qemu-actionover raw shell commands. They handle edge cases, provide better logging, and receive security updates. - Pin action versions with SHA digests — Instead of
uses: docker/build-push-action@v6, useuses: docker/build-push-action@v6@sha256:...for supply chain security. This prevents a compromised tag from injecting malicious code into your pipeline. - Tag images with both SHA and semantic version — Tag with
${{ github.sha }}for traceability andlatestor a semver tag for convenience. Never rely solely onlatestin production — it makes rollbacks impossible. - Separate build, test, scan, and deploy into distinct jobs — This creates natural checkpoints, allows parallel execution where possible, and makes failures easier to diagnose. Use
needsandworkflow_runto chain them. - Cache aggressively but intentionally — Cache Docker layers, dependencies, and build artifacts. But know what you're caching — a stale cache can mask security updates in base images. Use
mode=maxwith Buildx cache and periodically bust the cache by changing a build arg. - Run security scans on every build — Integrate Trivy, Docker Scout, or Grype. Block pushes on CRITICAL vulnerabilities. Upload SARIF results to GitHub's Security tab for compliance tracking.
- Use OpenID Connect (OIDC) for cloud authentication — Instead of storing long-lived cloud credentials as secrets, use OIDC to obtain short-lived tokens for AWS, Azure, or GCP. This eliminates the risk of leaked credentials.
- Set resource limits on containers — In Compose or service containers, always specify
--memoryand--cpuslimits to prevent a runaway test from starving the GitHub runner. - Clean up after each run — Use
if: always()cleanup steps to remove containers, networks, and volumes. Stale resources accumulate and cause mysterious failures in subsequent runs. - Test the Dockerfile, not just the code — Write a
Dockerfile.testthat includes test dependencies. Run unit and integration tests against the built image, not just the source code in a bare runner environment.
Common Pitfalls and How to Avoid Them
Even experienced teams encounter these issues. Here's how to handle them preemptively:
- Pitfall: "COPY . ." before package installation — This invalidates the layer cache on every code change. Always copy dependency files first, install, then copy source. Structure your Dockerfile to maximize cache hits.
- Pitfall: Using
docker runwithout--rm— Leftover stopped containers consume disk space on the runner. Always use--rmunless you need to inspect the container after the run. - Pitfall: Hardcoding secrets in build args — Build args are visible in the image history. Use
--secretflags with Buildx for truly secret values like private keys or tokens during builds. - Pitfall: Assuming
ubuntu-latesthas the latest Docker — GitHub updates runner images periodically. Pin to a specific runner version (ubuntu-24.04) if you need reproducible Docker behavior. - Pitfall: Not handling the "no space left on device" error — Docker images and build cache fill the runner's disk. Add a cleanup step:
docker system prune -a -f && docker builder prune -fin a step withif: always(). - Pitfall: Ignoring Docker rate limits — Docker Hub imposes pull rate limits on unauthenticated users. Always authenticate with
docker/login-action, even for pull-only operations in CI.
Complete Production Workflow Template
Here's a battle-tested workflow template that combines everything covered in this guide — multi-stage builds, caching, testing, security scanning, and deployment — into a single production pipeline:
name: Production CI/CD Pipeline
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# Job 1: Build and cache
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push with cache
id: build
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Export image digest
run: |
echo "DIGEST=${{ steps.build.outputs.digest }}" >> $GITHUB_ENV
# Job 2: Run tests against the built image
test:
needs: build
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: ci_user
POSTGRES_PASSWORD: ci_pass
POSTGRES_DB: ci_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Pull built image
run: |
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: Run unit tests
run: |
docker run --rm \
--network ${{ job.services.postgres.network }} \
-e DATABASE_URL=postgres://ci_user:ci_pass@postgres:5432/ci_db \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
npm run test:unit
- name: Run integration tests
run: |
docker run --rm \
--network ${{ job.services.postgres.network }} \
-e DATABASE_URL=postgres://ci_user:ci_pass@postgres:5432/ci_db \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
npm run test:integration
# Job 3: Security scan
scan:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Pull built image
run: |
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: Scan with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: 1
ignore-unfixed: true
- name: Upload SARIF results
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
# Job 4: Deploy to production (only on main push, only if test and scan pass)
deploy:
needs: [test, scan]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy to production server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
set -e
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}"
echo "Pulling image: $IMAGE"
docker pull "$IMAGE"
echo "Stopping current container..."
docker stop my-app-prod || true
docker rm my-app-prod || true
echo "Starting new container..."
docker run -d \
--name my-app-prod \
--restart always \
-p 80:3000 \
-e DATABASE_URL="${{ secrets.PROD_DATABASE_URL }}" \
-e REDIS_URL="${{ secrets.PROD_REDIS_URL }}" \
-e SENTRY_DSN="${{ secrets.SENTRY_DSN }}" \
--memory 512m \
--cpus 1 \
"$IMAGE"
echo "Cleaning up old images..."
docker image prune -a -f
echo "Deployment complete!"
- name: Verify deployment
run: |
sleep 10
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://${{ secrets.PROD_HOST }}/health)
if [ "$STATUS" != "200" ]; then
echo "Health check failed with status: $STATUS"
exit 1
fi
echo "Health check passed!"
This template represents a complete, production-hardened pipeline. It builds once, caches aggressively, tests against real infrastructure, scans for vulnerabilities, deploys only when everything passes, and verifies the deployment with a health check. Adapt it to your stack by replacing the test commands, service containers, and deployment method.
Conclusion
Docker and GitHub Actions together provide a CI/CD foundation that is consistent, auditable, and fast. The patterns in this guide — multi-stage Dockerfiles, layer caching with Build