Once you build an image, you need somewhere to store it so your VPS, a second server or a CI pipeline can pull it. Docker Hub and GitHub Container Registry (GHCR) are the two most common hosted registries. This guide shows how to log in safely with tokens, tag images in a way you will understand later, push them and pull private images on your server.
Before you start
- Docker installed on the machine where you build images, and on the VPS that will run them. See Install Docker and Docker Compose.
- A Docker Hub account, a GitHub account, or both.
- An image built locally, for example
myapp:1.0. If you do not have one yet, follow Write a Dockerfile and build your first image.
Free plan limits, private repository counts and pull rate limits change from time to time. Check the current terms on Docker's and GitHub's own pricing pages.
Create an access token
Use a token instead of your account password. Tokens can be limited in scope and revoked without changing your password.
Docker Hub
- Sign in at Docker Hub and open your Account settings.
- Find Personal access tokens and generate a new token.
- Give it a description such as
vps-pulland choose the narrowest access that works: read-only for servers that only pull, read and write for the machine that pushes. - Copy the token. Docker Hub shows it only once.
GitHub Container Registry
- In GitHub, open Settings → Developer settings → Personal access tokens.
- Create a classic token with the
write:packagesscope for pushing, or onlyread:packagesfor pulling. Classic tokens are the documented option for GHCR from the command line; check GitHub's docs in case fine-grained tokens support your case. - Set an expiry date and copy the token.
Tip: In GitHub Actions you do not need a personal token. The built-in GITHUB_TOKEN can push to GHCR when the workflow has packages: write permission.
Log in from the command line
Pass the token on standard input so it does not end up in your shell history:
echo "$DOCKERHUB_TOKEN" | docker login -u yourname --password-stdinFor GHCR:
echo "$GHCR_TOKEN" | docker login ghcr.io -u your-github-username --password-stdinSet the variable first with read -s DOCKERHUB_TOKEN, which reads it without echoing to the screen. Docker stores the credentials in ~/.docker/config.json.
Warning: Without a credential helper, config.json holds the token in a reversible encoded form. Keep the file readable only by your user and use read-only tokens on servers.
Tag the image
A registry needs the full name: registry host, namespace and repository.
docker tag myapp:1.0 yourname/myapp:1.0
docker tag myapp:1.0 ghcr.io/your-github-username/myapp:1.0Docker Hub is the default, so it needs no host prefix. GHCR names must be lowercase.
Good tagging habits:
- Use an exact version for every release, such as
1.4.2, and never overwrite it. - Optionally also push a moving tag such as
1.4orlatestfor convenience. - In CI, add the short Git commit hash, for example
sha-3f2c1ab, so you can trace any running image back to its code.
Add several tags to the same image:
docker tag myapp:1.0 yourname/myapp:latestPush the image
docker push yourname/myapp:1.0
docker push yourname/myapp:latestFor GHCR:
docker push ghcr.io/your-github-username/myapp:1.0Docker uploads only the layers the registry does not already have, so pushing a new version is usually quick.
Make the image private or public
- Docker Hub: set the visibility when you create the repository on the website, or change it later in the repository's Settings.
- GHCR: a new package is private by default when pushed from the command line. Change it under the package's Package settings. You can also link the package to a repository so it inherits that repository's access rules.
Pull private images on your VPS
On the server, log in once with a read-only token, then pull as normal:
echo "$GHCR_TOKEN" | docker login ghcr.io -u your-github-username --password-stdin
docker pull ghcr.io/your-github-username/myapp:1.0Compose uses the same stored credentials:
services:
app:
image: ghcr.io/your-github-username/myapp:1.0
restart: unless-stoppeddocker compose pull && docker compose up -dIf you run Compose with sudo, log in with sudo too, because credentials are stored per user.
Check it works
Remove the local copy and pull it back from the registry:
docker image rm yourname/myapp:1.0
docker pull yourname/myapp:1.0The pull should succeed. On GHCR, the package also appears under the Packages tab of your GitHub profile or organisation.
Troubleshooting
denied: requested access to the resource is denied: the image name does not match your username or organisation, or you are not logged in to that registry. Checkdocker tagand rundocker loginagain.unauthorizedon GHCR: the token lackswrite:packagesorread:packages, or it has expired.toomanyrequestson Docker Hub: you hit the pull rate limit. Log in on the server so pulls count against your account, and avoid pulling on every deploy when the tag has not changed.invalid reference format: repository name must be lowercase: use lowercase letters in GHCR and Docker Hub names.
Next steps
- Automate the build and push: GitHub Actions: build and deploy Docker images.
- Host your own instead: Run a private Docker registry on your VPS.
