# Docker insecure-registries not configured in CI

> Docker insecure-registries not configured is usually an entry that misses, because the daemon matches the host and port as one exact string.

Source: https://latchkey.dev/learn/docker/docker-insecure-registry-not-configured  
Updated: 2026-09-20

Docker insecure-registries not configured is the state where the daemon builds only an HTTPS endpoint for a registry and has no plain HTTP one to fall back to, and the commonest version of it is an entry that is present and does not match. The daemon compares a plain entry against the host and port as one exact string, so leaving the port off silently covers nothing.

## What this error means

Pulls and pushes to an internal registry fail the same way on every run, usually against a host reached by IP or on a nonstandard port, while the same registry works from a laptop that was configured months ago. Two separate failures wear this label. A malformed entry stops the daemon from loading the configuration at all and names the entry. A well formed entry that does not match the reference leaves the daemon behaving as though nothing was configured. The block below shows one of each, as the daemon formats the first and as a workflow would write the second, since no run is recorded for this page.

```A daemon validation message and the entry shape that silently misses
--- the daemon refusing an entry that carries a scheme
insecure registry https://registry.example.internal:5000 should not contain '://'
--- an entry that loads cleanly and never applies, because references carry the port
{ "insecure-registries": ["registry.example.internal"] }
```

## Common causes

### The entry omits the port the reference carries

The most frequent version, because people write the hostname they think of as the registry and address it on a nonstandard port. The entry loads without complaint, the daemon reports it in its configuration, and the lookup misses. In our experience this is the one that survives several rounds of debugging, precisely because the setting appears to be in place.

### The configuration was written but the daemon never reread it

The insecure list is loaded when the daemon starts. Writing the file in one step and pulling in the next uses the old configuration, and the failure looks identical to not having written anything. A job that restarts the daemon and does not wait for it to answer again has the same problem with extra steps.

### The entry carries a scheme

An entry beginning with http or https has the scheme stripped and a warning logged, which is survivable. An entry with any other scheme is a hard configuration error and the daemon says the entry should not contain a scheme separator. That one is loud, which makes it the easiest of these to fix.

### A subnet entry depends on resolution that fails

A subnet is matched by resolving the reference host and testing its addresses. When the lookup fails, the code treats that as no match rather than as an error, so a DNS problem on the runner presents as an insecure entry that does not apply. Using the address in the reference, or listing the host explicitly, removes the dependency.

## How to fix it

### Match the entry to the reference, character for character

Take the host and port exactly as they appear in the image reference and use that as the entry. If jobs use two spellings of the same registry, list both, because there is no relationship between them as far as the lookup is concerned.

```/etc/docker/daemon.json
{
  "insecure-registries": [
    "registry.example.internal:5000",
    "10.20.30.40:5000"
  ]
}
```

### Use a subnet when the registry answers to several names

1. Write the range the registry lives in rather than enumerating hostnames.
2. Confirm the runner can resolve those hostnames, since a failed lookup counts as no match.
3. Keep the range as tight as the network allows, because everything inside it becomes insecure.

```/etc/docker/daemon.json
{ "insecure-registries": ["10.20.30.0/24"] }
```

### Restart and then prove the daemon agrees with you

Do not assume the restart took. Ask the daemon what it now thinks its insecure configuration is, and fail the job if the answer does not contain what you wrote. This turns the silent version of the failure into a named one.

```Terminal
sudo systemctl restart docker
timeout 60 bash -c 'until docker info >/dev/null 2>&1; do sleep 2; done'
docker info 2>/dev/null | grep -A3 "Insecure Registries" \
  | grep -q "registry.example.internal:5000" \
  || { echo "::error::the daemon did not pick up the entry"; exit 1; }
```

### Prefer a certificate, and use the per-host trust directory

The daemon reads certificates from a directory named after the registry host when the host is not in the insecure list. That gives you TLS with a private certificate authority and none of the blanket verification skip that the insecure setting brings. It is more work once and less risk thereafter.

```Terminal
sudo mkdir -p /etc/docker/certs.d/registry.example.internal:5000
sudo cp internal-ca.crt /etc/docker/certs.d/registry.example.internal:5000/ca.crt
```

## How to prevent it

- Write the entry by copying the host and port out of an image reference, never by hand.
- Assert the daemon reports the entry after any restart the job performs.
- Keep one spelling of each internal registry across every workflow.
- Move to a private certificate authority once more than one runner needs the registry.

## What the setting does, in two separate ways

When the daemon looks up a registry it always builds an HTTPS endpoint first. It appends a plain HTTP endpoint only when the TLS configuration for that host has certificate verification switched off, and that switch is set from one question: is this host in the insecure list. So the setting has two effects at once. It makes the daemon tolerate a certificate it cannot verify, and it gives the daemon somewhere to go when HTTPS does not work at all.

That is why one setting appears as the answer to two different-looking problems, an untrusted certificate and a registry serving plain HTTP. It is also why turning it on is a real concession: you are not selectively allowing HTTP, you are also accepting any certificate that host presents.

| Entry you write | How the daemon matches it |
| --- | --- |
| `registry.example.internal:5000` | Stored as a key and compared to the reference host and port as one exact string |
| `registry.example.internal` | Also exact, so it never matches a reference that includes a port |
| `10.20.30.0/24` | Kept as a subnet; the reference host is resolved and its address compared |
| `https://registry.example.internal` | The scheme is stripped with a warning; any other scheme is rejected outright |

> Loading and matching read in the daemon registry configuration on 2026-09-20. The loopback ranges are appended to the subnet list unconditionally, so a registry on 127.0.0.1 is insecure with no configuration at all.

## The exact match is the trap

A plain entry is stored as a key in a map of index configurations, and the lookup uses the reference host exactly as it appears, which includes the port when there is one. There is no normalization step that adds a default port, and no prefix matching. So an entry of `registry.example.internal` does nothing for `registry.example.internal:5000`, and an entry with the port does nothing for a reference written without it.

A subnet entry behaves differently and more forgivingly. It is kept as a prefix, and at lookup time a hostname is resolved to addresses which are then tested against every configured subnet. That makes a subnet the right shape when the registry is reached by several names, and the wrong shape when name resolution on the runner is not reliable, since a failed lookup is treated as no match.

```.github/workflows/ci.yml
- name: Write an entry that matches the reference exactly
  run: |
    echo '{ "insecure-registries": ["registry.example.internal:5000"] }' \
      | sudo tee /etc/docker/daemon.json
    sudo systemctl restart docker
    timeout 60 bash -c 'until docker info >/dev/null 2>&1; do sleep 2; done'
    docker info --format "{{.RegistryConfig.InsecureRegistryCIDRs}} {{.RegistryConfig.IndexConfigs}}"
```

## Loopback is already insecure, which hides the problem locally

The daemon appends the IPv4 loopback range and the IPv6 loopback address to its subnet list before it reads anything you wrote. A registry container published on the runner and addressed as `127.0.0.1:5000` therefore works with no configuration, which is exactly how most people first try it.

Then the same registry, addressed by the name a service container gets or by the address a second machine uses, fails. Nothing changed about the registry. The reference stopped being loopback, and the implicit entry stopped applying.

## Why no recorded run backs this page

This page is about a setting that only takes effect when the daemon starts. A recorded run would have to rewrite the configuration and restart the daemon inside the job, and a restarted daemon on a hosted runner is a different daemon from the one the log started with, so the log would show a before and an after that are not really comparable. The honest version of that evidence is the configuration and the matching rule, not a transcript.

There is also nothing here for a runner to repair. The daemon is doing what it was configured to do, correctly, and the disagreement is between an entry and a reference. Retrying, resizing or warming a cache leaves the comparison exactly as it was.

## FAQ

### Do I need to restart the Docker daemon after editing the insecure list?

Yes. The list is read when the daemon loads its configuration, so an edit made while it is running has no effect on the current process. Restart it and then confirm it answers again before the next step, because a job that continues immediately will often be talking to a daemon that is still coming up.

### Why does my registry work on localhost and not by hostname?

Because the daemon appends the loopback ranges to its insecure subnet list before it reads your configuration. A reference addressed at 127.0.0.1 is therefore insecure whether you configured anything or not. The moment the reference names a hostname or a routable address, that implicit entry stops applying and yours has to match instead.

### Does insecure-registries turn off certificate checking too?

Yes, and that is the part worth being deliberate about. Listing a host sets certificate verification off for it as well as adding the plain HTTP endpoint, so any certificate that host presents will be accepted. If your problem is a private certificate authority rather than plain HTTP, the per-host certificate directory is the narrower fix.

### Can I put a whole subnet in the list?

Yes, and the daemon treats subnet entries differently from plain ones. A subnet is matched by resolving the reference host to addresses and testing them, so it covers every name that points into the range. The cost is that a name which fails to resolve counts as no match, so it is only as reliable as resolution on the runner.

## References

- [moby: loading the insecure list and matching a host against it](https://github.com/moby/moby/blob/master/daemon/pkg/registry/config.go)
- [moby: the endpoint lookup that adds an HTTP fallback only when verification is off](https://github.com/moby/moby/blob/master/daemon/pkg/registry/service_v2.go)
- [Docker docs: dockerd and the daemon configuration file](https://docs.docker.com/reference/cli/dockerd/)
- [Docker docs: certificates for repository client verification](https://docs.docker.com/engine/security/certificates/)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
