CertGrid CertGrid
Hands-on Lab·Podman

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

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.

The 2 hosts these commands ran on: podman01, podman03.
Server NameIP AddressOSRolesCPURAMHDD
PODMAN01192.168.0.21Ubuntu 26.04 LTSPrimary Container Host2 Core4 GB50 GB
PODMAN03192.168.0.23Ubuntu 26.04 LTSRegistry and Image Transfer Host2 Core4 GB50 GB

Before you start

  1. The default is to trust anything

    /etc/containers/policy.json on a fresh install is four lines long:

    {
        "default": [
            {
                "type": "insecureAcceptAnything"
            }
        ]
    }

    insecureAcceptAnything, and podman image trust show renders the same thing as all 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     accept

    Expected resultA four-line policy with insecureAcceptAnything, and accept for all transports.

    Success conditionYou can find the file that decides whether an image may run.

  2. 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/sigstore

    A file in /etc/containers/registries.d/. sigstore-staging is where signatures are written on push and sigstore is 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/sigstore

    Expected resultAn rsa3072 key for lab@certgrid.local, and a registries.d file naming a sigstore.

    Success conditionYou have a key and a configured place for its signatures.

  3. Push the same image twice, signing one

    One image, two destinations. --sign-by on the first:

    Creating signature: Signing image using simple signing
    Storing signatures

    and 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 verified and unverified, 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.

  4. 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. default stays permissive, and a scoped rule requires signedBy for 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 signedBy rule.

    Success conditionThe policy requires a signature for one registry and nothing else.

  5. 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 exists

    Exit 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/busybox still works, because default is 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 signedBy policy 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 destinationc6348fa86ba0fb2108c9334f5fe913ddc6d853313e655891f133a0127c30099f

    Expected resultA successful signed pull, A signature was required on the unsigned one, and Docker Hub still working.

    Success conditionAn unsigned image from your own registry cannot run, and public images still can.

Troubleshooting

Official sources