cd /news/developer-tools/show-hn-back-up-claude-code-sessions… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-67043] src=github.com β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

Show HN: Back up Claude Code sessions from Codespaces to a private repo

A developer released a tool called logkeeper that backs up Claude Code sessions from GitHub Codespaces to a private GitHub repo, storing plaintext transcripts and age-encrypted full session data. The tool uses Claude Code hooks to save sessions after every response or on session end, and requires only two folders and a setup script to integrate into existing devcontainer configurations.

read14 min views1 publishedJul 21, 2026
Show HN: Back up Claude Code sessions from Codespaces to a private repo
Image: source

Drop two folders into your project, git add

them, and every Claude Code session gets saved to a private GitHub repo in two layers:

transcripts/<repo>/<date>-<session>.md

β€”plaintext, user and Claude text only. Tool results (file contents, command output β€” where secrets tend to end up) are stripped. Safe-ish to browse and share within the team.raw/<repo>/<session>.jsonl.age

β€” thefull jsonl, age-encrypted. Only holders of a secret key can decrypt it. Restored into~/.claude/projects/

, it brings the session back inclaude --resume

.index.json

β€” session metadata for a viewer.

Trigger: Claude Code hooks (Stop

= after every response, SessionEnd

= on session end). If your Codespace dies mid-session, everything up to the last completed response is already saved.

For the background β€” why the two layers, and the failures that shaped the design β€” see the development notes.

git

,jq

,curl

β€” preinstalled in most dev environments - β€” a small, modern file encryption tool. Install:age

OS Command macOS brew install age

Windows winget install FiloSottile.age

(orscoop install age

/choco install age.portable

)Debian/Ubuntu sudo apt install age

Other platforms and prebuilt binaries: see the

age README. - gh

(GitHub CLI) β€” optional but recommended; used for exact private-repo verification and as a clone/push fallback - In

Codespaces, none of this matters:setup.sh

installs everything, and Claude Code itself is installed by the devcontainer feature.

Both paths come down to the same thing: copy the .claude/

and .devcontainer/

folders from this repo into your project, then point logkeeper at a private logs repo and an age key.

If you're already running Claude Code in a Codespace, you almost certainly have your own

devcontainer.json

configured for your stack.Overwriting it with this repo's copy will break your dev environment.This repo'sdevcontainer.json

is a working example for people who don't have one yet, not a drop-in replacement.You only need to add

two thingsto your existing file: the features that install Claude Code andgh

, and a call tosetup.sh

.

Before(a typical Next.js project):

{
  "name": "example-app Next.js",
  "image": "mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm",
  "forwardPorts": [3000],
  "postCreateCommand": "npm install",
  "customizations": {
    "vscode": {
      "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
    }
  }
}

After(the two additions marked):

{
  "name": "example-app Next.js",
  "image": "mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm",
  "forwardPorts": [3000],

  // ADDED: installs the Claude Code CLI + VS Code extension, and gh
  "features": {
    "ghcr.io/anthropics/devcontainer-features/claude-code:1": {},
    "ghcr.io/devcontainers/features/github-cli:1": {}
  },

  // CHANGED: append setup.sh, keep your existing command
  "postCreateCommand": "npm install && bash .devcontainer/setup.sh",

  "customizations": {
    "vscode": {
      "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
    }
  }
}

The diff is only:

+ "features": {
+   "ghcr.io/anthropics/devcontainer-features/claude-code:1": {},
+   "ghcr.io/devcontainers/features/github-cli:1": {}
+ },
- "postCreateCommand": "npm install",
+ "postCreateCommand": "npm install && bash .devcontainer/setup.sh",

Keep your ownDon't switch to this repo'simage

.base:ubuntu

β€” your image is chosen for your stack. Note that if your image already includes Node.js (likejavascript-node

above), you donotneed thenode

feature; the Claude Code feature just needs Node to be present. If your image has no Node.js, add"ghcr.io/devcontainers/features/node:1": {}

to the features list.

.devcontainer/setup.sh

is a new file and won't collide with anything, so copy it in as-is.

devcontainer changes only take effect in a new container.After committing, either runCodespaces: Rebuild Container

from the command palette, or delete the Codespace and create a fresh one. A plain stop/start will not apply the new features. (To get going in yourcurrentCodespace without rebuilding, just install the two dependencies by hand:sudo apt-get install -y age jq && chmod +x .claude/hooks/logkeeper.sh

.)

Likewise, if you already have a .claude/settings.json

, merge the hooks

block rather than replacing the file β€” see "Merging into an existing settings.json" below. (A .claude/settings.local.json

is a separate file and won't conflict.)

You need two folders from this repo: .claude/

and .devcontainer/

. Click the green Code button at the top of this repo β†’ Download ZIP, unzip it, and copy those two folders into your project. (They're hidden dotfolders; on macOS press Cmd+Shift+.

in Finder to see them.)

That's it if your project has neither folder yet. If it does, see the merge notes above β€” nothing else in this repo is needed at runtime.

Copy the two folders into your project(see above). That's the whole file step β€” nochmod

, no installing anything by hand.setup.sh

marks the hook executable and installsage

,jq

, and the Claude Code CLI + extension when the Codespace is created. - Set three Codespaces secrets(Settings β†’ Codespaces β†’ Secrets), scoped to your project repo. This is the zero-edit path β€” you never touch the config files:Secret Value LOGKEEPER_REPO

owner/claude-logs

β€” aprivate repo you created (gh repo create claude-logs --private

)LOGKEEPER_PUBKEY

your age1...

public key (see "Generating keys"; multiple keys space/newline separated)LOGKEEPER_GH_TOKEN

a fine-grained PAT with Contents write on the logs repo (see step-by-step below) Easy to miss β€” grant each secret access to your WORKING repo. After creating a secret, its "Repository access" is empty by default (the Secrets list shows**"0 repositories"). A secret with no repository access is not injected into any Codespace**, so logkeeper reportsLOGKEEPER_REPO env var is not set

even though you created it. For each of the three secrets, edit it and grant access tothe repo you run Codespaces from(your project, e.g.you/my-app

).Don't confuse this with the PAT's own repository access. There are two different settings with similar names:

Setting Where What to select Codespaces secret→ Repository accessSettings → Codespaces → Secrets your working repo (which Codespaces receive this value)Fine-grained PAT→ Repository accessDeveloper settings → Tokens your logs repo (what the token may write to)Selecting the logs repo on the

secretis the common mistake: you never run a Codespace from the logs repo, so the value is injected nowhere andecho $LOGKEEPER_GH_TOKEN

comes back empty.Secrets are injected only at container start, so after changing this, stop and restart the Codespace. Verify with

echo $LOGKEEPER_REPO

. - Commit and push:git add .claude .devcontainer && git commit -m "add logkeeper" && git push

Create a new Codespace. Everything is wired up on creation; startclaude

and sessions begin saving.

That's it for Codespaces β€” the file step really is just a copy. Note the first build takes a couple of minutes (see "Choosing a base image").

Copy the two folders into your project(see "Getting the files" above). (.devcontainer/

is only used by Codespaces, but it's harmless to keep.) - Install the requirementsβ€”age

,jq

,git

(seeRequirements). - Make the hook executable. This is the one step Codespaces does for you but a local setup needs:

chmod +x .claude/hooks/logkeeper.sh

Configure, either way:- Env vars (take precedence): export LOGKEEPER_REPO="owner/claude-logs"

andexport LOGKEEPER_PUBKEY="age1..."

in your shell profile, or - Edit the files: put your repo in .claude/logkeeper.conf

and your public key in.claude/logkeeper.pub

. (NoLOGKEEPER_GH_TOKEN

needed locally β€” your normal git credentials push to the logs repo.)

  • Env vars (take precedence):

Commit:git add .claude && git commit -m "add logkeeper"

Because .claude/settings.json

is committed, the hooks apply to every teammate who pulls β€” they only add their own key and secrets.

The default Codespaces token can only reach the repo you launched from, so pushing to a separate claude-logs

repo needs your own token. Fine-grained is preferred over a classic token because it can be locked to just this one repo with just one permission. It takes about a minute:

  • On github.com, click your profile picture(top-right) β†’** Settings**. - In the left sidebar, scroll all the way to the bottom β†’ Developer settings. - Click Personal access tokensβ†’** Fine-grained tokensβ†’ Generate new token**. Token name: anything, e.g.logkeeper

.Expiration: pick a duration (fine-grained tokens can't be non-expiring; 90 days is a fine default β€” set a calendar reminder to regenerate).Resource owner: your own account (the one that ownsclaude-logs

).Repository access: choose** Only select repositories**, then select yourclaude-logs

repo. (Don't use "All repositories".)Permissions→ expand** Repository permissions→ find Contentsand set it to Read and write**. That's the only one you need. ("Metadata: Read-only" gets added automatically — leave it.)- Click Generate token, then** copy the token now**— GitHub shows it only once. If you lose it, just generate another. - Add it as a Codespaces secret named LOGKEEPER_GH_TOKEN

(step 2 above).

Alternative toyou can instead uncomment theLOGKEEPER_GH_TOKEN

:customizations

block in.devcontainer/devcontainer.json

and hardcode the logs repo name. Two gotchas: that blockcannot read env vars(GitHub reads it before the container exists, so it needs a literalowner/name

), and itonly affects newly created Codespaces, not existing ones. On creation GitHub shows a one-click prompt to authorize write access. The PAT route above is simpler and is the true zero-edit path.

age-keygen -o ~/.config/age/logkeeper.txt

The age1...

line is the public key (safe to share/commit, and what goes in LOGKEEPER_PUBKEY

or .claude/logkeeper.pub

). The AGE-SECRET-KEY-...

line is the secret key: keep it locally and in a password manager only, never on GitHub or in a Codespaces secret. Losing it makes the raw layer permanently undecryptable. For team setups, see "Using logkeeper with a team" below.

If your project already has .claude/settings.json

, don't overwrite it β€” copy the hooks

block from this repo's settings.json

into yours (merge the Stop

and SessionEnd

arrays). Copy the other files (hooks/logkeeper.sh

, logkeeper.conf

, logkeeper.pub

) as-is.

age can encrypt to multiple recipients at once, and logkeeper uses that directly: every key listed in .claude/logkeeper.pub

(or in the LOGKEEPER_PUBKEY

env var) becomes a recipient, and each member decrypts with their own secret key. There is no shared secret to distribute.

Onboarding a member

  • The new member generates their own pair locally: age-keygen -o ~/.config/age/logkeeper.txt

  • They send you only the age1...

public key (it is not sensitive β€” chat or PR is fine). - Add it as a new line in .claude/logkeeper.pub

and commit. Every log saved from then on is decryptable by them. - Give them read access to the private logs repo.

Because .claude/settings.json

and the hook script are committed to the project repo, the member needs no further setup β€” their sessions start being logged automatically once they pull.

Offboarding a member

Remove their line from logkeeper.pub

and revoke their access to the logs repo. Logs saved after that point are no longer decryptable by them. Be aware of the honest limitation: logs saved before removal remain decryptable with their old key if they kept copies of the ciphertext. If that matters (e.g. a hostile departure), rotate by creating a fresh logs repo, or re-encrypt the raw/

tree to the new recipient list:

for f in raw/**/*.jsonl.age; do
  age -d -i ~/.config/age/logkeeper.txt "$f" \
    | age -R .claude/logkeeper.pub -o "$f.new" && mv "$f.new" "$f"
done

Alternative: reuse existing SSH keys

age also accepts ssh-ed25519

/ ssh-rsa

public keys as recipients, and every GitHub user's SSH public keys are available at https://github.com/USERNAME.keys

. A team can skip age key generation entirely: put each member's GitHub SSH public key in logkeeper.pub

, and each member decrypts with age -d -i ~/.ssh/id_ed25519

. Zero new keys to manage β€” though members' backups then depend on how well they protect their SSH keys.

logkeeper degrades instead of failing silently: the plaintext transcript is still saved, and raw/<repo>/<session>.ENCRYPTION-ERROR.txt

is written in place of the encrypted jsonl, containing the error and how to fix it. Errors printed to a Codespaces terminal are easy to miss; an error note sitting in the logs repo is not. Once a valid key appears, the next save replaces the error note with the real .jsonl.age

.

Saving is refused entirely only when: dependencies are missing, no destination repo is configured, or the destination repo is public (safety gate).

age -d -i ~/.config/age/logkeeper.txt raw/<repo>/<session>.jsonl.age \
  > ~/.claude/projects/<encoded-project-path>/<session>.jsonl
claude --resume   # the restored session appears in the picker

<encoded-project-path>

is the absolute project path with every non-alphanumeric character replaced by -

.

.devcontainer/devcontainer.json

ships with a lightweight base image:

"image": "mcr.microsoft.com/devcontainers/base:ubuntu",

Even with this lightweight image, expect the first Codespace build to take roughly 2–3 minutes β€” it still installs three features (Node.js, the Claude Code CLI + extension, GitHub CLI) plus age

/jq

, so it is not instant. It's noticeably faster than the universal image (which can take ~10 minutes on first build), and it's cached afterward, so subsequent stop/start is quick β€” only a fresh creation or a rebuild pays the build cost again. If the first build feels slow, that's expected; give it a couple of minutes.

Everything logkeeper needs gets installed via features and setup.sh

(Node.js, the Claude Code CLI + extension, age

, jq

, gh

).

If you'd rather have GitHub's full "universal" image β€” with many languages and runtimes (Python, Ruby, Go, Java, etc.) preinstalled, matching the default Codespaces environment β€” swap that one line for:

"image": "mcr.microsoft.com/devcontainers/universal:2",

The trade-off is a much slower first build: the universal image is several GB, so the initial pull and extract can take several minutes. It's cached afterward, so only the first creation (or a rebuild) pays that cost. If you go this route and create Codespaces often, consider enabling prebuilds to make creation near-instant.

Either way, keep the node

feature in the list β€” the Claude Code feature needs Node.js, and base:ubuntu

doesn't include it.

If you install logkeeper into projects often, or want to hand a preconfigured copy to teammates, it's convenient to package the two folders into a zip. This repo doesn't ship one (a committed binary would drift out of sync with the source), but you can build one in a second:

git clone --depth 1 https://github.com/markmatsu/claude-logkeeper
cd claude-logkeeper
zip -r ../logkeeper-install.zip .claude .devcontainer

The archive holds .claude/

and .devcontainer/

at the top level, so extracting it at a project root drops them exactly where they belong without touching your existing files:

cd /path/to/your-project
unzip /path/to/logkeeper-install.zip

Two things worth knowing:

Use On macOS, Archive Utility wraps multi-item archives in a folder named after the zip (unzip

in a terminal, not a double-click.logkeeper-install/

), and since.claude

and.devcontainer

are hidden dotfolders, it looks like nothing happened. If you already double-clicked:mv logkeeper-install/.claude logkeeper-install/.devcontainer . && rmdir logkeeper-install

If you pre-fill Public keys and a repo name are fine to share. A private key never is.logkeeper.conf

/logkeeper.pub

for your team, remember what's in them.

Failures are effectively silent. This is the sharpest edge, so know it going in. logkeeper writes its errors to stderr, but hook stderr is only surfaced in Claude Code's transcript view (Ctrl+O

) or underclaude --debug

β€” and in a browser-based Codespace with the VS Code extension, it is very easy to never see it. If logkeeper stops working (an expired token, a renamed repo, a secret that lost its repository access),the symptom you will notice is simply that no new files appear in the logs repo. Nothing pops up to tell you.We deliberately did not route errors through Claude Code's

systemMessage

JSON channel. Doing so would require the hook to write JSON to stdout, which Claude Code parses β€” and since this hook shells out togit

,age

,jq

, andcurl

, a single stray line of stdout from any of them would corrupt that JSON and break the session. Keeping stdout empty is the safer design, at the cost of quieter failures.To diagnose, run the hook by hand and read the error directly:

cd /path/to/your-project
ENC=$(pwd | sed 's/[^a-zA-Z0-9]/-/g')
LATEST=$(ls -t ~/.claude/projects/${ENC}/*.jsonl | head -1)
echo "{\"transcript_path\":\"${LATEST}\",\"session_id\":\"manual-test\"}" \
  | bash .claude/hooks/logkeeper.sh

It prints exactly what went wrong. A good habit is to glance at the logs repo occasionally: if the newest transcript is older than your last session, something is broken.

The jsonl format is internal and unversioned. The extraction filter ignores unknown entry types for forward compatibility, but breaking changes may require updates.

Plaintext transcripts exclude tool results, but

do include anything you pasted into prompts and any code Claude quoted in its replies. Mind the sharing scope of the logs repo. - The

Stop

hook clones and pushes after every response. On a large logs repo this gets slow; dropStop

from settings.json to save only onSessionEnd

(at the cost of losing the final session if the container dies abruptly). - Concurrent sessions in the same project are safe: each hook receives its own

transcript_path

, so sessions never get mixed up. - The Codespaces "universal" image currently ships a third-party apt repo (yarn) with an expired signing key, which makes

apt-get update

return a non-zero code.setup.sh

tolerates this (it never aborts on it), but if you see a yarnNO_PUBKEY

GPG warning during creation, it's harmless β€”age

/jq

still install. The lightweightbase:ubuntu

image avoids the noisy warning entirely.

This is a personal tool I built for myself and published in case it's useful to others. I'm not planning active maintenance and can't promise timely responses to issues.

That said, the tool was designed in conversation with Claude, and it's small enough that an AI can reason about the whole thing at once. If you hit a problem or want to add a feature, you will likely get a faster and better answer by handing DESIGN.md to an AI assistant than by waiting for me. That file contains the full specification, the reasoning behind each design decision, and the failure modes I hit while building it β€” everything an AI needs to modify this safely.

Bug reports are still welcome, and PRs even more so.

logkeeper is an independent, community project. It is not affiliated with, endorsed by, or sponsored by Anthropic. "Claude" and "Claude Code" are trademarks of Anthropic, PBC, used here only to describe what this tool works with.

MIT. See LICENSE.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/show-hn-back-up-clau…] indexed:0 read:14min 2026-07-21 Β· β€”