Automatic Deployments with GitHub Actions and Watchtower
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:
- GitHub Actions - Builds your Docker image when you push code
- ghcr.io - Stores your Docker image (like Docker Hub, but from GitHub)
- 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 labelWATCHTOWER_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:
- Portainer (to deploy stacks that use private images)
- 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:packagesscope
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.ioand 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.