---
sidebar_label: GitHub App auth for agent repos
toc_max_heading_level: 3
doc_id: 3b8e1c47-5d2a-4f19-9c63-7ae02b45d8f1
description: >-
  Configure the nullplatform agent to clone and update its GitHub repos with
  per-organization GitHub App installation tokens instead of a static token
  embedded in the repo URL.
keywords:
  - github app
  - agent repo
  - private repos
  - installation token
  - agent configuration
  - nullplatform agent
---

# GitHub App auth for agent repos

The agent reads scopes and actions from the repos you list in `AGENT_REPO`. By default, a private repo is cloned with a static token embedded in its URL:

```bash
AGENT_REPO="https://<git-token>@github.com/your-org/private-repo.git#main"
```

That token is long-lived, tied to a user account, and stops working the day it expires or gets rotated. A GitHub App replaces it with short-lived installation tokens that the agent mints on demand and renews on its own, with no restart and no token in the URL.

You declare credentials **per GitHub organization**, so an agent that serves repos from several orgs uses a different App for each one.

## How it works

Each time the agent runs a git operation against a repo whose org has an App configured, it asks for a fresh installation token and authenticates with it. Tokens are cached per organization and re-minted before they expire, so a long-running agent keeps pulling without any manual rotation.

```mermaid
flowchart LR
    A["**GitHub App credentials**<br/>App ID and private key,<br/>declared per organization"]
    B["**Agent**<br/>Mints an installation token<br/>for the org and caches it.<br/>Renews it before expiry."]
    C["**Agent repo on GitHub**<br/>Cloned, fetched and pulled<br/>with the installation token."]

    A -- "reads" --> B
    B -- "per git operation" --> C

    classDef customer fill:#eef3fb,stroke:#274a86,color:#0b1e3f,stroke-width:1.5px;
    class A,B,C customer;

    linkStyle 0 stroke:#0b1e3f,stroke-width:2.5px;
    linkStyle 1 stroke:#0b1e3f,stroke-width:2.5px;
```

:::note
This applies only to HTTPS `github.com` remotes. SSH remotes, GitHub Enterprise, and other git providers are not affected and keep using the credentials you already configured for them.
:::

## Set up the GitHub App

In the GitHub organization that owns your agent repo, [create a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) with:

- **Repository permissions:** `Contents: Read-only` and `Metadata: Read`.
- A new **private key**. Download the `.pem` file when GitHub shows it, since you can't retrieve it later.

Install the App on the organization and grant it access to the repos the agent needs. Then note the **App ID** from the App's settings page. You can also note the **Installation ID** (the numeric ID at the end of the URL after installing), but it's optional — the agent resolves it from the organization when you leave it out.

Repeat this for every organization whose repos the agent clones.

## Configure the agent

Pass one `--github-app` flag per organization:

```bash
agent \
  --github-app "org=acme-core,app-id=123456,installation-id=78901234,private-key=/etc/np/keys/acme-core.pem" \
  --github-app "org=acme-labs,app-id=654321,private-key-ssm-parameter=/np/gh/acme-labs-key"
```

### Flag keys

| Key | Required | Description |
| --- | --- | --- |
| `org` | Yes | GitHub organization the App is installed in. Case-insensitive |
| `app-id` | Yes | The App's App ID or Client ID |
| `installation-id` | No | Pins the installation and skips the lookup. Resolved from the org when omitted |
| `private-key` | One of the two | Path to the App's private key `.pem` file |
| `private-key-ssm-parameter` | One of the two | Name of an AWS SSM SecureString parameter holding the PEM |

Each organization can appear only once, and each entry needs exactly one key source.

:::warning
Inline PEM values aren't accepted. Point the agent at a file path or an SSM parameter name.
:::

### On Kubernetes

Don't set the flag by hand. The `nullplatform-agent` chart takes the same credentials under `github.apps`, one entry per organization, and renders them into the agent's `NP_GITHUB_APPS` environment variable for you.

In production, keep the PEMs in a Secret you manage and point the chart at it:

```yaml
github:
  secret:
    create: false
    name: my-github-app-keys
  apps:
    - org: acme-core
      appId: "123456"
      installationId: "78901234"
      privateKeySecretKey: acme-core.pem
    - org: acme-labs
      appId: "654321"
      privateKeySsmParameter: /np/gh/acme-labs-key
```

| Value | Description |
| --- | --- |
| `github.apps[].org` | GitHub organization the App is installed in |
| `github.apps[].appId` | The App's App ID or Client ID |
| `github.apps[].installationId` | Optional. Resolved from the org when unset |
| `github.apps[].privateKeySecretKey` | PEM file from the Secret. Defaults to `<org>.pem` |
| `github.apps[].privateKeySsmParameter` | AWS SSM parameter holding the PEM. Needs IRSA on the service account |
| `github.apps[].privateKey` | Inline PEM. Requires `github.secret.create: true`, so dev only |
| `github.secret.create` | Whether the chart creates the Secret from inline `privateKey` values |
| `github.secret.name` | Existing Secret holding one PEM per org |
| `github.mountPath` | Where the PEMs are mounted. Defaults to `/etc/nullplatform/github-apps` |

Each entry needs exactly one key source. Leave `github.apps` empty to disable GitHub App auth entirely.

:::info
`github.apps` requires chart version **3.0.0** or later. It replaces `githubTokenInit`, which was [removed in that release](install-helm-github-app.md).
:::

## AWS permissions for SSM private keys

If you store a private key with `private-key-ssm-parameter` (or `privateKeySsmParameter` in the chart), the agent reads it from AWS Systems Manager at startup. Store the PEM in a parameter of type `SecureString`, and grant the agent's IAM identity:

| Action | On | Why |
| --- | --- | --- |
| `ssm:GetParameter` | Each parameter holding a PEM | Reads the parameter |
| `kms:Decrypt` | The KMS key that encrypts those parameters | The agent always requests the value with decryption enabled |

A minimal policy for two organizations looks like this:

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "ssm:GetParameter",
      "Resource": [
        "arn:aws:ssm:us-east-1:123456789012:parameter/np/gh/acme-core-key",
        "arn:aws:ssm:us-east-1:123456789012:parameter/np/gh/acme-labs-key"
      ]
    },
    {
      "Effect": "Allow",
      "Action": "kms:Decrypt",
      "Resource": "arn:aws:kms:us-east-1:123456789012:key/abcd1234-a123-456a-a12b-a123b4cd56ef"
    }
  ]
}
```

Keep in mind:

- **Scope the resources.** List only the parameters the agent actually reads, rather than granting `parameter/*`.
- **Customer managed keys need both sides.** If your parameters use your own KMS key, allow the agent's role in the **key policy** too. An IAM policy alone isn't enough.
- **On EKS, use [IRSA](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html).** Attach the policy to a role and annotate the agent's service account with it:

  ```yaml
  serviceAccount:
    annotations:
      eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/nullplatform-agent-irsa
  ```

- **Outside EKS**, the agent uses the standard AWS credential chain, so an instance profile or the environment's configured credentials work as usual.

:::note
These are the same permissions the agent needs for [`--ssm-apikey-parameter`](install-helm.md#use-aws-ssm-for-the-api-key). If you already use that, extend the existing policy with the new parameter ARNs instead of creating a second role.
:::

If you'd rather not grant SSM access at all, use `private-key` and mount the PEM as a file instead.

## Migrate an existing agent

You can move one organization at a time, and the agent keeps running the whole way through.

1. **Add the App entry** for the org and restart the agent.
2. **Drop the token from the URL.** Once the org has an App configured, the agent strips any credentials embedded in the repo URL and authenticates with the installation token instead. You can simplify `AGENT_REPO` to a clean URL:

   ```bash
   AGENT_REPO="https://github.com/your-org/private-repo.git#main"
   ```

3. **Repeat for the remaining orgs.** Any org without an entry keeps using the token in its URL, and the agent logs a warning naming that org so you can see what's still pending.

Repos already on disk are re-addressed in place, so there's no need to wipe the agent's working directory.

:::tip
Rolling back is just as simple. Remove the org's `--github-app` entry, put the token back in the repo URL, and restart.
:::

## Security notes

- Private keys and minted tokens stay in memory. They're never written to disk or to logs.
- Clone and fetch errors have any credentials in the repo URL redacted before they reach the logs.
- Installation tokens are short-lived and scoped to the repos you granted the App, unlike a personal access token tied to a user account.

## Related pages

- [Install the agent with Helm](install-helm.md) — where `AGENT_REPO` is configured.
- [Authenticate private repos with a GitHub App](install-helm-github-app.md) — the chart's init container flow, which clones the repo once at pod startup.
- [Refresh agent sources](agent-refresh.md) — pull new commits into a running agent.
