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 GitHub repository with a
Dockerfileat the repository root. - Write access to that repository so you can add workflow files and secrets.
- A registry to push to. GitHub Container Registry (
ghcr.io) needs no separate account, or you can use Docker Hub. For the Docker Hub path, see Push images to Docker Hub or GitHub Container Registry. - A Linux VPS with Docker installed if you want to pull the finished image somewhere real. Install Docker and Docker Compose on a Linux VPS covers that.
A minimal Dockerfile for testing looks like this:
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.
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@v3The 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.
- 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:
- 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.
- 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=maxThree 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
- Commit and push the workflow:
git add .github/workflows/build.yml Dockerfile .dockerignore
git commit -m "Add image build workflow"
git push origin main- 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.
- Confirm the package appears under your repository or organisation's Packages list. Private packages are invisible to unauthenticated users by default.
- Pull the image onto your VPS to prove it runs:
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:mainUse 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 lackspackages: write, or an organisation policy blocksGITHUB_TOKENfrom publishing. Add the permission and check the org's package settings.unauthorized: authentication requiredon 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
pushis not guarded bygithub.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=ghaandcache-to: type=gha,mode=maxare both present. - Image runs locally but crashes on the VPS — usually an architecture mismatch. Check with
docker inspect --format '{{.Architecture}}' image:tagand add theplatformslist if you build for ARM hosts.
Next steps
- Push images to Docker Hub or GitHub Container Registry for registry-specific authentication details.
- Securing Docker images against vulnerabilities and malware to add scanning to the pipeline.
- Smaller Docker images with multi-stage builds to cut image size before the push step.
