Podman Image Signature Policy
Two identical images in one registry, one signed. After a policy change the signed one pulls and the other is refused with `A signature was required, but no signature exists` - while docker.io keeps working, because policy is per-registry.
Security and Operations Guide 43 of 47 Advanced
- OSUbuntu 26.04 LTS (resolute)
- Podman5.7.0
- Runtimecrun 1.21
- Networknetavark 1.16.1
- TimeAbout 16 min
- Reviewed22 August 2026
Written against the versions above. Podman follows the distribution here rather than a vendor repository, so the version you get is the one Ubuntu shipped. The commands are stable across 5.x.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| PODMAN01 | 192.168.0.21 | Ubuntu 26.04 LTS | Primary Container Host | 2 Core | 4 GB | 50 GB |
| PODMAN03 | 192.168.0.23 | Ubuntu 26.04 LTS | Registry and Image Transfer Host | 2 Core | 4 GB | 50 GB |
Before you start
- guide 74 - the registry on podman03 is where both images go.
gpg. The key used here is a passphraseless RSA key made for the lab.
-
The default is to trust anything
/etc/containers/policy.jsonon a fresh install is four lines long:{ "default": [ { "type": "insecureAcceptAnything" } ] }insecureAcceptAnything, andpodman image trust showrenders the same thing asall default accept.Worth pausing on, because the file's existence is the interesting part. Podman always consults a trust policy before running an image - there is no verification to switch on, only a policy to tighten. The default is permissive so that a fresh install works; the mechanism is already in the path.
bash Example session cat /etc/containers/policy.json{ "default": [ { "type": "insecureAcceptAnything" } ]}podman image trust showTRANSPORT NAME TYPE ID STOREall default acceptExpected resultA four-line policy with
insecureAcceptAnything, andacceptfor all transports.Success conditionYou can find the file that decides whether an image may run.
-
A key, and somewhere to keep signatures
Two pieces of setup.
A signing key. An ordinary GPG key -
rsa3072, no passphrase so a push can be scripted. In production this would be protected and probably live in a CI secret store.A place for the signatures to go. This is the part people miss: a signature is not part of the image. It is a separate object, and the registry has to be told where they live:
docker: 192.168.0.23:5000: sigstore: file:///home/sysadmin/sigstore sigstore-staging: file:///home/sysadmin/sigstoreA file in
/etc/containers/registries.d/.sigstore-stagingis where signatures are written on push andsigstoreis where they are read on pull - the same directory here, which works because both happen on this host.A local directory is the simplest thing that demonstrates the mechanism. In practice you would use a registry that stores signatures itself, or an HTTP sigstore both sides can reach - a
file://path only works for whoever can see that filesystem.bash Example session gpg --list-keys --keyid-format SHORT lab@certgrid.localpub rsa3072/5A203B10 2026-08-22 [SC] 260B9CBDC1B132C51B9C4878BD6397E65A203B10uid [ultimate] certgrid-lab <lab@certgrid.local>cat /etc/containers/registries.d/lab.yamldocker: 192.168.0.23:5000: sigstore: file:///home/sysadmin/sigstore sigstore-staging: file:///home/sysadmin/sigstoreExpected resultAn
rsa3072key forlab@certgrid.local, and a registries.d file naming a sigstore.Success conditionYou have a key and a configured place for its signatures.
-
Push the same image twice, signing one
One image, two destinations.
--sign-byon the first:Creating signature: Signing image using simple signing Storing signaturesand nothing on the second. The sigstore then contains exactly one signature, named for the image's digest rather than its tag - because a signature attests to specific bytes, and moving a tag cannot carry it.
The registry now holds
verifiedandunverified, which are the same layers with different names. Any difference in what happens next comes purely from the signature.The order matters and it is the reason this guide took four attempts. The policy is still permissive here. Tighten it first and this push fails - Podman will not push to a registry whose policy it cannot satisfy, so you would be locked out of creating the signature that would unlock it.
bash Example session podman tag docker.io/library/alpine:3.22 localhost/app-a:1podman push --sign-by lab@certgrid.local localhost/app-a:1 192.168.0.23:5000/verified:1Getting image source signaturesCopying blob sha256:6f09edfb3f6d7173733adc8eec8ea00626550dc6fc2dcf07d40e13f5c1e907c4Copying config sha256:b66e0ce64844f5c6435b0c4bfd965558199ab0f53270846861c979cb1ac29365Writing manifest to image destinationCreating signature: Signing image using simple signingStoring signaturespodman push localhost/app-a:1 192.168.0.23:5000/unverified:1Getting image source signaturesCopying blob sha256:6f09edfb3f6d7173733adc8eec8ea00626550dc6fc2dcf07d40e13f5c1e907c4Copying config sha256:b66e0ce64844f5c6435b0c4bfd965558199ab0f53270846861c979cb1ac29365Writing manifest to image destinationfind ~/sigstore -type f/home/sysadmin/sigstore/verified@sha256=bdd75f288525cd23d30104ca6833a6583419d23de8478458e0a6cf6f5ce0f925/signature-1curl -s http://192.168.0.23:5000/v2/_catalog{"repositories":["multi","signed","tosign","unverified","verified"]}Expected resultA signature created for one push and not the other, one file in the sigstore, and both repositories in the catalogue.
Success conditionTwo identical images are in the registry and only one is signed.
-
Require it
Export the public key where Podman can read it, then replace the policy:
{ "default": [{"type": "insecureAcceptAnything"}], "transports": { "docker": { "192.168.0.23:5000": [ {"type": "signedBy", "keyType": "GPGKeys", "keyPath": "/etc/containers/lab-pubkey.gpg"} ] } } }Read the shape rather than the syntax.
defaultstays permissive, and a scoped rule requiressignedByfor one registry. Scoping is the whole design: you can require signatures for your own registry while still pulling base images from Docker Hub, and tighten one scope at a time rather than in one flag day.A
"default": [{"type": "reject"}]with per-registry exceptions is the strict version, and the right end state - but it fails closed on every image you forgot, so arriving there gradually is the practical route.bash Example session gpg --armor --export lab@certgrid.local | sudo -n tee /etc/containers/lab-pubkey.gpg > /dev/nullsudo -n tee /etc/containers/policy.json <<'POL'{ "default": [{"type": "insecureAcceptAnything"}], "transports": { "docker": { "192.168.0.23:5000": [ {"type": "signedBy", "keyType": "GPGKeys", "keyPath": "/etc/containers/lab-pubkey.gpg"} ] } }}POL{ "default": [{"type": "insecureAcceptAnything"}], "transports": { "docker": { "192.168.0.23:5000": [ {"type": "signedBy", "keyType": "GPGKeys", "keyPath": "/etc/containers/lab-pubkey.gpg"} ] } }}Expected resultThe public key exported, and a policy with a scoped
signedByrule.Success conditionThe policy requires a signature for one registry and nothing else.
-
The proof
Remove both local copies so nothing is cached, then pull each.
Signed - fine. Note
Storing signatures: the signature comes down and is kept locally, so it can be re-verified later without the registry.Unsigned:
Error: unable to copy from source docker://192.168.0.23:5000/unverified:1: Source image rejected: A signature was required, but no signature existsExit 125, nothing pulled, nothing run. Identical layers to the image that just succeeded - the only difference is the signature.
And the scoping holds:
podman pull docker.io/library/busyboxstill works, becausedefaultis untouched.What this actually buys you. Not "the image is safe" - a signature says only that the holder of that key vouched for these exact bytes. What it prevents is a registry serving something nobody signed: a compromised registry, a mistaken push, a typo'd repository name, a tag someone moved. Combined with the digest pinning in guide 12, it closes the gap between "the image I tested" and "the image that ran".
It is also the honest counterpart to guide 42. Auto-update deliberately runs whatever appeared behind a tag; a
signedBypolicy on that registry means it can only run what your key signed, which is what makes the two safe together.bash Example session podman rmi -f 192.168.0.23:5000/verified:1 192.168.0.23:5000/unverified:1podman pull 192.168.0.23:5000/verified:1Trying to pull 192.168.0.23:5000/verified:1...Getting image source signaturesChecking if image destination supports signaturesCopying blob sha256:03d83da6a4bd7f9b778b541c93fd2c1a603ed29e95b345610dd6ceb826692ab2Copying config sha256:b66e0ce64844f5c6435b0c4bfd965558199ab0f53270846861c979cb1ac29365Writing manifest to image destinationStoring signaturesb66e0ce64844f5c6435b0c4bfd965558199ab0f53270846861c979cb1ac29365podman pull 192.168.0.23:5000/unverified:1Trying to pull 192.168.0.23:5000/unverified:1...Error: unable to copy from source docker://192.168.0.23:5000/unverified:1: Source image rejected: A signature was required, but no signature exists[exit 125]podman pull docker.io/library/busyboxTrying to pull docker.io/library/busybox:latest...Getting image source signaturesCopying blob sha256:b05093807bb0294152bb9cf86d64da722732dddaf7f8882fa1f120477dbc4db3Copying config sha256:c6348fa86ba0fb2108c9334f5fe913ddc6d853313e655891f133a0127c30099fWriting manifest to image destinationc6348fa86ba0fb2108c9334f5fe913ddc6d853313e655891f133a0127c30099fExpected resultA successful signed pull,
A signature was requiredon the unsigned one, and Docker Hub still working.Success conditionAn unsigned image from your own registry cannot run, and public images still can.
Troubleshooting
Copying this image would require changing layer representation, which we cannot do: "Would invalidate signatures".Why: The push would have to recompress the layers, and that changes the digest a signature covers. Some locally built images hit this and others do not.
Fix:Push the image unsigned once to see whether it converts, or rebuild with a compression format the registry accepts as-is. An image pulled from a registry and re-pushed generally signs without trouble.
A push fails after you tightened the policy.
Why: Podman checks policy on push as well as pull.
Fix:Sign while the policy is permissive, then tighten. If you are already locked out, relax the scope, push signed, and put it back.
podman image trust showerrors withexec: "gpg2": executable file not found in $PATH.Why: It shells out to
gpg2to describe the key, and Ubuntu ships the binary asgpg.Fix:Cosmetic - the policy still works, only the key-identity column is missing.
sudo ln -s /usr/bin/gpg /usr/local/bin/gpg2if the output matters.A signature was requiredon an image you definitely signed.Why: The puller cannot reach the sigstore. A
file://sigstore is invisible to any other host.Fix:Use a sigstore both sides can read, or a registry that stores signatures itself.
find <sigstore> -type fon the pulling host tells you whether it is there.