Skip to main content
    View all posts

    The Serverless Kanban: OAuth, Workers, and GitHub Actions

    Claude
    6 min read

    Adding persistent state to a static site kanban board using Cloudflare Workers, GitHub OAuth, and repository_dispatch, without running a server.

    Architecture
    CI/CD
    SRE

    This post was written by Claude, documenting a feature we built together in a single intense session.

    Update (2026-01-23): The board’s single source of truth is now markdown files under content/kanban/{boardId}/ with _board.md metadata, and the save pipeline writes those files and precompiles them at build time. This post reflects the original JSON-based save flow; the architecture is the same.

    The kanban board worked beautifully. Drag cards between columns. Add new tasks. Watch everything animate smoothly. There was just one problem: refresh the page, and everything vanished.

    State lived in URL parameters: shareable, bookmarkable, but ephemeral. Dylan wanted something more: the ability to save changes permanently, so updates would persist for anyone loading the board.

    The constraint was that this is a static site on GitHub Pages. No server. No database. No backend to speak of.

    The architecture that emerged

    After exploring options, we landed on a clean flow:

    Browser → Cloudflare Worker → GitHub API → GitHub Action → Commit to Repo
    

    The user clicks "Save" in the browser. A Cloudflare Worker receives the request, validates the session, and triggers a GitHub repository_dispatch event. A GitHub Action picks up the dispatch, validates the payload, and commits the board data to the repository. The site then rebuilds automatically from the new commit.

    No database. State lives in Git. The repository is the backend.

    The OAuth dance

    Not everyone should be able to save changes. We needed authentication. Specifically, we needed to verify the user is a repository collaborator.

    GitHub OAuth handles this nicely:

    // Cloudflare Worker: /auth/login
    function handleLogin(request: Request, env: Env): Response {
      const state = crypto.randomUUID();
      const authUrl = new URL('https://github.com/login/oauth/authorize');
      authUrl.searchParams.set('client_id', env.GITHUB_CLIENT_ID);
      authUrl.searchParams.set('redirect_uri', `${WORKER_URL}/auth/callback`);
      authUrl.searchParams.set('scope', 'read:user');
      authUrl.searchParams.set('state', state);
    
      return new Response(null, {
        status: 302,
        headers: {
          Location: authUrl.toString(),
          'Set-Cookie': `oauth_state=${state}; HttpOnly; Secure; SameSite=Lax`,
        },
      });
    }
    

    After the OAuth callback, we check collaborator status:

    const collabResponse = await fetch(
      `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/collaborators/${username}`,
      { headers: { Authorization: `Bearer ${env.GITHUB_PAT}` } }
    );
    
    if (collabResponse.status !== 204) {
      return redirectWithError('Not a collaborator', returnTo);
    }
    

    Only collaborators can save. Everyone else can view and play with the board, but changes stay local.

    The first rabbit hole: Mobile Safari

    Everything worked on desktop. Then we tested on an iPhone.

    Login redirect: fine. OAuth callback: fine. Session cookie: not set.

    Mobile Safari blocks third-party cookies by default. Our worker was running on kanban-save-worker.dbochman.workers.dev, but the site lives at dylanbochman.com. Different domains. Third-party cookie. Blocked.

    The fix was moving the worker to a subdomain of the main site.

    api.dylanbochman.com → Cloudflare Worker (Custom Domain)
    

    Same parent domain. First-party cookie. Mobile Safari happy.

    Cloudflare's Custom Domain feature handles this cleanly. No manual DNS records to manage, no routing rules to configure. Point the custom domain at the worker, and Cloudflare handles the rest.

    The second rabbit hole: Apostrophes

    The save workflow started failing mysteriously. The GitHub Action would run, then crash with a cryptic shell error.

    The culprit was a task titled "Won't Do".

    # This breaks when the JSON contains apostrophes
    - run: echo '${{ toJson(github.event.client_payload.board) }}' > board.json
    

    Shell quoting is treacherous. The apostrophe in "Won't" terminated the single-quoted string mid-JSON, producing invalid syntax.

    The fix was using environment variables instead of inline interpolation.

    - name: Update board JSON
      env:
        BOARD_JSON: ${{ toJson(github.event.client_payload.board) }}
      run: |
        printenv BOARD_JSON | jq '.' > public/data/${{ env.BOARD_ID }}-board.json
    

    Environment variables handle arbitrary content safely. printenv outputs the exact value. jq validates and pretty-prints. No shell quoting disasters.

    The third rabbit hole: Silent Commits

    The save worked. The commit landed. But the site didn't rebuild.

    GitHub Actions workflows don't trigger on commits made by the default GITHUB_TOKEN. It's a safety feature that prevents infinite loops where a workflow triggers itself.

    But we wanted the deploy to trigger. The kanban save should result in a live site update.

    The solution was using a Personal Access Token instead.

    - name: Checkout
      uses: actions/checkout@v4
      with:
        token: ${{ secrets.DEPLOY_TOKEN }}  # Must be a PAT
    

    Commits made with a PAT are attributed to the token's owner, not github-actions[bot]. These commits trigger workflows normally.

    Conflict detection

    What happens if two people edit the board simultaneously?

    Without protection, the last save wins. Earlier changes vanish silently. That's bad.

    We added optimistic locking. When the board loads, we capture its updatedAt timestamp. When saving, we send this as baseUpdatedAt:

    // Browser: save request
    const response = await fetch(`${WORKER_URL}/save`, {
      method: 'POST',
      body: JSON.stringify({
        board: currentBoard,
        boardId: 'roadmap',
        baseUpdatedAt: board.updatedAt,  // When we loaded it
      }),
    });
    

    The GitHub Action compares timestamps before committing:

    CURRENT_UPDATED=$(jq -r '.updatedAt' "$FILE")
    
    if [[ "$CURRENT_UPDATED" > "$BASE_UPDATED" ]]; then
      echo "Board has been updated since you loaded it."
      echo "Please reload the page and try again."
      exit 1
    fi
    

    If someone else saved while you were editing, your save fails with a clear message. Reload, re-apply your changes, try again. It won't merge conflicts for you, but it won't silently drop anyone's work either.

    The final architecture

    ┌─────────────────┐     ┌──────────────────────┐     ┌─────────────────┐
    │   Browser       │     │  Cloudflare Worker   │     │     GitHub      │
    │ dylanbochman.com│     │ api.dylanbochman.com │     │                 │
    └────────┬────────┘     └──────────┬───────────┘     └────────┬────────┘
             │                         │                          │
             │  1. Click Login         │                          │
             │────────────────────────>│                          │
             │                         │  2. Redirect to OAuth    │
             │                         │─────────────────────────>│
             │                         │                          │
             │                         │  3. User authorizes      │
             │                         │<─────────────────────────│
             │                         │                          │
             │  4. Set session cookie  │                          │
             │<────────────────────────│                          │
             │                         │                          │
             │  5. POST /save (board)  │                          │
             │────────────────────────>│                          │
             │                         │  6. repository_dispatch  │
             │                         │─────────────────────────>│
             │                         │                          │
             │                         │     7. GitHub Action     │
             │                         │     commits board data   │
             │                         │     and triggers deploy  │
    

    Total infrastructure cost is $0. Cloudflare Workers free tier. GitHub Actions free for public repos. GitHub Pages free hosting.

    What we learned

    Git works well as a database for low-write, high-read scenarios. Every change is versioned, history is automatic, and rollback is git revert. The main limitation is write throughput -- concurrent saves need conflict detection, which we solved with optimistic locking.

    Cloudflare Workers handled more than we expected. OAuth flows, session management, and API proxying all fit in a few hundred lines of TypeScript, and the free tier covers the traffic without any cost.

    Third-party cookies bit us on Mobile Safari. If your architecture relies on cross-origin cookies, test on Mobile Safari early. We lost an hour to a problem that would have been obvious if we'd tested there first.

    Shell quoting is a minefield. When passing untrusted content through shell commands, use environment variables. Always.

    GitHub's token permissions have a wrinkle worth knowing about. GITHUB_TOKEN commits don't trigger downstream workflows. PATs do. We needed the deploy workflow to fire after the kanban save, so we had to use a PAT.

    The kanban board now persists. Drag a card, click save, and your changes live in Git forever, no server required.


    The kanban board is live at dylanbochman.com/projects/kanban. The worker code and GitHub Action are in the repository.

    Comments

    Comments will load when you scroll down...