Home | Send Feedback | Share on Bluesky |

Self-host a Docker registry server on Ubuntu

Published: 8. May 2020  •  selfhost

In this blog post, I'll show you how to install Docker and a Docker registry server on Ubuntu.

I'll run all steps as the root user. If you are not root, prepend sudo to the commands or start an interactive root shell with sudo -i.

Docker

First, update all installed packages.

apt update
apt full-upgrade

Install Docker from Docker's official apt repository:

apt install ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  tee /etc/apt/sources.list.d/docker.list > /dev/null

apt update
apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

systemctl start docker
systemctl enable docker

Test the installation with:

docker run --rm hello-world

You should see a hello message from Docker. The option --rm automatically deletes the container after it exits.

Firewall

On servers connected to the Internet, I usually install a firewall. On Ubuntu systems, I use UFW for this purpose. On this installation, UFW is already installed (VPS server from Hetzner (referral link)). If it's not installed on your server, install it with apt install ufw.

I usually change the SSH daemon's default port 22 to another random port (/etc/ssh/sshd_config). In this installation, it's 35353. Open the port for SSH in the firewall.

ufw allow 35353/tcp
ufw enable

Double-check the port before you enable UFW.

Check the firewall status:

ufw status

Docker Registry

A Docker registry is like a Git repository where you can push images to and pull images from. The Docker registry server is a Docker container that we can start with this command:

mkdir /var/lib/docker-registry
docker run -d -p 5000:5000 -v /var/lib/docker-registry:/var/lib/registry --restart=always --name registry registry:2

Container port 5000 is mapped to local port 5000, and with --restart=always we ensure that the container starts when the host system boots up.

-v bind-mounts the host directory /var/lib/docker-registry into the registry container at /var/lib/registry.

We are using the standard settings of the Docker registry server here.

UFW / Docker Issue

At this point, we need to fix the UFW/Docker issue. Docker and UFW both create iptables rules. When you publish a container port with -p, Docker adds rules that can bypass UFW for published ports.

In this installation, we started the Docker registry server on port 5000, and this port is now reachable from the public network. You can verify that with nmap from another computer:

nmap <ip_address>

The output of nmap shows open ports:

PORT     STATE SERVICE
5000/tcp open  upnp

Note that port 35353 does not appear in this list because nmap scans only the most common 1,000 ports by default. Use -p- to scan all 65,535 ports.


We'll use the actively maintained utility from this repository to apply the fix:

https://github.com/chaifeng/ufw-docker

Install the utility:

wget -O /usr/local/bin/ufw-docker https://github.com/chaifeng/ufw-docker/raw/master/ufw-docker
chmod +x /usr/local/bin/ufw-docker

Preview and install the UFW changes:

ufw-docker check
ufw-docker install --docker-subnets
ufw reload

ufw-docker install --docker-subnets updates UFW so that Docker keeps its normal networking behavior, while published ports are no longer exposed publicly by default.

Run nmap again. Port 5000 should no longer be accessible.

If you want to expose a specific container port later, use ufw-docker allow. Example:

ufw-docker allow registry 5000/tcp

Remove that rule again with:

ufw-docker delete allow registry 5000/tcp

For details, see the ufw-docker utility documentation.

Reverse proxy

Now we can no longer connect to the Docker registry server directly from the outside. However, the purpose of a registry is to pull and push images from other computers. We could open the port in UFW, but for this demo installation, I want to use Nginx as a reverse proxy.

It is worth noting that exposing a registry without authentication is risky. In this setup, we keep the registry port protected by firewall rules and expose access through Nginx with Basic Authentication and TLS.


First, I added A and AAAA records to my DNS configuration. For this installation, I use the domain docker-registry.ralscha.ch. A domain is needed if you want to request a TLS certificate from Let's Encrypt.

Install Nginx and certbot:

apt install nginx
apt install python3-certbot-nginx

Open ports 80 and 443 in the UFW firewall:

ufw allow 80/tcp
ufw allow 443/tcp

We protect access to the registry with Basic Authentication and need to create a password file for Nginx. We can run the registry image to create this file. Here with user demouser and password mysupersecretpassword.

docker run --rm --entrypoint htpasswd registry:2 -Bbn demouser mysupersecretpassword > /etc/nginx/nginx.htpasswd

Note that this only protects external access to the registry. As mentioned before, somebody with shell access on the server could still push images if they can reach the local registry endpoint.


Create the Nginx configuration file:

cd /etc/nginx/sites-available/
rm default
rm ../sites-enabled/default

nano registry

Paste the following configuration into the file:

server {
  listen 80;
  listen [::]:80;
  server_name docker-registry.ralscha.ch;
  client_max_body_size 0;
  chunked_transfer_encoding on;

  location /v2/ {
    if ($http_user_agent ~ "^(docker\/1\.(3|4|5(?!\.[0-9]-dev))|Go ).*$" ) {
      return 404;
    }
    auth_basic "Docker Registry Realm";
    auth_basic_user_file /etc/nginx/nginx.htpasswd;
    proxy_pass                          http://localhost:5000;
    proxy_set_header  Host              $http_host;
    proxy_set_header  X-Real-IP         $remote_addr;
    proxy_set_header  X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header  X-Forwarded-Proto $scheme;
    proxy_read_timeout                  900;
  }
}

Check out this site for more information:
https://docs.docker.com/registry/


Enable the site and restart Nginx:

ln -s /etc/nginx/sites-available/registry /etc/nginx/sites-enabled/registry
systemctl restart nginx

In the configuration above, we only configured HTTP (port 80). The next step is to create a TLS certificate and configure HTTPS (port 443). The certbot command-line tool conveniently performs both tasks.

certbot --nginx

When you run this command for the first time, it asks you about your email, which is used for urgent renewal and security notices. The command then lists all configured Nginx sites, in this case, only one.

Which names would you like to activate HTTPS for?
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
1: docker-registry.ralscha.ch
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Select the appropriate numbers separated by commas and/or spaces, or leave input
blank to select all options shown (Enter 'c' to cancel):

Select the site for which you want to create the TLS certificate.

Certbot acquires the TLS certificate and updates the Nginx configuration file.

Using Docker registry

We can now use our self-hosted Docker registry.

I'm testing this with a Quarkus starter application. I run the following commands on my development computer.

mvn io.quarkus.platform:quarkus-maven-plugin:create -DprojectGroupId=ch.ralscha -DprojectArtifactId=test -DclassName="ch.ralscha.test.GreetingResource" -Dpath="/hello"
cd test
./mvnw quarkus:add-extension -Dextensions="container-image-docker"
./mvnw clean package -Dquarkus.container-image.build=true

On my computer, this creates an image with the name sr/test:1.0-SNAPSHOT. Before you push images to a remote registry, you need to tag them correctly. The tag must start with the registry server address (docker-registry.ralscha.ch/).

docker tag sr/test:1.0-SNAPSHOT docker-registry.ralscha.ch/test
docker login docker-registry.ralscha.ch
docker push docker-registry.ralscha.ch/test

The login command asks for the credentials. Enter demouser and mysupersecretpassword. These are the username and password we configured in the previous step.

It's important to note that docker login stores your credentials in plaintext in a file by default. Check out this documentation on how to configure a more secure solution.

The push command uploads the image from your local Docker installation to the remote registry.

You can check the stored images in the registry with this command.

curl --user demouser:mysupersecretpassword https://docker-registry.ralscha.ch/v2/_catalog

The Docker registry server provides a REST API. See this page for more information.

For testing purposes, you can now pull the image. Delete the images in your local Docker installation, pull the image from the remote registry, and run it.

docker image remove sr/test:1.0-SNAPSHOT
docker image remove docker-registry.ralscha.ch/test

docker pull docker-registry.ralscha.ch/test
docker run docker-registry.ralscha.ch/test

That concludes this tutorial on setting up a self-hosted Docker registry server. Don't forget to back up the registry regularly if you use this setup in production. If you followed the tutorial, all registry data is stored in /var/lib/docker-registry.