At some point, keeping Docker images only on your local machine stops being enough.
Maybe you built an application locally and now need to deploy it to a server. Or several servers need access to the same image. You might also want your CI pipeline to build an image once and make that exact version available for deployment.
A private Docker Registry solves this problem.
The basic workflow is not complicated:
Dockerfile
↓
docker build
↓
Local image
↓
docker tag
↓
docker push
↓
Private Registry
↓
docker pull
↓
ServerIn this guide, we’ll go through that entire process. We’ll build an image, authenticate with a private registry, tag and push the image, then pull it onto another server and start a container.
What You Need
Before starting, make sure Docker is installed on your development machine:
docker --versionYou will also need access to a private container registry and credentials that are allowed to push images to it.
Finally, Docker needs to be installed on the server where you plan to run the image.
For the examples below, I’ll use these values:
Registry: registry.example.com
Repository: my-project/my-app
Image: my-app
Tag: 1.0.0Replace them with the values from your own registry.
Step 1: Create a Private Registry
The exact setup depends on your provider.
In most registry dashboards, you create a registry or repository and receive a hostname similar to:
registry.example.comDepending on the provider, you may also create credentials such as a Key ID and Key Secret.
Keep the secret somewhere safe. It should never be committed to Git or placed directly in your application source code.
Cloud providers can use different authentication methods. For example, some registries use access tokens or service accounts instead of a permanent username and password. Check your provider’s authentication instructions before running docker login.
Once the registry exists, we can prepare the image.
Step 2: Build the Docker Image
Let’s assume the application already has a Dockerfile.
A small Node.js example might look like this:
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["npm", "start"]It’s also worth adding a .dockerignore file:
node_modules
.git
.env
npm-debug.log
Dockerfile*
README.mdThis keeps files that Docker doesn’t need out of the build context.
Now build the image:
docker build -t my-app:1.0.0 .Here:
my-app image name
1.0.0 image tag
. directory containing the DockerfileAfter the build finishes, check that the image exists:
docker image ls my-appYou should see something similar to:
REPOSITORY TAG IMAGE ID CREATED
my-app 1.0.0 a12bc34de567 1 minute agoOur image exists locally, but the private registry doesn’t know anything about it yet.
Step 3: Log In to the Registry
Before Docker can push a private image, it needs to authenticate with the registry.
You will often see commands like this:
docker login registry.example.com \
--username my-registry-key \
--password my-registry-secretIt works, but there is a better way.
Passing a secret directly through --password can expose it through shell history or process information. Use --password-stdin instead:
printf '%s' "$REGISTRY_PASSWORD" | \
docker login registry.example.com \
--username "$REGISTRY_USER" \
--password-stdinFor example, you could first provide the credentials through environment variables:
export REGISTRY_USER="my-registry-key"
export REGISTRY_PASSWORD="my-registry-secret"Then log in:
printf '%s' "$REGISTRY_PASSWORD" | \
docker login registry.example.com \
--username "$REGISTRY_USER" \
--password-stdinIf authentication succeeds, Docker prints:
Login SucceededOne detail is worth knowing here. Docker stores authentication configuration under:
~/.docker/config.jsonDepending on your Docker setup, credentials may be stored through a configured credential store rather than directly in that file.
Either way, don’t copy your Docker configuration into a repository.
Step 4: Tag the Image for the Registry
We currently have this local image:
my-app:1.0.0The registry needs a fully qualified image name:
registry.example.com/my-project/my-app:1.0.0We create that name with docker tag:
docker tag \
my-app:1.0.0 \
registry.example.com/my-project/my-app:1.0.0This does not rebuild or duplicate the image data. It simply gives the existing image another tag.
Run:
docker image lsand you should now see both names:
REPOSITORY TAG
my-app 1.0.0
registry.example.com/my-project/my-app 1.0.0The second one is the name we’ll push.
Step 5: Push the Image
Now the image is ready for the registry:
docker push registry.example.com/my-project/my-app:1.0.0Docker uploads the image layers and creates the 1.0.0 tag in the repository.
A successful push usually ends with output containing a digest:
1.0.0: digest: sha256:...You can then open your registry dashboard and check that the image appears there.
At this point the workflow looks like this:
my-app:1.0.0
|
| docker tag
v
registry.example.com/my-project/my-app:1.0.0
|
| docker push
v
Private RegistryThe image can now be pulled from another machine that has access to the registry.
Step 6: Pull the Image on Your Server
Connect to the server where you want to run the application.
First, make sure Docker is available:
docker --versionThen authenticate with the registry.
Again, avoid putting the secret directly into the command:
export REGISTRY_USER="my-registry-key"
export REGISTRY_PASSWORD="my-registry-secret"
printf '%s' "$REGISTRY_PASSWORD" | \
docker login registry.example.com \
--username "$REGISTRY_USER" \
--password-stdinNow pull the image:
docker pull registry.example.com/my-project/my-app:1.0.0Check that it arrived:
docker image lsYou should see:
registry.example.com/my-project/my-app 1.0.0The server now has exactly the image that we pushed earlier.
Step 7: Start the Container
We can run the image like any other Docker image:
docker run -d \
--name my-app \
-p 3000:3000 \
registry.example.com/my-project/my-app:1.0.0The options are straightforward:
-d run in the background
--name my-app name the container
-p 3000:3000 map host port 3000 to container port 3000Check whether the container is running:
docker psIf something went wrong, inspect its logs:
docker logs my-appFor live logs:
docker logs -f my-appThat is usually enough to spot an application startup error.
Don’t Use latest for Everything
Using latest is convenient:
docker build -t my-app:latest .but it doesn’t tell you which version of the application you’re actually running.
For deployments, explicit versions are much easier to reason about:
my-app:1.0.0
my-app:1.0.1
my-app:1.1.0Git commit hashes can also work well:
my-app:a9c83f1Then a server can pull one exact build:
docker pull registry.example.com/my-project/my-app:1.0.1If a deployment breaks, you know exactly which image was deployed and which previous version you can return to.
You can still add latest as an additional tag if your workflow needs it:
docker tag \
my-app:1.0.0 \
registry.example.com/my-project/my-app:latestThen push both:
docker push registry.example.com/my-project/my-app:1.0.0
docker push registry.example.com/my-project/my-app:latestThe important part is not to rely on latest as your only version identifier.
Updating the Application
Once the registry is configured, future deployments become much simpler.
Build a new version:
docker build -t my-app:1.0.1 .Tag it:
docker tag \
my-app:1.0.1 \
registry.example.com/my-project/my-app:1.0.1Push it:
docker push registry.example.com/my-project/my-app:1.0.1Then, on the server:
docker pull registry.example.com/my-project/my-app:1.0.1Stop and remove the old container:
docker stop my-app
docker rm my-appStart the new version:
docker run -d \
--name my-app \
-p 3000:3000 \
registry.example.com/my-project/my-app:1.0.1For a small project, this is already a perfectly understandable deployment workflow. Larger systems usually automate these steps through CI/CD, Docker Compose, Kubernetes, or another deployment platform.
Common Problems
If docker push returns an authentication error, first check that you’re logged in to the correct registry:
docker login registry.example.comAn error such as:
denied: requested access to the resource is deniedoften means the account doesn’t have permission to push to that repository, or the image was tagged with the wrong repository path.
If Docker cannot find the image:
An image does not exist locally with the tag...check your local tags:
docker image lsThe name passed to docker push must match the registry tag exactly.
Network and TLS errors are different. In that case, verify the registry hostname, HTTPS configuration, firewall rules, DNS, and whether the machine can actually reach the registry.
The Whole Workflow
Once you’ve done it a couple of times, the entire process comes down to a handful of commands:
# Build
docker build -t my-app:1.0.0 .
# Tag
docker tag \
my-app:1.0.0 \
registry.example.com/my-project/my-app:1.0.0
# Login
printf '%s' "$REGISTRY_PASSWORD" | \
docker login registry.example.com \
--username "$REGISTRY_USER" \
--password-stdin
# Push
docker push registry.example.com/my-project/my-app:1.0.0Then on the server:
# Login
printf '%s' "$REGISTRY_PASSWORD" | \
docker login registry.example.com \
--username "$REGISTRY_USER" \
--password-stdin
# Pull
docker pull registry.example.com/my-project/my-app:1.0.0
# Run
docker run -d \
--name my-app \
-p 3000:3000 \
registry.example.com/my-project/my-app:1.0.0Final Thoughts
A private registry sits between building an application and deploying it.
Instead of copying project files to a server and rebuilding everything there, you build an image, give it a clear version, push it to the registry, and pull that exact image wherever it needs to run.
The workflow is simple:
Build → Tag → Login → Push → Pull → RunOnce this works manually, automating the same steps in CI/CD is the natural next move. The commands don’t really change. You’re just letting your pipeline run them for you.