Containers

Set Up a Private Docker Registry on Fedora 45 / 44

A private Docker registry on Fedora is one container and three files you have to get right. The TLS certificate must be trusted by every client, the htpasswd file needs a bcrypt hash, and SELinux has to let the container read its storage. Miss one and the client error rarely names the cause, so the docker registry fedora guides that still switch TLS off cause more trouble than they save.

Original content from computingforgeeks.com - post 5084

This guide runs the upstream registry:3 image from the CNCF Distribution project as a Podman Quadlet service, with TLS, htpasswd authentication, SELinux enforcing and firewalld. A second Fedora machine pushes and pulls through Docker CE and Podman, and every error we hit has its fix at the end. Every command below was run on Fedora 45 with the registry 3.1.2 image in November 2026, and the core path was repeated on a Fedora 44 box.

Prerequisites

You need two Fedora machines on the same network. One runs the registry, the other is the client that builds and pushes images; a single box works too if you only want to try it, but a second host is the only honest test of the certificate trust. The lab used the setup below.

  • Registry host: Fedora 45 or 44, 2 vCPU, 4 GB RAM, 20 GB disk, IP 10.0.1.50
  • Client: Fedora 45 or 44 with Docker CE, Podman or both, IP 10.0.1.51
  • A sudo user on both, SELinux in enforcing mode
  • A hostname for the registry, here registry.example.com, in DNS or /etc/hosts
  • TCP port 5000 reachable from the client

Disk is the number to size for. The registry itself idled at about 10 MB of RAM in the lab. Every image layer you push lands on disk once per unique digest, so a CI pipeline that adds 300 MB of new layers per commit fills 20 GB in under 70 builds unless you prune. Put /opt/registry/data on its own volume for anything beyond a lab.

Set the shell variables

The hostname, directory and credentials repeat in almost every command, so export them once on both machines. Change the password before you paste it; the value here is a placeholder.

export REGISTRY_HOST="registry.example.com"
export REGISTRY_DIR="/opt/registry"
export REGISTRY_USER="admin"
export REGISTRY_PASS="ChangeMe#Reg2026"

The values only live in the current shell. If you reconnect or switch to sudo -i, run the block again. Without DNS for the registry name, map it to the registry host’s IP on both machines.

echo "10.0.1.50 ${REGISTRY_HOST}" | sudo tee -a /etc/hosts

The name matters more than it looks, because Docker and Podman match the trusted certificate by the exact host:port string you push to. Pushing to the IP while the certificate names the hostname fails the TLS check.

Install Podman, firewalld and htpasswd

Fedora Server and Workstation ship Podman and firewalld already, but the Cloud image we tested has Podman and no firewalld, and none of them carry htpasswd. It lives in httpd-tools. Install all of it on the registry host and start the firewall.

sudo dnf install -y podman httpd-tools firewalld openssl
sudo systemctl enable --now firewalld

Confirm the host before going further. You want SELinux in Enforcing and firewalld running:

Fedora 45 registry host showing kernel, SELinux enforcing, Podman version and firewalld running

Fedora also packages the same registry as docker-distribution, and the Fedora 45 repo carries the same 3.1.x line as the container. We still run the container because the upstream image gets security releases the day they ship, and because the Quadlet file below is the whole config you have to keep track of.

Create the TLS certificate and htpasswd file

The registry keeps three directories: image data, the auth file and the certificate. Create them under one parent so backups and SELinux labels cover everything in one go.

sudo mkdir -p "${REGISTRY_DIR}"/{data,auth,certs}

Each directory maps to one path inside the container, and you will back up all three together:

  • data: image layers and manifests, mounted at /var/lib/registry
  • auth: the htpasswd user list, mounted at /auth
  • certs: the certificate and private key, mounted at /certs

Now generate a self-signed certificate. The subjectAltName extension is the part people skip, and Go-based clients like Docker and Podman ignore the CN completely, so a certificate without a SAN entry for your hostname fails every login.

sudo openssl req -x509 -newkey rsa:4096 -sha256 -days 825 -nodes \
  -keyout "${REGISTRY_DIR}/certs/domain.key" \
  -out "${REGISTRY_DIR}/certs/domain.crt" \
  -subj "/CN=${REGISTRY_HOST}" \
  -addext "subjectAltName=DNS:${REGISTRY_HOST}"

Check that the SAN and expiry came out the way you expect.

sudo openssl x509 -in "${REGISTRY_DIR}/certs/domain.crt" -noout -subject -ext subjectAltName -enddate

The hostname shows up in both the subject and the SAN list, and 825 days puts expiry a little over two years out.

subject=CN=registry.example.com
X509v3 Subject Alternative Name:
    DNS:registry.example.com
notAfter=Feb  5 12:56:55 2029 GMT

If you already run an internal CA or the registry name is public, use a certificate from it instead and skip the client trust steps later. The registry only reads domain.crt and domain.key, so a CA-signed pair drops into the same paths. Next, create the user. The registry accepts only bcrypt hashes, which is what -B selects:

sudo htpasswd -Bbc "${REGISTRY_DIR}/auth/htpasswd" "${REGISTRY_USER}" "${REGISTRY_PASS}"

The hash in the file starts with admin:$2y$05, which is bcrypt. An MD5 hash from plain htpasswd without -B makes every login fail even with the right password. Add more users later with the same command minus -c, because -c recreates the file and drops everyone else.

Run the registry as a Quadlet service

On Fedora the clean way to keep a container running across reboots is a Quadlet file: systemd generates a normal service from it, so systemctl and the journal work as with any other daemon. The volumes carry :Z, and that suffix is the SELinux step people miss. Before writing the unit we started the container with a plain podman run and no :Z, and it died within a second:

Registry container exits with open /certs/domain.crt permission denied until the bind mounts get the SELinux :Z label on Fedora

The certificate file was world-readable, so the denial came from SELinux, not file permissions. The new certs directory carried the host’s cert_t type, which a container_t process may not read. Adding :Z relabels the directory to container_file_t with the container’s private MCS categories. What made this one slow to diagnose is that ausearch -m avc printed nothing on our Fedora 45 Cloud VM, with auditd active and even with dontaudit rules switched off, and journalctl -k had no AVC line either, so the registry’s log line was the only clue. Never fix it with setenforce 0. With the labels in the unit, open the Quadlet file:

sudo vim /etc/containers/systemd/registry.container

Paste the unit exactly as we ran it. It publishes port 5000, points the registry at the certificate and htpasswd file through environment variables, and turns on deletes so you can prune images later.

[Unit]
Description=Private container registry (distribution)
Wants=network-online.target
After=network-online.target

[Container]
ContainerName=registry
Image=docker.io/library/registry:3
PublishPort=5000:5000
Volume=/opt/registry/data:/var/lib/registry:Z
Volume=/opt/registry/certs:/certs:Z
Volume=/opt/registry/auth:/auth:Z
Environment=REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt
Environment=REGISTRY_HTTP_TLS_KEY=/certs/domain.key
Environment=REGISTRY_AUTH=htpasswd
Environment="REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm"
Environment=REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd
Environment=REGISTRY_STORAGE_DELETE_ENABLED=true

[Service]
Restart=always

[Install]
WantedBy=multi-user.target

Every REGISTRY_* variable maps to a key in the registry configuration reference, so storage drivers, proxy caching or token auth are a few more Environment= lines away. Quadlet units are generated, so there is no systemctl enable; the [Install] section handles boot. Reload systemd so the generator picks up the file, then start it.

sudo systemctl daemon-reload
sudo systemctl start registry.service
systemctl status registry.service --no-pager

If the unit is not found after the reload, run sudo /usr/libexec/podman/quadlet -dryrun; it prints the generated ExecStart line or the parse error in your file. A healthy start reads active (running) with the Quadlet file listed as the source.

Open port 5000 in firewalld

Open the port in firewalld so the host’s rules say what the box serves, and make it permanent so it survives a reboot. You should know what this rule does not do, though. In a later test we removed 5000/tcp from the zone and the client still got its 401. That happens because rootful Podman’s netavark backend DNATs the published port in its own nftables table and the traffic never meets the zone’s input rules.

sudo firewall-cmd --permanent --add-port=5000/tcp
sudo firewall-cmd --reload
sudo firewall-cmd --list-ports

Now test the API from the registry host itself. Without credentials the /v2/ endpoint must answer 401, which proves auth is on; with them, the catalog comes back empty.

curl -s -o /dev/null -w "%{http_code}\n" --cacert "${REGISTRY_DIR}/certs/domain.crt" "https://${REGISTRY_HOST}:5000/v2/"
curl -s --cacert "${REGISTRY_DIR}/certs/domain.crt" -u "${REGISTRY_USER}:${REGISTRY_PASS}" "https://${REGISTRY_HOST}:5000/v2/_catalog"

The whole server side fits in one capture: the Quadlet unit active, port 5000 open, a 401 for anonymous requests and {"repositories":[]} for the admin user:

Docker registry Quadlet unit active on Fedora with firewalld port 5000 open and authenticated catalog check

That netavark behaviour means a zone rich rule will not limit who reaches your registry. The htpasswd file and TLS are your real access control here, and a network firewall in front of the host is where you restrict source subnets. The firewalld zones and rich rules guide covers the rules for services that do run on the host itself. The server is done at this point; everything after this happens on the client.

Install Docker CE on the client

Fedora’s own moby-engine works as a client too, but most readers push from Docker CE, so that is what we tested. Docker’s Fedora tree had the full package set when we checked, buildx included. Add the repo and install.

sudo dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/fedora/docker-ce.repo
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

The transaction pulls these packages, and with -y dnf imports Docker’s signing key on its own, so check that the fingerprint it prints matches the one below.

 containerd.io             x86_64 0:2.4.2-3.fc45  docker-ce-stable 115.6 MiB
 docker-buildx-plugin      x86_64 0:0.38.1-1.fc45 docker-ce-stable  72.4 MiB
 docker-ce                 x86_64 3:29.9.2-1.fc45 docker-ce-stable 111.2 MiB
 docker-ce-cli             x86_64 1:29.9.2-1.fc45 docker-ce-stable  36.6 MiB
 docker-compose-plugin     x86_64 0:5.7.0-1.fc45  docker-ce-stable  32.6 MiB
 docker-ce-rootless-extras x86_64 0:29.9.2-1.fc45 docker-ce-stable  20.2 MiB
Importing OpenPGP key 0x621E9F35:
 Fingerprint: 060A61C51B558A7F742B77AAC52FEB6B621E9F35
Complete!

Start Docker and add your user to the docker group, then log out and back in (or run newgrp docker) so the group applies.

sudo systemctl enable --now docker
sudo usermod -aG docker "${USER}"

If dnf on your machine reports a 404 for Docker’s repo, Docker has not published a tree for your release yet; the third-party repo 404 fixes show how to pin the previous release’s tree in the meantime. The full Docker setup, including rootless mode, is in the Docker on Fedora install guide.

Trust the certificate and log in from Docker

Docker refuses a self-signed certificate until you tell it which CA to trust for this one registry. Try a login first and you will see the error you would otherwise meet in a CI job.

echo "${REGISTRY_PASS}" | docker login "${REGISTRY_HOST}:5000" -u "${REGISTRY_USER}" --password-stdin

Without the certificate in place, the daemon rejects the TLS handshake.

Error response from daemon: Get "https://registry.example.com:5000/v2/": tls: failed to verify certificate: x509: certificate signed by unknown authority

Copy the certificate from the registry host, then install it under a directory named after the exact host:port. Install it with mode 644, because the Podman section below runs rootless and a root-only file breaks it.

scp 10.0.1.50:/opt/registry/certs/domain.crt /tmp/domain.crt
sudo install -D -m 644 /tmp/domain.crt "/etc/docker/certs.d/${REGISTRY_HOST}:5000/ca.crt"

Docker reads certs.d on every connection, so no daemon restart is needed. Run the same login again; feeding the password on stdin keeps it out of your shell history and the process list.

echo "${REGISTRY_PASS}" | docker login "${REGISTRY_HOST}:5000" -u "${REGISTRY_USER}" --password-stdin

The capture shows the failed login, the trust step and the successful login one after the other.

Docker login fails with x509 certificate signed by unknown authority, then succeeds after adding ca.crt to /etc/docker/certs.d

Docker stores the credential base64-encoded in ~/.docker/config.json and warns about it. On a shared build host, set up a credential helper such as pass or a secret service before you put a real password there.

Push and pull an image with Docker

A push goes to whatever registry the image name starts with, so tag a public image with your registry’s name first.

docker pull alpine:latest
docker tag alpine:latest "${REGISTRY_HOST}:5000/alpine:latest"
docker push "${REGISTRY_HOST}:5000/alpine:latest"

To prove the copy came from your registry and not Docker Hub, remove both local tags, pull it back and run it.

docker image rm "${REGISTRY_HOST}:5000/alpine:latest" alpine:latest
docker pull "${REGISTRY_HOST}:5000/alpine:latest"
docker run --rm "${REGISTRY_HOST}:5000/alpine:latest" cat /etc/alpine-release

The push ends with a digest, and the pull from the private registry returns the same digest.

Docker CE on Fedora pushing alpine to the private registry, pulling it back and running it

That Not all multiplatform-content is present note is new with Docker’s containerd image store. alpine:latest is a multi-arch index, but the pull only fetched the amd64 image. Docker pushed that one platform manifest, so the registry’s digest (c266...) is the amd64 manifest’s digest, not the index digest you see on Docker Hub. It is harmless for an amd64 fleet. If ARM machines pull from this registry too, mirror the full index with docker buildx imagetools create or skopeo copy --all instead of tag and push.

Push from Podman on the same client

Podman checks its own certs.d directories first and falls back to /etc/docker/certs.d only when none of them has an entry for the registry. We confirmed the fallback: with only the Docker copy in place, podman login and podman pull both worked. A copy in Podman’s own directory wins, though, and rootless Podman reads it as your user. Our first copy there ended up root-only (0600); Docker never noticed the same mode on its own copy because its daemon runs as root, but Podman did:

Rootless Podman login fails with permission denied on a 0600 ca.crt in /etc/containers/certs.d until the file is made readable

On a Podman-only client there is no Docker copy to fall back on, so install the certificate in Podman’s directory with mode 644 from the start and log in. Doing it on a mixed client too keeps Podman independent of the Docker files.

scp 10.0.1.50:/opt/registry/certs/domain.crt /tmp/domain.crt
sudo install -D -m 644 /tmp/domain.crt "/etc/containers/certs.d/${REGISTRY_HOST}:5000/ca.crt"
echo "${REGISTRY_PASS}" | podman login "${REGISTRY_HOST}:5000" -u "${REGISTRY_USER}" --password-stdin

Each client looks for the registry certificate in its own place. Both use the same host:port directory name, and a mismatch in either part is treated as an unknown registry.

ClientCertificate pathWho reads it
Docker CE/etc/docker/certs.d/registry.example.com:5000/ca.crtdockerd as root
Podman, Buildah, Skopeo/etc/containers/certs.d/registry.example.com:5000/ca.crtRoot, or your user when rootless
Rootless Podman, per user~/.config/containers/certs.d/registry.example.com:5000/ca.crtYour user
Podman fallback/etc/docker/certs.d/registry.example.com:5000/ca.crtUsed only when no containers certs.d entry exists
curl, dnf, the system/etc/pki/ca-trust/source/anchors/ plus update-ca-trustEverything using the system bundle

To give Podman something of its own to push, build a small image on top of the alpine copy that already sits in the registry. If you skipped the Docker section, put that copy there with Podman first, which is what the Fedora 44 smoke run did:

podman pull docker.io/library/alpine:latest
podman tag docker.io/library/alpine:latest "${REGISTRY_HOST}:5000/alpine:latest"
podman push "${REGISTRY_HOST}:5000/alpine:latest"

Then create a Containerfile:

vim Containerfile

Add these three lines, swapping in your registry name if it differs:

FROM registry.example.com:5000/alpine:latest
RUN apk add --no-cache curl
CMD ["curl", "--version"]

Build it under a namespaced name, push it, and ask the registry API what it now holds.

podman build -t "${REGISTRY_HOST}:5000/tools/curl:1.0" -f Containerfile .
podman push "${REGISTRY_HOST}:5000/tools/curl:1.0"
curl -s --cacert "/etc/containers/certs.d/${REGISTRY_HOST}:5000/ca.crt" -u "${REGISTRY_USER}:${REGISTRY_PASS}" "https://${REGISTRY_HOST}:5000/v2/_catalog"
curl -s --cacert "/etc/containers/certs.d/${REGISTRY_HOST}:5000/ca.crt" -u "${REGISTRY_USER}:${REGISTRY_PASS}" "https://${REGISTRY_HOST}:5000/v2/tools/curl/tags/list"

The catalog lists both repositories, and the tag list shows 1.0 under tools/curl:

Podman building an image, pushing it to the private registry on Fedora and listing the catalog and tags

Docker pulled tools/curl:1.0 straight afterwards and ran it, so images move freely between the two clients. Podman also falls back to Docker’s ~/.docker/config.json for credentials, which is why podman logout told us “Existing credentials were established via docker login”. For day-to-day Podman flags, the Podman cheat sheet has the push, login and Quadlet commands on one page.

Run the registry with Docker CE instead

If the registry host already runs Docker CE and you would rather not add Podman, the same image, files and environment work with docker run. We ran this on the Docker client with the same certificate and htpasswd copied into /opt/registry:

docker run -d --restart=always --name registry -p 5000:5000 \
  -v /opt/registry/data:/var/lib/registry:Z \
  -v /opt/registry/certs:/certs:Z \
  -v /opt/registry/auth:/auth:Z \
  -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \
  -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \
  -e REGISTRY_AUTH=htpasswd \
  -e REGISTRY_AUTH_HTPASSWD_REALM="Registry Realm" \
  -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
  registry:3

It came up on the first try and answered the authenticated catalog query locally.

registry  Up 6 seconds  0.0.0.0:5000->5000/tcp, [::]:5000->5000/tcp
{"repositories":[]}

Keep the :Z on Docker too. Docker CE leaves SELinux labeling off by default, so the flag looks unnecessary today. Once someone sets "selinux-enabled": true in daemon.json, though, the container runs confined and needs the relabel just like the Podman run above. Compared with the Quadlet setup, three things change:

  • --restart=always takes the place of the systemd unit
  • docker logs registry replaces journalctl -u registry
  • Garbage collection runs with docker exec instead of podman exec

Our run left out -e REGISTRY_STORAGE_DELETE_ENABLED=true; add it before registry:3 if you plan to use the delete section below, or every DELETE returns 405. The certificate paths on the clients and every push and pull command stay the same. Firewalld is different: Docker puts its published ports in its own docker zone and forwarding policy, as described in Docker’s packet filtering and firewalls docs, and its docker-forwarding policy accepts forwarded traffic from any zone. We only ran the remove-port test against Podman, but expect the same result here: the zone rule above does not decide who can connect.

Delete images and reclaim disk space

The API deletes manifests by digest, which also drops every tag that points at that digest, and the layers stay on disk until garbage collection runs. That is why the Quadlet file sets REGISTRY_STORAGE_DELETE_ENABLED=true, because without it every DELETE returns 405. Run these on the registry host. First fetch the manifest digest for the tag, then delete by digest.

DIGEST=$(curl -sI --cacert "${REGISTRY_DIR}/certs/domain.crt" -u "${REGISTRY_USER}:${REGISTRY_PASS}" \
  -H "Accept: application/vnd.oci.image.manifest.v1+json" \
  "https://${REGISTRY_HOST}:5000/v2/tools/curl/manifests/1.0" \
  | awk -F': ' 'tolower($1)=="docker-content-digest" {print $2}' | tr -d '\r')
echo "${DIGEST}"
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE --cacert "${REGISTRY_DIR}/certs/domain.crt" \
  -u "${REGISTRY_USER}:${REGISTRY_PASS}" "https://${REGISTRY_HOST}:5000/v2/tools/curl/manifests/${DIGEST}"

A 202 means the manifest is gone. Podman built this image in its default OCI format, hence the OCI Accept header. If the HEAD request prints no digest for an image stored in the older Docker format, ask for application/vnd.docker.distribution.manifest.v2+json instead. Now run the garbage collector inside the container.

sudo podman exec registry registry garbage-collect --delete-untagged /etc/distribution/config.yml
sudo du -sh /opt/registry/data

Our registry dropped from 6.3 MB to 3.8 MB, and the tag list for tools/curl came back empty:

Deleting a manifest by digest through the registry API and running registry garbage-collect to reclaim disk on Fedora

We ran it with the registry up but idle. The upstream garbage collection docs want the registry in read-only mode or stopped, because a push that lands during the run can lose layers. If your CI writes to the registry, schedule the run in a window when nothing pushes. We also rebooted the registry host after all of this: the Quadlet unit came back on its own and port 5000 was still open, and both clients pulled again without logging in a second time.

Registry service active and port 5000 still open after rebooting the Fedora registry host

Back up /opt/registry as a whole: the data directory holds the images, and the auth and certs directories are what every client trusts. Losing the key means re-trusting a new certificate on every client.

Fix common registry errors

Each of these came up in the lab, with the exact message the client printed. They are in the order a new setup tends to hit them, so if you followed the steps above, the last one is the only one you should meet, usually after someone mistypes a password in a CI secret.

http: server gave HTTP response to HTTPS client

Docker and Podman always try HTTPS first. A registry started without the TLS variables, as in the upstream quick-start, speaks plain HTTP, and the push fails like this:

Docker CE push to a plain HTTP registry failing with http: server gave HTTP response to HTTPS client

Fix the server, not the client: add the certificate as shown above. Listing the registry under insecure-registries in /etc/docker/daemon.json silences the error, but it sends your htpasswd credentials in clear text across the network and has to be repeated on every client, which is a worse deal than one certificate.

x509: certificate signed by unknown authority

The client has no copy of your self-signed certificate under the right host:port directory. Check the directory name first, because registry.example.com:5000 and registry.example.com are different keys. If you push by IP, the certificate needs an IP: entry in its SAN as well.

open /certs/domain.crt: permission denied

The registry container cannot read a bind-mounted directory because it still has its host SELinux label. Add :Z to each volume and restart. If the directory is shared by several containers, use the lowercase :z, which applies a shared label instead of a private one. The SELinux survival guide covers labels in more depth.

open /etc/containers/certs.d/…/ca.crt: permission denied

Rootless Podman reads the trust directory as your user, so a root-only ca.crt blocks login, pull and even podman build when the base image comes from the registry. sudo chmod 644 on the file fixes it.

invalid username/password

Podman prints Error: logging into "registry.example.com:5000": invalid username/password for a wrong password, and Docker answers login attempt to https://registry.example.com:5000/v2/ failed with status: 401 Unauthorized. When the password is right and it still fails, open the htpasswd file: a hash that does not start with $2y$ was not made with -B, and the registry rejects it. Regenerate it with the htpasswd command from earlier. No restart is needed, because the registry’s htpasswd handler reloads the file whenever its modification time changes.

Keep reading

Best UI Applications for Managing Docker Containers Containers Best UI Applications for Managing Docker Containers Install Docker and Run Containers on Ubuntu 24.04|22.04 Containers Install Docker and Run Containers on Ubuntu 24.04|22.04 Install UniFi OS Server on Ubuntu 24.04 LTS Containers Install UniFi OS Server on Ubuntu 24.04 LTS CapRover Docker Install on Debian 13: Deploy Your First App Containers CapRover Docker Install on Debian 13: Deploy Your First App Best OpenShift Books for Admins and EX280 Candidates Books Best OpenShift Books for Admins and EX280 Candidates Install CapRover Self-Hosted PaaS on Ubuntu 26.04 / 24.04 Containers Install CapRover Self-Hosted PaaS on Ubuntu 26.04 / 24.04

Leave a Comment

Press ESC to close