TL;DR
Three quick fixes depending on what's broken:
- Registry cert is expired โ renew it (Let's Encrypt or your CA)
- Your system clock is wrong โ sync with NTP (
timedatectl set-ntp true) - Dev/test environment and you don't care โ add registry to
insecure-registriesin Docker daemon config
The Full Error
Get "https://registry.example.com/v2/": x509: certificate has expired or is not yet valid: current time 2025-01-01T00:00:00Z is after 2024-12-31T23:59:59Z
You'll hit this on docker pull, docker push, or docker login โ any operation that opens a TLS connection to a registry. The timestamp baked into the error message is your first clue. Run date on your machine and compare it to what Docker saw. That 30-second check often tells you everything.
Root Cause
Every TLS certificate has a validity window defined by two fields: notBefore and notAfter. Docker's Go TLS stack rejects the connection the moment either boundary is crossed:
- The cert's
notAfteris in the past โ the registry cert genuinely expired - Your local clock is ahead of
notAfterโ a valid cert looks expired due to clock skew - Your local clock is behind
notBeforeโ the cert looks not-yet-valid
Clock skew is sneakier than it sounds. A VM that was suspended for a few hours, a container host without NTP, or a laptop that drifted 5 minutes โ any of these can trigger the exact same error as a genuinely expired cert.
Step 1 โ Check the Certificate Expiry
Don't guess. Pull the cert directly and read its dates:
# Check the registry cert directly
openssl s_client -connect registry.example.com:443 -servername registry.example.com 2>/dev/null \
| openssl x509 -noout -dates
# Output example:
# notBefore=Dec 1 00:00:00 2024 GMT
# notAfter=Dec 31 23:59:59 2024 GMT
If notAfter is before today, the cert is expired โ go to Fix A. If the dates look perfectly fine, your system clock is lying to Docker.
Step 2 โ Check System Clock Skew
# Check current time and NTP sync status
timedatectl status
# Or just:
date -u
Off by more than a minute or two? Fix it:
# Enable NTP sync (systemd-based Linux)
timedatectl set-ntp true
# Force an immediate step correction
sudo chronyc makestep
# or
sudo ntpdate -u pool.ntp.org
Suspended VMs are the usual culprit here โ the clock freezes while the host sleeps, then wakes up potentially hours behind. After syncing, retry docker pull. Clock was the problem? It works right away.
Fix A โ Renew the Registry Certificate
You control the registry and the cert is dead. Time to replace it.
Let's Encrypt (Certbot)
# Renew all certs
sudo certbot renew
# Force renewal even if not near expiry
sudo certbot renew --force-renewal --cert-name registry.example.com
Then restart your registry service:
# If running as a Docker container
docker restart registry
# Or systemd
sudo systemctl restart docker-registry
Self-signed cert (generate a fresh one)
openssl req -newkey rsa:4096 -nodes -sha256 \
-keyout /certs/domain.key \
-x509 -days 365 \
-out /certs/domain.crt \
-subj "/CN=registry.example.com" \
-addext "subjectAltName=DNS:registry.example.com"
Restart the registry container with the new cert paths mounted. Every Docker client machine also needs to trust the new cert โ see Fix B.
Fix B โ Trust a Self-Signed or Internal CA Cert
Corporate registries and internal CAs aren't in Docker's default trust store. The fix is to drop the cert into Docker's per-registry directory:
# Create the directory for this registry
sudo mkdir -p /etc/docker/certs.d/registry.example.com:5000
# Copy the CA cert (or the self-signed cert)
sudo cp ca.crt /etc/docker/certs.d/registry.example.com:5000/ca.crt
No daemon restart needed โ Docker reads this directory on each connection.
Want system-wide trust so curl, openssl, and everything else also accepts the cert?
# Ubuntu/Debian
sudo cp ca.crt /usr/local/share/ca-certificates/registry-ca.crt
sudo update-ca-certificates
# RHEL/CentOS/Fedora
sudo cp ca.crt /etc/pki/ca-trust/source/anchors/registry-ca.crt
sudo update-ca-trust
Fix C โ Insecure Registry (Dev/Test Only)
Stuck in a CI pipeline or local dev loop and just need it to work right now? Mark the registry as insecure. Production use is not an option here.
Edit /etc/docker/daemon.json:
{
"insecure-registries": ["registry.example.com:5000"]
}
Restart the daemon:
sudo systemctl restart docker
Docker will connect over HTTP or skip TLS verification for that registry. Fast to set up, genuinely dangerous in production โ you've been warned.
Verify the Fix
# Retry the pull
docker pull registry.example.com/myimage:latest
# Confirm the cert won't expire for at least another 24 hours
openssl s_client -connect registry.example.com:443 -servername registry.example.com 2>/dev/null \
| openssl x509 -noout -checkend 86400
# Outputs: "Certificate will not expire" if valid
# Hit the registry v2 API directly
curl -v https://registry.example.com/v2/
Prevent This from Happening Again
- Automate cert renewal:
certbot renewvia cron or systemd timer, running 30 days before expiry โ not the day it dies - Monitor expiry dates with
ssl-cert-checkor a Prometheusssl_expiryexporter; page yourself at 14 days out - Keep NTP running on every Docker host, especially VMs โ
timedatectl show | grep NTPSynchronizedis a fast check - Self-signed certs on a 365-day rotation expire faster than you remember; either automate the rotation or switch to Let's Encrypt
Quick Reference
# Check cert expiry
openssl s_client -connect HOST:PORT 2>/dev/null | openssl x509 -noout -dates
# Sync clock
timedatectl set-ntp true && sudo chronyc makestep
# Trust a CA cert (Docker only)
sudo mkdir -p /etc/docker/certs.d/HOST:PORT
sudo cp ca.crt /etc/docker/certs.d/HOST:PORT/ca.crt
# Insecure registry fallback (dev only)
# /etc/docker/daemon.json โ { "insecure-registries": ["HOST:PORT"] }
sudo systemctl restart docker

