# Github As DB

> Use a GitHub repo as a zero-infra, anonymous-friendly data store — JSON files as records, GitHub App bot as the write layer, build-time index compilation as the read layer. No auth service, no database, no backend required. Covers CRUD via Octokit, community contribution via PR flow, and build-time index generation to avoid API rate limits. Use when building community data repos, personal low-frequency data stores, open-source projects that need structured data without a database, or any "submit to repo" feature from a browser app where users don't need a GitHub account.

- Skill: `phucbm/github-as-db` (Agent Skill)
- Install (CLI): `npx skillmds@latest add phucbm/github-as-db`
- Raw SKILL.md: https://api.skillmd.com/api/skills/phucbm/github-as-db/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: phucbm (https://skillmd.com/u/phucbm)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/phucbm/github-as-db

---


# GitHub as Zero-Infra Data Store

## Concept

Repo = database. JSON files checked into `data/` = records. A GitHub App bot handles all writes — no user GitHub account needed, no backend, no DB. A build-time script compiles all records into a single static `index.json` for fast reads without API calls.

Used in: [clyrics](https://github.com/phucbm/clyrics) — community song lyrics where anyone contributes from the web app anonymously.

---

## When to use / avoid

| Use | Avoid |
|---|---|
| Community contributions to public data | Private/sensitive data (repo must be public on free tier) |
| Personal low-frequency CRUD (configs, bookmarks, etc.) | High-frequency writes (>100/day gets slow) |
| OSS project, no auth or DB budget | Real-time data or complex queries |
| Version history / audit log matters | Relational data or joins |
| Anonymous contributions with moderation | Mass concurrent contributors |

PR review step = built-in moderation. Git history = free audit log. GitHub Issues/PRs = free support queue.

---

## Architecture

```
browser app
    │
    ├─ READ  → fetch /data/index.json (static, CDN-served, compiled at build time)
    │
    └─ WRITE → GitHub App bot
                  ├─ Personal use:    direct commit to main
                  └─ Community use:   branch → commit → PR (maintainer reviews + merges)

repo: data/*.json  ←──  source of truth
           │
    prebuild script
           │
      public/data/index.json  (gitignored, regenerated each deploy)
```

---

## 1. Create the GitHub App

1. GitHub → Settings → Developer Settings → GitHub Apps → New GitHub App
2. **Permissions** (repository scope):
   - Contents: Read & Write
   - Pull requests: Read & Write (community flow only)
3. Install the app on your target repo
4. Note down:
   - **App ID** (shown on app settings page)
   - **Installation ID** (GitHub → Settings → Installed GitHub Apps → click app → URL ends in `/installations/<id>`)
   - **Private Key** — generate and download `.pem` file

---

## 2. Env vars + framework notes

**Vite (browser-side — acceptable for public repos with narrow app permissions):**
```env
VITE_GITHUB_APP_ID=123456
VITE_GITHUB_APP_INSTALLATION_ID=78901234
VITE_GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----"
```

> `VITE_*` vars ship to the browser bundle. Acceptable only when the repo is public and the GitHub App has narrow permissions (one repo, no org access) — worst case someone uses the key to open a PR, still gated by maintainer review.

**Next.js / Nuxt — move auth server-side. Never use `NEXT_PUBLIC_*` for the private key.**
```env
# .env (not NEXT_PUBLIC_*)
GITHUB_APP_ID=123456
GITHUB_APP_INSTALLATION_ID=78901234
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----"
```

Call `getInstallationToken()` only inside a Server Action (`'use server'`) or API route (`/app/api/contribute/route.ts`). Browser posts `{ data }` → server mints JWT → exchanges for token → creates PR → returns PR URL.

> Store the PEM as a single line with literal `\n` separators. Reconstruct at runtime with `.replace(/\\n/g, '\n')`.
> For CI/CD: set as repo secrets, never commit the `.pem` file.

**Deps:**
```bash
pnpm add jose octokit nanoid
```

---

## 3. GitHub App auth — mint JWT + exchange for installation token

```ts
import { SignJWT, importPKCS8 } from 'jose'

async function getInstallationToken(): Promise<string> {
  const appId = import.meta.env.VITE_GITHUB_APP_ID
  const installationId = import.meta.env.VITE_GITHUB_APP_INSTALLATION_ID
  const rawKey = import.meta.env.VITE_GITHUB_APP_PRIVATE_KEY

  if (!appId || !installationId || !rawKey) throw new Error('NO_GITHUB_APP_CONFIG')

  const pem = rawKey.replace(/\\n/g, '\n')
  const key = await importPKCS8(pem, 'RS256')

  const jwt = await new SignJWT({})
    .setProtectedHeader({ alg: 'RS256' })
    .setIssuedAt()
    .setIssuer(appId)
    .setExpirationTime('10m')   // max allowed by GitHub
    .sign(key)

  const res = await fetch(
    `https://api.github.com/app/installations/${installationId}/access_tokens`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${jwt}`,
        Accept: 'application/vnd.github+json',
        'X-GitHub-Api-Version': '2022-11-28',
      },
    }
  )
  if (!res.ok) throw new Error(`GitHub App token error: ${res.status}`)
  const { token } = await res.json()
  return token
}

async function getOctokit() {
  const { Octokit } = await import('octokit')
  return new Octokit({ auth: await getInstallationToken() })
}
```

> Mint fresh per operation — installation tokens last ~1 hour but the JWT used to fetch them is 10m max.

---

## 4. CRUD operations

### Encoding — always use this for file content

```ts
// Handles CJK characters, emoji, and any non-ASCII safely
function encode(obj: object): string {
  return btoa(unescape(encodeURIComponent(JSON.stringify(obj, null, 2))))
}
```

> `btoa()` alone breaks on non-ASCII. `btoa(unescape(encodeURIComponent(...)))` is the fix. Learned from CJK song titles in clyrics.

### Get file SHA (required for update + delete)

```ts
async function getFileSha(octokit: Octokit, path: string): Promise<string | undefined> {
  try {
    const { data } = await octokit.rest.repos.getContent({ owner: OWNER, repo: REPO, path })
    if ('sha' in data) return data.sha
  } catch {
    return undefined // file doesn't exist
  }
}
```

> **Always fetch SHA before update or delete.** GitHub API rejects updates without the current SHA. If file was deleted externally, SHA fetch returns undefined — handle gracefully.

### Create

```ts
async function createRecord(path: string, data: object, message: string) {
  const octokit = await getOctokit()
  await octokit.rest.repos.createOrUpdateFileContents({
    owner: OWNER, repo: REPO,
    path,
    message,
    content: encode(data),
    branch: 'main',
  })
}
```

### Read (single file)

```ts
async function readRecord(path: string): Promise<object> {
  const res = await fetch(
    `https://raw.githubusercontent.com/${OWNER}/${REPO}/main/${path}`
  )
  if (!res.ok) throw new Error(`Not found: ${path}`)
  return res.json()
}
```

> Use raw.githubusercontent.com for direct file reads — no API auth needed, no rate limit for public repos.

### Update

```ts
async function updateRecord(path: string, data: object, message: string) {
  const octokit = await getOctokit()
  const sha = await getFileSha(octokit, path)
  await octokit.rest.repos.createOrUpdateFileContents({
    owner: OWNER, repo: REPO,
    path,
    message,
    content: encode(data),
    branch: 'main',
    ...(sha ? { sha } : {}),
  })
}
```

### Delete

```ts
async function deleteRecord(path: string, message: string) {
  const octokit = await getOctokit()
  const sha = await getFileSha(octokit, path)
  if (!sha) return // already gone
  await octokit.rest.repos.deleteFile({
    owner: OWNER, repo: REPO,
    path,
    message,
    sha,
    branch: 'main',
  })
}
```

---

## 5. Build-time index (solves the rate limit problem)

**Problem:** Fetching each JSON file via GitHub API at runtime burns rate limit fast — only 60 req/hr unauthenticated, 5000 req/hr authenticated. A collection of 200 items = 200 API calls on every page load.

**Solution:** Compile all records to a single `public/data/index.json` at build time. App fetches one static file — no API calls at runtime.

```js
// scripts/build-index.mjs
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'

const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const dataDir = join(root, 'data')
const outDir = join(root, 'public', 'data')

mkdirSync(outDir, { recursive: true })

const items = readdirSync(dataDir)
  .filter(f => f.endsWith('.json'))
  .map(f => {
    const record = JSON.parse(readFileSync(join(dataDir, f), 'utf-8'))
    return { ...record, source: 'repo' }
  })
  .sort((a, b) => new Date(b.createdAt ?? 0) - new Date(a.createdAt ?? 0))

writeFileSync(join(outDir, 'index.json'), JSON.stringify(items, null, 2))
console.log(`[build-index] wrote ${items.length} items → public/data/index.json`)
```

Hook into `package.json`:
```json
{
  "scripts": {
    "predev": "node scripts/build-index.mjs",
    "prebuild": "node scripts/build-index.mjs"
  }
}
```

Add to `.gitignore`:
```
public/data/
```

App fetches at runtime:
```ts
const res = await fetch('/data/index.json')
const items = await res.json()
```

---

## 6. Community contribution via PR flow

```ts
import { nanoid } from 'nanoid'

async function contributeRecord(
  data: object,
  filePath: string,
  prTitle: string,
  prBody: string,
): Promise<string> {
  const octokit = await getOctokit()
  const branch = `contribute/${nanoid(8)}`
  const { data: ref } = await octokit.rest.git.getRef({ owner: OWNER, repo: REPO, ref: 'heads/main' })

  await octokit.rest.git.createRef({
    owner: OWNER, repo: REPO,
    ref: `refs/heads/${branch}`,
    sha: ref.object.sha,
  })

  await octokit.rest.repos.createOrUpdateFileContents({
    owner: OWNER, repo: REPO,
    path: filePath,
    message: prTitle,
    content: encode(data),
    branch,
  })

  const { data: pr } = await octokit.rest.pulls.create({
    owner: OWNER, repo: REPO,
    title: prTitle,
    head: branch,
    base: 'main',
    body: prBody,
  })

  try {
    await octokit.rest.pulls.requestReviewers({
      owner: OWNER, repo: REPO,
      pull_number: pr.number,
      reviewers: [OWNER],
    })
  } catch { /* non-fatal */ }

  return pr.html_url
}
```

Strip internal fields before publishing:
```ts
const { _localOnly, source, copiedFrom, ...publicData } = record
await contributeRecord(publicData, filePath, title, body)
```

---

## Gotchas

| Issue | Fix |
|---|---|
| Non-ASCII content breaks `btoa()` | Use `btoa(unescape(encodeURIComponent(str)))` — handles CJK, emoji |
| PEM newlines in env | Store as `\n` literals; `.replace(/\\n/g, '\n')` at runtime |
| Update/delete fails with 422 | Always fetch current SHA first; pass it to the API call |
| Branch name collisions | Append `nanoid(6-8)` to branch name |
| Internal fields leaking to repo | Destructure + strip before serializing |
| Installation ID hard to find | GitHub → Settings → Installed GitHub Apps → click app → URL: `/installations/<id>` |
| Rate limits on file reads | Compile to `public/data/index.json` at build time |
| Private key in Next.js | Never use `NEXT_PUBLIC_*` — use server actions or API routes only |

