How to Deploy a Private GHCR Docker Image on an Ubuntu VPS
When your Docker image is published to GitHub Container Registry (GHCR) as a private package, deploying it on a VPS requires authentication. This sounds obvious, but the exact steps -- and the mistakes you can make along the way -- are not always clear from the documentation.
This guide is based on my experience deploying private images from GHCR to Ubuntu servers. The commands have been verified against current documentation, but always check for updates before running them in production.
GitHub Repository vs. GHCR Image
A private GitHub repository and a private GHCR image are separate things with separate permissions. You can have a public repository that publishes a private image, or a private repository that publishes a public image. The image visibility is controlled in the package settings on GitHub, not in the repository settings.
When GitHub Actions builds and pushes an image to GHCR, it uses the GITHUB_TOKEN with package write permissions. Pulling that image from a different machine requires separate authentication.
Creating a Personal Access Token
To pull a private image from GHCR on your VPS, you need a Personal Access Token (PAT) with the read:packages scope.
Go to GitHub Settings > Developer settings > Personal access tokens > Fine-grained tokens. Create a token with:
- Repository access: Only select repositories (choose the one that publishes the image)
- Permissions: Repository > Packages > Read
The fine-grained token is preferable to a classic token because it limits the blast radius. If the token is compromised, it can only read packages from the specific repository you selected.
Never use a token with write permissions for pulling images. The principle of least privilege applies.
Authenticating on the VPS
SSH into your server and authenticate with GHCR:
echo "$CR_PAT" | docker login ghcr.io -u YOUR_USERNAME --password-stdin
The --password-stdin flag reads the token from standard input. This is important because passing the token as a command-line argument (--password) makes it visible in the shell history and in the process list.
The echo command is a simplification. In practice, the token should be stored in an environment variable or a secrets manager, not hardcoded in scripts. On a personal server, storing it in a protected environment file is reasonable:
# In /etc/environment or a sourced file
export CR_PAT=ghp_xxxxxxxxxxxx
After successful authentication, Docker stores the credentials in ~/.docker/config.json. These credentials persist across sessions until you explicitly log out.
Finding the Full Image Name
The full GHCR image name follows this pattern:
ghcr.io/OWNER/IMAGE_NAME:TAG
For example, if your GitHub username is MEHDImp4 and your image is named portfolio:
ghcr.io/mehdimp4/portfolio:main
A common mistake is using the wrong case. GHCR requires lowercase image names. If your repository is MEHDImp4/Portfolio, the image must be pushed as mehdimp4/portfolio. GitHub Actions can handle this transformation automatically, but manual pushes require you to lowercase the name.
Common Errors
unauthorized: authentication required: The token is missing, expired, or lacks the read:packages scope. Re-authenticate with a valid token.
denied: permission denied: The token is valid but does not have access to this specific package. Check that the token's repository access includes the repository that owns the package.
manifest unknown: The image or tag does not exist. Check the exact name and tag. Case matters. Also check that the package visibility matches your expectations (private vs. public).
Tags: latest, main, and SHA-based
GHCR images can have multiple tags. Common patterns:
latest: Updated on every push to the default branch. Convenient but can be unpredictable.main(or the branch name): Updated on pushes to that specific branch.sha-abc1234: Immutable tag based on the commit SHA. Best for production because you know exactly what you are deploying.
For production deployments, I prefer SHA-based tags. They are immutable and traceable: you can look at the tag and know exactly which commit built it. The latest tag can point to different images at different times, which makes debugging harder.
Deploying with Docker Compose
A docker-compose.yml on the VPS references the private image:
services:
portfolio:
image: ghcr.io/mehdimp4/portfolio:main
container_name: portfolio
restart: unless-stopped
ports:
- "3000:3000"
environment:
- DATABASE_URL=file:/app/data/dev.db
- AUTH_SECRET=${AUTH_SECRET}
volumes:
- ./data:/app/data
The authentication happens at the Docker level (via docker login), not in the docker-compose.yml. Docker Compose uses the stored credentials when pulling the image.
Environment Variables
Sensitive values (database URLs, secrets, API tokens) should not be in the docker-compose.yml. Use a .env file in the same directory:
AUTH_SECRET=your-secret-here
Docker Compose automatically reads .env files. The .env file should not be committed to version control.
Updating the Image
When a new image is pushed to GHCR, pull and restart on the server:
docker compose pull
docker compose up -d
docker compose pull fetches the new image. docker compose up -d recreates only the containers whose images have changed. Containers with unchanged images are left running.
If you are using SHA-based tags, you also need to update the tag in docker-compose.yml before pulling. This is an extra step, but it ensures you are deploying exactly the version you intend.
Token Rotation
Tokens should be rotated periodically. The process is:
- Create a new token on GitHub
- Update the environment variable on the server
- Re-authenticate with
docker login - Delete the old token on GitHub
This is manual but straightforward. The old token stops working immediately when deleted, so make sure the new one is configured before revoking the old one.
Security Considerations
Do not pass the token as a command-line argument. Use --password-stdin or store it in an environment variable.
Do not commit the token to version control. Even in a private repository, tokens in git history are a risk.
Do not grant more permissions than necessary. A token that only needs to pull images should not have write permissions.
Log out when done if the server is shared: docker logout ghcr.io. This removes the stored credentials.
The authentication step is an extra hoop compared to pulling public images. But for private images, it is the mechanism that keeps your code and infrastructure from being publicly accessible.