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

# Licence and images

> How one licence key becomes a rotating image-pull credential, and what to do when it doesn't.

# Licence and images

Swarmd's container images live in a private ECR registry. Your licence key is
what lets your cluster pull them — and the same key is what billing-service
checks to establish your subscription tier.

Worth understanding before you install, because it is the one part of the
chart that reaches outside your cluster, and the one failure mode that stops
everything.

***

## The loop

```mermaid theme={null}
sequenceDiagram
    participant J as Pre-install Job
    participant L as Swarmd licence server
    participant S as Pull Secret
    participant K as Kubelet
    participant C as Renew CronJob

    J->>L: POST /v1/ecr-credentials<br/>Authorization: Licence LIC-...
    L-->>J: { dockerConfigJson, expiresAt }
    J->>S: create/update dockerconfigjson Secret
    Note over S: swarmd-ecr-pull
    K->>S: read on every image pull
    loop every 8 hours
        C->>L: POST /v1/ecr-credentials
        C->>S: overwrite with a fresh token
    end
```

1. **A pre-install Job runs before anything else.** It calls
   `POST {licence.serverUrl}/v1/ecr-credentials` with your key in an
   `Authorization: Licence <key>` header.
2. **The response carries a base64 `dockerConfigJson`.** The Job decodes it and
   writes a `kubernetes.io/dockerconfigjson` Secret — `swarmd-ecr-pull` by
   default.
3. **That Secret is appended to `global.image.pullSecrets` automatically.** You
   don't wire it up; every pod gets it.
4. **A CronJob refreshes it every 8 hours.** AWS ECR tokens expire after 12,
   so this leaves four hours of headroom.

The Job and CronJob run the *same script* from one ConfigMap, so the bootstrap
and rotation paths can never drift apart. The image they run is
`alpine/k8s` — deliberately a public image, because it has to run *before* the
pull secret exists.

<Note>
  **Failure is loud by design.** If the licence server is unreachable during
  pre-install, the script exits non-zero, the hook fails, and Helm rolls the
  install back. You get an error instead of a namespace full of
  `ImagePullBackOff`.
</Note>

***

## Supplying the key

<Tabs>
  <Tab title="From a Secret (recommended)">
    ```bash theme={null}
    kubectl -n swarmd create secret generic swarmd-licence \
      --from-literal=value='LIC-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
    ```

    ```yaml theme={null}
    licence:
      existingSecretRef:
        name: swarmd-licence
        key: value          # defaults to "value"
    ```

    The key never enters your values file, your shell history, or Helm's release
    history.
  </Tab>

  <Tab title="Inline">
    ```yaml theme={null}
    licence:
      key: "LIC-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
    ```

    Fine for a laptop test. Anywhere else this leaks: `--set` values are stored in
    the release history and readable by anyone who can run `helm get values`.
  </Tab>
</Tabs>

<Warning>
  Set **exactly one**. Both, or neither, fails the install with a named error.
</Warning>

***

## What you can tune

```yaml theme={null}
licence:
  serverUrl: "https://api.dev.swarmd.ai/licence"

  ecr:
    secretName: swarmd-ecr-pull      # name of the generated pull Secret
    renewIntervalHours: 8            # CronJob cadence; ECR tokens last 12h
    bootstrapImage:
      repository: alpine/k8s         # must be publicly pullable
      tag: "1.28.4"

  billing:
    verifyIntervalMinutes: 60        # how often billing re-checks the licence
    cacheTtlMinutes: 120             # hard cutoff on a stale verification
```

<Accordion title="Why the renew interval matters">
  Raising `renewIntervalHours` past 12 means the token expires before the next
  refresh, and every pull fails until the CronJob next runs. Existing pods keep
  running — kubelet only pulls on start — so the symptom is delayed and
  confusing: everything is fine until a node reboots or you scale up.

  Leave it at 8 unless you have a specific reason.
</Accordion>

<Accordion title="Verification cadence and revocation">
  `verifyIntervalMinutes` and `cacheTtlMinutes` trade licence-server load
  against how long a revoked licence keeps working. With the defaults, a
  revocation takes effect within an hour, and at most two.

  Loosen them for an air-gapped-ish deployment that can only reach the licence
  server intermittently; tighten them if you need revocation to bite quickly.
</Accordion>

***

## Mirroring into your own registry

If your cluster cannot reach ECR — or policy forbids pulling from a vendor
registry — mirror the images and point the chart at your mirror:

```yaml theme={null}
global:
  image:
    registry: registry.internal.example.com/swarmd
    pullSecrets:
      - my-registry-creds
```

`global.image.registry` is prepended to every image reference that doesn't
already carry a registry.

<Note>
  You still need a valid licence key: billing-service verifies it at runtime
  independently of image pulls. The ECR bootstrap Job will keep trying and
  failing harmlessly if it can't reach the licence server — but plan for
  offline validation with us rather than relying on that.
</Note>

***

## When it goes wrong

<AccordionGroup>
  <Accordion title="Every pod is ImagePullBackOff">
    The pull Secret is missing or stale. Look at the bootstrap Job first — it's
    the only thing that creates it:

    ```bash theme={null}
    kubectl -n swarmd logs job/swarmd-licence-ecr-bootstrap
    kubectl -n swarmd get secret swarmd-ecr-pull
    ```

    If the Secret doesn't exist, the Job never succeeded. If it exists but pulls
    still fail, the token has expired — check the CronJob:

    ```bash theme={null}
    kubectl -n swarmd get cronjob
    kubectl -n swarmd logs -l job-name --tail=50
    ```

    Force a refresh:

    ```bash theme={null}
    kubectl -n swarmd create job --from=cronjob/swarmd-licence-ecr-renew manual-refresh
    ```
  </Accordion>

  <Accordion title="'licence server returned empty dockerConfigJson'">
    The server answered, but without credentials. That means the key was accepted
    as a *header* but rejected as a licence — expired, revoked, or not entitled to
    image pulls. Check the response body in the Job logs, then talk to Swarmd.
  </Accordion>

  <Accordion title="curl: (28) Operation timed out">
    No egress. The script allows 30 seconds. Your cluster needs outbound HTTPS to
    `api.dev.swarmd.ai` — check egress policy, proxy configuration, and whether
    your CNI's NetworkPolicy allows the `swarmd` namespace out.
  </Accordion>

  <Accordion title="Install rolled back with a pre-install hook failure">
    Working as intended: the ECR Job is a `pre-install` hook, so a failure aborts
    before any service is created. Fix the cause and re-run `helm install` — there
    is nothing to clean up.
  </Accordion>

  <Accordion title="The platform runs, but billing shows the wrong tier">
    Image pulls and tier enforcement are separate paths. Pulls only need the key
    once; billing-service verifies continuously against `licence.serverUrl`. If
    that URL is wrong or unreachable, the tier falls back to `STARTER`.

    ```bash theme={null}
    kubectl -n swarmd logs deploy/swarmd-billing | grep -i licence
    ```
  </Accordion>
</AccordionGroup>

***

## Next

<CardGroup cols={2}>
  <Card title="Databases" icon="database" href="/self-hosting/databases">
    Where all this state actually lives.
  </Card>

  <Card title="Configuration" icon="sliders" href="/self-hosting/configuration">
    Secrets, app settings, and the optional components.
  </Card>
</CardGroup>
