> ## Documentation Index
> Fetch the complete documentation index at: https://docs.basaltic.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Create a pull-through cache rule

> Maps a prefix in this account onto an upstream registry, so a fleet
pulls public base images through its own registry.

```
docker pull registry.sa-saopaulo-1.basaltic.sh/my-account/docker-hub/library/nginx:1.27
                                                  └ prefix ─┘└ upstream path ┘
```

**What this is for.** Docker Hub rate-limits by source IP, and a fleet
behind one NAT is one IP: a rolling deploy across two hundred nodes is
two hundred pulls of the same base image against a shared budget, and
the failure — every node's image pull backing off at once — arrives as
an outage in the middle of a deploy. One fetch, cached, takes the
budget off the critical path. Setting `upstream_username` and
`upstream_secret` helps even for a public upstream, because an
authenticated Docker Hub pull draws on a much larger budget than an
anonymous one.

**Nothing is mirrored now.** A rule is a mapping, not an instruction
to copy a registry. The repository under it and its content are
created by the first pull that misses, so a rule nobody pulls through
costs a row.

**Who can trigger a fetch.** A cache miss spends the account's storage
quota and pulls bytes from a third party, so it needs
`registry:BatchImportUpstreamImage` on top of the pull actions. A
fleet given only pull consumes what is already cached and gets a `404`
on anything else — which is how you keep a production cluster from
reaching the internet while CI keeps the mirror warm.

**The prefix must own its namespace.** Creating a rule over a prefix
the account already has repositories under is refused: those
repositories would stay un-mirrored while new names under the same
prefix mirrored, and nothing in a reference would say which applied.

Requires `registry:CreatePullThroughCacheRule`.




## OpenAPI

````yaml /api-reference/specs/registry.yaml post /v1/pull-through-cache-rules
openapi: 3.0.3
info:
  title: Basaltic Registry API
  version: 1.0.0
  description: |
    Private container image registries, backed by Ceph.

    **This spec documents the control plane, not the pull path.** The
    registry serves two protocols on one host. Everything below is the
    `/v1` REST surface — repositories, images, lifecycle policies,
    resource policies — authenticated with a request signature or a
    console session, like every other Basaltic API. Separately, `/v2`
    implements the [OCI Distribution
    Specification](https://github.com/opencontainers/distribution-spec)
    v1.1, which is what `docker`, `podman`, `buildah`, `crane`, `skopeo`,
    and every Kubernetes node's container runtime speak. That surface is
    not described here because it is not ours to describe: its paths,
    status codes, headers, and error codes are fixed by the spec, and a
    client that finds anything else fails.

    **Getting a docker credential.** `POST /v1/authorization-token`
    returns a short-lived password. Feed it to `docker login`; the daemon
    then exchanges it at `/v2/token` for per-repository bearer tokens on
    its own, which is where the actual authorization decision is made.
    The login credential carries no authority of its own — revoking a
    policy takes effect within one bearer token's lifetime (five minutes)
    rather than at the end of a twelve-hour login.

    ```
    basaltic registry get-login-password --region sa-saopaulo-1 \
      | docker login --username basaltic --password-stdin registry.sa-saopaulo-1.basaltic.sh
    docker push registry.sa-saopaulo-1.basaltic.sh/my-account/api:v1
    ```

    **The account handle is part of the image reference.** A region
    serves one registry host, so an image lives at
    `registry.<region>.<domain>/<account_handle>/<repository>:<tag>`. That
    first path component is the only thing separating two tenants'
    `api` repositories, which is why every repository response includes a
    ready-made `uri` rather than leaving clients to assemble one.

    **Everything except a tag is immutable.** A blob is named by the
    sha256 of its bytes and a manifest by the sha256 of its own JSON, so
    pushing the same content twice is a no-op the client detects before it
    uploads a byte. A tag is the one mutable pointer — and a repository
    may forbid even that, with `tag_mutability: immutable`, so a deployed
    digest cannot be swapped under a running fleet.

    **Deleting by digest and deleting by tag are different.** Removing a
    digest removes the manifest and every tag pointing at it. Removing a
    tag leaves the manifest in place, untagged: another tag may still name
    it, and `docker rmi` of one tag must not destroy an image someone else
    is pulling by another. Untagged manifests are what the `untagged`
    lifecycle rule exists to sweep.

    **Layers are shared and counted once.** Blobs are deduplicated across
    every repository in an account, so a repository's `size_bytes` is the
    sum of the distinct blobs its manifests reference — not the sum of its
    images' sizes, which would count every shared base layer once per
    image. Deleting an image does not immediately free space: whether a
    layer is still needed is a question about every other repository in
    the account, and the answer comes from a background sweep.

    **Sharing a repository.** A repository is private to its account
    until you attach a resource policy. `PUT
    /v1/repositories/{id}/policy` is how another account — or, with
    principal `*`, the public — is granted pull access. Cross-account
    access requires BOTH the resource policy and the caller's own IAM
    policy to allow it, so one tenant cannot grant another tenant's
    employee more than that employee's organization permits.
  contact:
    name: Basaltic Support
    email: ping@basaltic.sh
  license:
    name: Proprietary
    url: https://basaltic.sh/terms
servers:
  - url: https://registry.{region}.basaltic.sh
    description: Regional API endpoint
    variables:
      region:
        default: sa-saopaulo-1
        description: Region code
security:
  - SignatureAuth: []
tags:
  - name: Authorization
    description: The credential `docker login` consumes
  - name: Repositories
    description: Repository lifecycle and settings, and the images and tags in one
  - name: Policies
    description: Resource policies and lifecycle policies
  - name: Encryption
    description: At-rest encryption of layer bytes
  - name: Pull-through cache
    description: Mirroring an upstream registry's images into a namespace of your own
paths:
  /v1/pull-through-cache-rules:
    post:
      tags:
        - Pull-through cache
      summary: Create a pull-through cache rule
      description: >
        Maps a prefix in this account onto an upstream registry, so a fleet

        pulls public base images through its own registry.


        ```

        docker pull
        registry.sa-saopaulo-1.basaltic.sh/my-account/docker-hub/library/nginx:1.27
                                                          └ prefix ─┘└ upstream path ┘
        ```


        **What this is for.** Docker Hub rate-limits by source IP, and a fleet

        behind one NAT is one IP: a rolling deploy across two hundred nodes is

        two hundred pulls of the same base image against a shared budget, and

        the failure — every node's image pull backing off at once — arrives as

        an outage in the middle of a deploy. One fetch, cached, takes the

        budget off the critical path. Setting `upstream_username` and

        `upstream_secret` helps even for a public upstream, because an

        authenticated Docker Hub pull draws on a much larger budget than an

        anonymous one.


        **Nothing is mirrored now.** A rule is a mapping, not an instruction

        to copy a registry. The repository under it and its content are

        created by the first pull that misses, so a rule nobody pulls through

        costs a row.


        **Who can trigger a fetch.** A cache miss spends the account's storage

        quota and pulls bytes from a third party, so it needs

        `registry:BatchImportUpstreamImage` on top of the pull actions. A

        fleet given only pull consumes what is already cached and gets a `404`

        on anything else — which is how you keep a production cluster from

        reaching the internet while CI keeps the mirror warm.


        **The prefix must own its namespace.** Creating a rule over a prefix

        the account already has repositories under is refused: those

        repositories would stay un-mirrored while new names under the same

        prefix mirrored, and nothing in a reference would say which applied.


        Requires `registry:CreatePullThroughCacheRule`.
      operationId: createPullThroughCacheRule
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePullThroughCacheRuleRequest'
      responses:
        '201':
          description: Rule created. Nothing has been fetched.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PullThroughCacheRuleResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: |
            The prefix is already claimed — by another rule, or by a
            repository that exists under it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
components:
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: >-
        Optional client-generated key that makes a create replay-safe. Retrying
        a request with the same key returns the original outcome verbatim
        instead of creating a duplicate resource. Reusing a key with a different
        request body is rejected (422); a request whose key is still being
        processed returns 409. Records are honored for 24 hours. Use a UUID or
        similarly unique token.
      required: false
      schema:
        type: string
        maxLength: 255
      example: 550e8400-e29b-41d4-a716-446655440000
  schemas:
    CreatePullThroughCacheRuleRequest:
      type: object
      properties:
        prefix:
          type: string
          description: |
            One or two lowercase repository-name components. It becomes the
            leading path of every repository the rule creates, so it has to be
            a name a client can pull.
          example: docker-hub
        upstream_registry:
          type: string
          description: |
            A hostname with an optional port. `https://` is accepted and
            stripped; anything else is refused, because the connection can
            carry a credential.
          example: docker.io
        upstream_prefix:
          type: string
          description: Optional path prepended upstream.
          example: library
        upstream_username:
          type: string
          description: |
            Optional. Worth setting even against a public registry: an
            authenticated Docker Hub pull draws on a far larger rate-limit
            budget than an anonymous one.
        upstream_secret:
          type: string
          format: password
          description: |
            The password or token. Write-only — sealed at rest and never
            returned. Required with `upstream_username`, and refused without
            it: a half-specified credential produces an anonymous pull that
            silently draws on the small budget.
      required:
        - prefix
        - upstream_registry
    PullThroughCacheRuleResponse:
      type: object
      properties:
        pull_through_cache_rule:
          $ref: '#/components/schemas/PullThroughCacheRule'
      required:
        - pull_through_cache_rule
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
            - request_id
          properties:
            code:
              type: string
              description: Error code identifying the type of error
              example: INVALID_INPUT
            message:
              type: string
              description: Human-readable error message
              example: Invalid request parameters
            request_id:
              type: string
              format: uuid
              description: Request ID for debugging
              example: 550e8400-e29b-41d4-a716-446655440000
    PullThroughCacheRule:
      type: object
      properties:
        crn:
          type: string
          example: >-
            crn:registry:sa-saopaulo-1:my-account:pull-through-cache-rule/docker-hub
        prefix:
          type: string
          description: >-
            The leading path component(s) of the repository names this rule
            claims.
          example: docker-hub
        uri_prefix:
          type: string
          description: |
            The pullable reference prefix the rule creates. Returned rather
            than assembled by the client, for the same reason a repository's
            `uri` is: the account handle's position in the path is the only
            thing separating two tenants' namespaces.
          example: registry.sa-saopaulo-1.basaltic.sh/my-account/docker-hub
        upstream_registry:
          type: string
          description: |
            The upstream host. `docker.io` is stored as
            `registry-1.docker.io`, which is the host that actually speaks
            `/v2` — the docker daemon makes the same rewrite internally.
          example: registry-1.docker.io
        upstream_prefix:
          type: string
          description: |
            Prepended to the rest of the repository name upstream. `library`
            is what makes `docker-hub/nginx` resolve to Docker Hub's
            `library/nginx`.
          example: library
        upstream_username:
          type: string
          example: robot
        has_credential:
          type: boolean
          description: |
            Whether a secret is stored. The secret itself is never returned;
            rotation is a write of the new value.
          example: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required:
        - crn
        - prefix
        - uri_prefix
        - upstream_registry
        - has_credential
        - created_at
        - updated_at
  responses:
    BadRequest:
      description: Invalid request parameters
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: INVALID_INPUT
              message: Invalid request parameters
              request_id: 550e8400-e29b-41d4-a716-446655440000
    Unauthorized:
      description: Authentication required or token invalid
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: UNAUTHORIZED
              message: Authentication required
              request_id: 550e8400-e29b-41d4-a716-446655440000
    Forbidden:
      description: Insufficient permissions
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: ACCESS_DENIED
              message: You don't have permission to perform this action
              request_id: 550e8400-e29b-41d4-a716-446655440000
    UnprocessableEntity:
      description: |
        The request is well-formed but cannot be processed as sent. On the
        operations that accept `Idempotency-Key` this is the key-reuse case: the
        key was first seen with a different request payload, so replaying the
        stored outcome would answer a question the caller did not ask.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: IDEMPOTENCY_KEY_REUSED
              message: >-
                This Idempotency-Key was already used with a different request
                payload
              request_id: 550e8400-e29b-41d4-a716-446655440000
  securitySchemes:
    SignatureAuth:
      type: apiKey
      in: header
      name: Authorization
      description: >
        Request signing with an access key issued to a service account. An

        HMAC-SHA256 over a canonical form of the request, close to AWS SigV4.

        The `basaltic` CLI signs for you.


        Send `Authorization`, `X-Date` (UTC, `YYYYMMDDTHHMMSSZ`) and `X-Nonce`

        (random per request); add `X-Content-Sha256` to bind a body, and

        `X-Amz-Security-Token` when using temporary credentials.


        ```

        Authorization: BASALTIC-HMAC-SHA256
        Credential=<access_key_id>/<date>/<region>/basaltic/basaltic_request,
        SignedHeaders=host;x-date;x-nonce, Signature=<hex>

        ```


        `<region>` is the region code you are calling, or `global` for the
        global

        services. A signature is valid for 5 minutes from `X-Date`, and mutating

        requests are replay-guarded on the nonce.


        **Full signing procedure, including a working implementation:**

        https://docs.basaltic.sh/authentication


        ## Rate limits

        There is no global request budget. A limit applies only where an

        operation documents a `429`, and that operation says what it counts.

        Those responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`,

        `X-RateLimit-Reset` and, on a `429`, `Retry-After` — read them rather

        than hard-coding a number. Retrying before `Retry-After` is refused and

        extends the window. Everything else is bounded by quota, not by request

        rate.

````