Docker & DevOps

Build and push Docker images with GitHub Actions

Set up a GitHub Actions workflow that builds your Docker image on every push and pushes it to GitHub Container Registry or Docker Hub, with tags and layer caching.

IntermediateLinux server5 min read

GitHub Actions can build and push your Docker image on every commit, so the tag your VPS pulls is always built from the code you just pushed. The modern way to do this is docker/build-push-action combined with docker/login-action and docker/metadata-action — no docker build shell steps required. This guide sets up the whole workflow, from the Dockerfile to verifying the pushed image on your server.

Before you start

You need:

A minimal Dockerfile for testing looks like this:

Dockerfile
FROM alpine:3.20
WORKDIR /app
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
USER 10001
ENTRYPOINT ["/app/entrypoint.sh"]

Note: Action versions such as actions/checkout@v4 or docker/build-push-action@v6 change over time. Check each action's repository for the current major version before you copy this into production. Pinning to a commit SHA is the safer habit for third-party actions.

Create the workflow file

Create .github/workflows/build.yml in your repository. The workflow triggers on pushes to main, on version tags, and on pull requests so you get a build check before merging.

YAML
name: Build and push image

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Buildx
        uses: docker/setup-buildx-action@v3

The permissions block matters. Without packages: write, the automatic GITHUB_TOKEN cannot push to GitHub Container Registry, and you will get a permission error later.

Log in to the registry

Add a login step. For GitHub Container Registry, the built-in GITHUB_TOKEN is enough — no secret to create.

YAML
      - name: Log in to GHCR
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

The if guard skips login on pull requests. Pull requests from forks get a read-only token and should never push, so building without pushing is the correct behaviour.

For Docker Hub instead, create two repository secrets under Settings → Secrets and variables → Actions and use them like this:

YAML
      - name: Log in to Docker Hub
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

Tip: Use a Docker Hub access token, not your account password. You can revoke a token without changing your login.

Build and push the Docker image

Tags are generated by docker/metadata-action, which reads the Git ref and produces sensible names such as main, sha-abc1234 or 1.4.2 from a v1.4.2 tag.

YAML
      - name: Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=sha,format=short

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Three things are doing the work here. push is true only outside pull requests, so a broken branch never overwrites a good image. tags and labels come from the metadata step, which keeps naming consistent across every build. And cache-from / cache-to with type=gha stores BuildKit layers in the Actions cache, so unchanged layers are not rebuilt.

Add platforms: linux/amd64,linux/arm64 to the with block if you deploy to both x86 and ARM machines. Multi-platform builds take longer because they run under emulation.

If your Dockerfile lives outside the root, set context and file accordingly — for example context:./app and file:./app/Dockerfile.

Warning: Pushing an image to a public registry publishes whatever your build context contains. Add a .dockerignore with .git, .env, node_modules and *.pem entries before the first push.

Check it works

  1. Commit and push the workflow:
Bash
git add .github/workflows/build.yml Dockerfile .dockerignore
git commit -m "Add image build workflow"
git push origin main
  1. Open the Actions tab in your repository. The run should finish with a green check. Click the build job and read the Build and push step — the log lists every tag that was pushed.
  1. Confirm the package appears under your repository or organisation's Packages list. Private packages are invisible to unauthenticated users by default.
  1. Pull the image onto your VPS to prove it runs:
Bash
echo "$GHCR_TOKEN" | docker login ghcr.io -u your-github-username --password-stdin
docker pull ghcr.io/example-org/example-app:main
docker run --rm ghcr.io/example-org/example-app:main

Use a personal access token with read:packages for $GHCR_TOKEN, or make the package public if the image contains nothing sensitive. If you are deploying from the same server, Git push-to-deploy with a bare repository and post-receive hook is a lighter alternative for simple apps.

Troubleshooting

  • denied: permission_denied: write_package — the job lacks packages: write, or an organisation policy blocks GITHUB_TOKEN from publishing. Add the permission and check the org's package settings.
  • unauthorized: authentication required on Docker Hub — secret names do not match the ones referenced in the workflow, or the token was revoked. Secret names are case-sensitive.
  • Pull request builds fail on the push step — this is expected when push is not guarded by github.event_name != 'pull_request'. Add the guard shown above.
  • Every build reinstalls all dependencies — the cache is not configured or the cache key changed. Confirm cache-from: type=gha and cache-to: type=gha,mode=max are both present.
  • Image runs locally but crashes on the VPS — usually an architecture mismatch. Check with docker inspect --format '{{.Architecture}}' image:tag and add the platforms list if you build for ARM hosts.

Next steps

Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.