← Back to blog

Automatic Deployments with GitHub Actions and Watchtower

ci-cddockerself-hostinggithub-actions

One thing I wanted for this site was zero-touch deployments. Push to master, wait a couple minutes, and the site updates itself. No SSH, no manual docker pulls, nothing.

Here’s how I set it up.

The Big Picture

┌─────────────┐      ┌─────────────┐      ┌─────────────┐      ┌─────────────┐
│   You       │      │   GitHub    │      │   ghcr.io   │      │ Your Server │
│             │      │   Actions   │      │  (registry) │      │             │
│  git push   │ ───► │  builds     │ ───► │  stores     │ ◄─── │ Watchtower  │
│  to master  │      │  docker     │      │  your       │      │ pulls new   │
│             │      │  image      │      │  image      │      │ image       │
└─────────────┘      └─────────────┘      └─────────────┘      └─────────────┘
                                                                      │
                                                                      ▼
                                                               ┌─────────────┐
                                                               │ Container   │
                                                               │ restarts    │
                                                               │ with new    │
                                                               │ code!       │
                                                               └─────────────┘

Three pieces working together:

  1. GitHub Actions - Builds your Docker image when you push code
  2. ghcr.io - Stores your Docker image (like Docker Hub, but from GitHub)
  3. Watchtower - Runs on your server, checks for new images, and updates your containers

What is ghcr.io?

ghcr.io stands for GitHub Container Registry. Think of it as a place to store your Docker images, similar to Docker Hub.

Why use it?

  • It’s already integrated with GitHub - no extra accounts needed
  • Your code and your images live in the same place

When you push an image to ghcr.io/your-username/your-app:latest, it’s stored there and can be pulled from any server.

What is Watchtower?

Watchtower is a Docker container that watches your other containers. Every few minutes, it checks: “Is there a newer version of this image available?” If yes, it pulls the new image and restarts the container.

It’s like having someone constantly checking for updates and deploying them for you - but automated.

Without Watchtower, you’d have to SSH into your server and run docker pull + docker restart manually every time you push new code. Watchtower does this automatically.

Is This Free?

For public repositories: completely free, unlimited builds.

For private repositories: GitHub gives you 2,000 build minutes per month on the free tier (3,000 on Pro). A typical build takes 1-2 minutes, so that’s roughly 1,000+ deployments per month. More than enough for personal projects.

Storage on ghcr.io is 500 MB free, which is plenty for most Docker images.

Setting It Up

1. The GitHub Actions Workflow

Create .github/workflows/build.yml:

name: Build and Push

on:
  push:
    branches: [master]

jobs:
  build:
    runs-on: ubuntu-latest

    permissions:
      contents: read
      packages: write

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

      - name: Log in to GitHub Container Registry
        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@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository_owner }}/your-app:latest

About permissions: packages: write - By default, GitHub Actions can only read your repo. To push images to ghcr.io (which GitHub calls “packages”), you need to explicitly grant write permission. Without this line, the push will fail with a permission error.

About secrets.GITHUB_TOKEN - This is a special token that GitHub automatically creates for every workflow run. You don’t need to create it - it’s just there. It lets the workflow authenticate with GitHub services like ghcr.io.

2. Docker Compose on Your Server

services:
  your-app:
    image: ghcr.io/your-username/your-app:latest
    container_name: your-app
    restart: unless-stopped
    labels:
      - "com.centurylinklabs.watchtower.enable=true"

That label tells Watchtower: “Hey, watch this container for updates.”

3. Watchtower

services:
  watchtower:
    image: containrrr/watchtower
    container_name: watchtower
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - WATCHTOWER_CLEANUP=true
      - WATCHTOWER_POLL_INTERVAL=300
      - WATCHTOWER_LABEL_ENABLE=true
  • WATCHTOWER_POLL_INTERVAL=300 - Check for updates every 300 seconds (5 minutes)
  • WATCHTOWER_LABEL_ENABLE=true - Only update containers that have the watchtower label
  • WATCHTOWER_CLEANUP=true - Delete old images after updating (saves disk space)

4. If Your Repo is Private

If your repository is private, you need to authenticate with ghcr.io in two places:

  1. Portainer (to deploy stacks that use private images)
  2. Watchtower (to pull updates automatically)

This is a common gotcha - they’re separate containers with separate credentials.

Create a Personal Access Token

First, create a Personal Access Token (Classic) on GitHub:

  • Settings → Developer settings → Personal access tokens → Tokens (classic)
  • Generate with read:packages scope

Configure Portainer

If you use Portainer to deploy your stacks:

  • Go to Registries → Add registry
  • Select Custom registry
  • Set the URL to ghcr.io
  • Enter your GitHub username and the PAT as the password

Configure Watchtower

Watchtower runs as its own container, so it needs credentials separately. On your server:

# Login to ghcr.io (creates ~/.docker/config.json)
docker login ghcr.io -u your-username
# Enter your Personal Access Token when prompted

Then mount that config file into Watchtower:

services:
  watchtower:
    image: containrrr/watchtower
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /home/your-user/.docker/config.json:/config.json:ro
    environment:
      - DOCKER_CONFIG=/
      - WATCHTOWER_CLEANUP=true
      - WATCHTOWER_POLL_INTERVAL=300
      - WATCHTOWER_LABEL_ENABLE=true

Important: Use the full absolute path to your config.json (like /home/your-user/.docker/config.json), not ~/.docker/config.json. Docker Compose doesn’t always expand ~ correctly, and if the file isn’t found, Docker will create a directory instead of mounting the file - which causes a cryptic “is a directory” error.

The :ro at the end means read-only, which is good practice for credential files.

The Result

Push to master → wait ~5 minutes → site is updated. No intervention needed.

# Check if it's working
docker logs watchtower

Troubleshooting

“unauthorized” or “403 Forbidden” errors:

  • Watchtower can’t authenticate with ghcr.io
  • Make sure you ran docker login ghcr.io and mounted the config file

“/config.json: is a directory” error:

  • Docker created a directory instead of mounting your file
  • Use the full absolute path (not ~) and make sure the file exists before starting the container
  • Remove and recreate the Watchtower container after fixing the path

Force Watchtower to check now:

docker exec watchtower /watchtower --run-once

The best deployment pipeline is one you forget exists.