Home | GitHub | LinkedIn | UK

Using GitHub as an Encrypted Forgejo Backup

I don't like the idea of putting all of my eggs in one basket. Imagine putting your life's work onto some SSD you got off of Amazon without a second thought. Maybe you've got a fancy RAID setup so hardware failure doesn't apply to you? That means nothing if your hardware gets destroyed by a fire or stolen by a rogue gaggle of gnomes. That got me thinking: how can I back up my repos elsewhere if I don't trust other people with my code?

The Idea

I love self-hosting as much as I can; honestly, after GitHub announced they would charge for the use of self-hosted runners, I think that was the final straw. I did my research and landed on Forgejo, but how can I add some redundancy? What if I could still use GitHub to host my code, but now allow Microsoft to get their grubby LLM training hands on it? What if I could encrypt my code and commit it onto GitHub as a mirror of my private repositories???

Is This Against TOS?

As of writing, the GitHub terms of service (TOS) doesn't explicitly forbid uploading encrypted blobs... so we're good!!

How Should We Encrypt It?

A password should be used for the encryption. The reason we use a password over anything else is that you can easily remember it. If you all of a sudden lose all of your hardware that you host your git on, you can still get it all back as long as you've remembered the password. The password doesn't even have to be secure (it could just be "password"), since the whole reason I wanted to encrypt stuff in the first place was to prevent LLMs from being trained off of my code.

As for the method of encryption, I decided to use GPG!

The Implementation

I created a new repository on Forgejo that acted as a reusable composite action. The repository just consists of a README.md and an action.yml. If you haven’t used composite actions before, here’s the simplest way to think about them:

A composite action is basically “a little script packaged like an action”.

It’s a list of shell steps that you can re-use in lots of repos without copy/pasting.

Below is the action, but instead of dumping it and running away, we’ll walk through it step-by-step.

Action Configuration

Before we get into the steps, here's what you should configure.

Inputs

These are values you pass in your workflow YAML:

Environment Variables and Secrets

This action expects two secrets to exist as environment variables:

It also needs a token to clone the source repo inside the runner:

Why both? Because different runners / setups expose tokens differently. This action tries to “just work” in both Forgejo and GitHub-style environments.

Step 1: Install Tools

This step installs everything the runner needs: git, gpg, curl, jq, etc.

- name: Install tools
  shell: sh
  run: |
    set -euo pipefail
    apk add --no-cache \
      git gnupg coreutils findutils \
      curl jq ca-certificates
    update-ca-certificates

Step 2: Checkout the Repo (manually)

Normally you’d use actions/checkout. But this is Forgejo + act friendly, so it clones manually.

- name: Checkout (manual, Forgejo/act-safe)
  shell: sh
  env:
    FORGEJO_TOKEN: $
    GITHUB_TOKEN: $
  run: |
    set -euo pipefail

    TOKEN="${FORGEJO_TOKEN:-${GITHUB_TOKEN:-}}"
    [ -n "${TOKEN:-}" ] || { echo "ERROR: No FORGEJO_TOKEN/GITHUB_TOKEN available for cloning."; exit 1; }

    SERVER="${GITHUB_SERVER_URL:-${CI_SERVER_URL:-}}"
    REPO="${GITHUB_REPOSITORY:-}"
    [ -n "${SERVER:-}" ] || { echo "ERROR: No GITHUB_SERVER_URL/CI_SERVER_URL set by runner."; exit 1; }
    [ -n "${REPO:-}" ] || { echo "ERROR: No GITHUB_REPOSITORY set by runner."; exit 1; }

    URL="${SERVER}/${REPO}.git"
    echo "Cloning: ${URL}"

    rm -rf ./* ./.??* 2>/dev/null || true

    case "$URL" in
      https://*) AUTH_URL="https://x-access-token:${TOKEN}@${URL#https://}" ;;
      http://*)  AUTH_URL="http://x-access-token:${TOKEN}@${URL#http://}" ;;
      *) echo "ERROR: Unexpected URL scheme: $URL"; exit 1 ;;
    esac

    git clone --depth 1 "$AUTH_URL" .
    echo "Checked out commit: $(git rev-parse HEAD)"

This picks a token, preferring FORGEJO_TOKEN, but falling back to GITHUB_TOKEN. It then figures out where the repo lives, builds a clone URL, wipes the workspace, and shallow clones the repository.

Step 3: Validate Configuration and Write a State File

This step checks you provided everything, figures out names, normalizes booleans, then writes the key values into mirror.env (which is also encrypted and packaged with the repo).

- name: Validate / Configure
  shell: sh
  env:
    MIRROR_PASSWORD: $
    GH_PAT: $

    INPUT_GH_OWNER: $
    INPUT_TARGET_REPO: $
    INPUT_TARGET_BRANCH: $
    INPUT_INCLUDE_FORGEJO_DIR: $
    INPUT_CREATE_REPO: $
  run: |
    set -euo pipefail

    [ -n "${MIRROR_PASSWORD:-}" ] || { echo "Missing env.MIRROR_PASSWORD"; exit 1; }
    [ -n "${GH_PAT:-}" ] || { echo "Missing env.GH_PAT (GitHub PAT)"; exit 1; }
    [ -n "${INPUT_GH_OWNER:-}" ] || { echo "Missing input gh_owner"; exit 1; }

    echo "::add-mask::${MIRROR_PASSWORD}"
    echo "::add-mask::${GH_PAT}"

    origin="$(git remote get-url origin 2>/dev/null || true)"
    [ -n "$origin" ] || { echo "ERROR: No git origin found after checkout."; exit 1; }
    SRC_REPO_NAME="$(printf '%s' "$origin" | sed -E 's#\.git$##; s#^.*[:/]+([^/]+)/([^/]+)$#\2#')"
    [ -n "$SRC_REPO_NAME" ] || { echo "ERROR: Failed to derive source repo name from origin: $origin"; exit 1; }

    GH_OWNER="${INPUT_GH_OWNER}"

    TARGET_REPO="${INPUT_TARGET_REPO:-}"
    if [ -z "$TARGET_REPO" ]; then TARGET_REPO="$SRC_REPO_NAME"; fi

    TARGET_BRANCH="${INPUT_TARGET_BRANCH:-main}"
    INCLUDE_FORGEJO_DIR="${INPUT_INCLUDE_FORGEJO_DIR:-true}"
    CREATE_REPO="${INPUT_CREATE_REPO:-false}"

    case "$INCLUDE_FORGEJO_DIR" in true|false) ;; "true"|"false") INCLUDE_FORGEJO_DIR="${INCLUDE_FORGEJO_DIR%\"}"; INCLUDE_FORGEJO_DIR="${INCLUDE_FORGEJO_DIR#\"}";; 1) INCLUDE_FORGEJO_DIR="true";; 0) INCLUDE_FORGEJO_DIR="false";; *) INCLUDE_FORGEJO_DIR="true";; esac
    case "$CREATE_REPO" in true|false) ;; "true"|"false") CREATE_REPO="${CREATE_REPO%\"}"; CREATE_REPO="${CREATE_REPO#\"}";; 1) CREATE_REPO="true";; 0) CREATE_REPO="false";; *) CREATE_REPO="false";; esac

    GH_REPO="${GH_OWNER}/${TARGET_REPO}"

    printf 'SRC_REPO_NAME=%s\n' "$SRC_REPO_NAME" > mirror.env
    printf 'GH_OWNER=%s\n' "$GH_OWNER" >> mirror.env
    printf 'TARGET_REPO=%s\n' "$TARGET_REPO" >> mirror.env
    printf 'TARGET_BRANCH=%s\n' "$TARGET_BRANCH" >> mirror.env
    printf 'INCLUDE_FORGEJO_DIR=%s\n' "$INCLUDE_FORGEJO_DIR" >> mirror.env
    printf 'CREATE_REPO=%s\n' "$CREATE_REPO" >> mirror.env
    printf 'GH_REPO=%s\n' "$GH_REPO" >> mirror.env

    echo "Configured:"
    echo "  Source repo:      $SRC_REPO_NAME"
    echo "  GitHub target:    $GH_REPO"
    echo "  Target branch:    $TARGET_BRANCH"
    echo "  Include .forgejo: $INCLUDE_FORGEJO_DIR"
    echo "  Create repo:      $CREATE_REPO"

This step fails early if secrets / required inputs are missing and performs some other useful steps:

Why Write mirror.env?

Because each step in a composite action runs in a fresh shell. This is the easiest way to pass “state” from one step to the next without re-calculating everything.

Step 4: Encrypt the Entire Repository

This is the core: take the repo, output a second repo that contains only encrypted blobs.

- name: Encrypt repo (file-by-file)
  shell: sh
  env:
    MIRROR_PASSWORD: $
  run: |
    set -euo pipefail
    umask 077
    . ./mirror.env

    SRC="$(pwd)"
    OUT="$(mktemp -d)"
    echo "Using OUT=${OUT}"

    if [ "${INCLUDE_FORGEJO_DIR}" = "true" ]; then
      FIND_CMD='find . -type f ! -path "./.git/*" -print0'
    else
      FIND_CMD='find . -type f ! -path "./.git/*" ! -path "./.forgejo/*" ! -path "./.forgejo" -print0'
    fi

    eval "$FIND_CMD" | while IFS= read -r -d '' f; do
      rel="${f#./}"
      mkdir -p "$OUT/$(dirname "$rel")"
      gpg --batch --yes --pinentry-mode loopback \
        --passphrase "$MIRROR_PASSWORD" \
        --symmetric --cipher-algo AES256 \
        -o "$OUT/$rel.gpg" "$f"
    done

    cat > "$OUT/README.txt" <<EOF
    This repository is an encrypted mirror of \`${SRC_REPO_NAME}\`.
    Each original file is stored as <path>.gpg and was encrypted symmetrically with a passphrase.
    EOF

    count="$(find "$OUT" -type f -name '*.gpg' | wc -l | tr -d ' ')"
    echo "Encrypted file count: $count"
    [ "$count" != "0" ] || { echo "ERROR: No files were encrypted."; exit 1; }

    bad="$(find "$OUT" -type f ! -name '*.gpg' ! -name 'README.txt' | head -n 20 || true)"
    if [ -n "$bad" ]; then
      echo "Refusing: found unexpected plaintext files in mirror output:"
      echo "$bad"
      exit 1
    fi

    printf 'OUT_DIR=%s\n' "$OUT" >> "$SRC/mirror.env"

Step 5 (Optional): Create the Remote Repository

This step calls the GitHub API to check if the destination repo exists. If it doesn’t:

- name: Ensure GitHub repo exists (optional create)
  shell: sh
  env:
    GH_PAT: $
  run: |
    set -euo pipefail
    . ./mirror.env

    api="https://api.github.com"
    auth_hdr="Authorization: token ${GH_PAT}"
    accept="Accept: application/vnd.github+json"
    version="X-GitHub-Api-Version: 2022-11-28"

    status="$(curl -sS -o /tmp/gh_repo.json -w '%{http_code}' \
      -H "$auth_hdr" -H "$accept" -H "$version" \
      "${api}/repos/${GH_REPO}" || true)"
    echo "Repo check status: $status"

    if [ "$status" = "200" ]; then
      echo "GitHub repo exists: ${GH_REPO}"
      exit 0
    fi

    if [ "$status" != "404" ]; then
      cat /tmp/gh_repo.json || true
      echo "ERROR: Unexpected status checking repo."
      exit 1
    fi

    if [ "${CREATE_REPO}" != "true" ]; then
      echo "ERROR: GitHub repo missing: ${GH_REPO}"
      echo "Create it manually (private), or set create_repo=true (requires PAT perms)."
      exit 1
    fi

    payload="$(jq -n --arg name "$TARGET_REPO" '{
      name: $name,
      private: true,
      has_issues: false,
      has_projects: false,
      has_wiki: false,
      auto_init: false
    }')"

    create_status="$(curl -sS -o /tmp/gh_create.json -w '%{http_code}' \
      -X POST -H "$auth_hdr" -H "$accept" -H "$version" \
      -d "$payload" \
      "${api}/user/repos" || true)"
    echo "Create status: $create_status"

    if [ "$create_status" != "201" ]; then
      cat /tmp/gh_create.json || true
      echo "ERROR: Failed to create repo. Use a classic PAT with 'repo' scope, or pre-create the repo."
      exit 1
    fi

    echo "Created GitHub repo: ${GH_REPO}"

Step 6: Convert the Encrypted Blobs into a Repository and Commit

Now we take the $OUT_DIR folder (full of .gpg files), initialize a brand new git repo in it, commit everything, and force push to GitHub.

- name: Push encrypted mirror to GitHub
  shell: sh
  env:
    GH_PAT: $
  run: |
    set -euo pipefail
    . ./mirror.env

    : "${OUT_DIR:?OUT_DIR not set}"
    : "${GH_REPO:?GH_REPO not set}"
    : "${TARGET_BRANCH:?TARGET_BRANCH not set}"

    cd "$OUT_DIR"

    git init
    git config user.name  "forgejo-actions"
    git config user.email "[email protected]"
    git config --global init.defaultBranch main

    git add -A
    git commit -m "Encrypted mirror @ ${GITHUB_SHA:-unknown}" || true

    git branch -M "$TARGET_BRANCH"
    git remote add origin "https://x-access-token:${GH_PAT}@github.com/${GH_REPO}.git"
    git push --force --set-upstream origin "$TARGET_BRANCH"

How Do I Use It?

You just need to include a small amount of configuration in your workflow. Here's an example from one of my projects:

name: Mirror to GitHub

on:
  push:
    branches: ["main"]
  workflow_dispatch: {}

jobs:
  mirror:
    runs-on: docker
    container:
      image: node:20-alpine
    steps:
      - uses: http://git.home/alex.thomson/github-mirror-action@main
        with:
          gh_owner: alexjthomson
          target_branch: main
          include_forgejo_dir: true
          create_repo: false
        env:
          GH_PAT: $
          MIRROR_PASSWORD: $
          FORGEJO_TOKEN: $

How Do I Decrypt It?

On your machine (not in CI), you can decrypt a file like:

gpg --output package.json --decrypt package.json.gpg

You’ll be prompted for the passphrase.

If you want to restore the whole repo, you can write a small script that finds all *.gpg files, decrypts them, and strips the .gpg extension.

The Final Action

Who am I to deny you the entire action? Here you go!

name: Encrypted mirror to GitHub
description: Encrypt each file in the repo and push to a private GitHub repo.
inputs:
  gh_owner:
    description: "GitHub owner/org (required)"
    required: true
  target_repo:
    description: "Target GitHub repo name (blank = same as source repo name)"
    required: false
    default: ""
  target_branch:
    description: "Target branch on GitHub"
    required: false
    default: "main"
  include_forgejo_dir:
    description: "Encrypt .forgejo directory too (true/false)"
    required: false
    default: "true"
  create_repo:
    description: "Create GitHub repo if missing (true/false)"
    required: false
    default: "false"
runs:
  using: "composite"
  steps:
    - name: Install tools
      shell: sh
      run: |
        set -euo pipefail
        apk add --no-cache \
          git gnupg coreutils findutils \
          curl jq ca-certificates
        update-ca-certificates

    - name: Checkout (manual, Forgejo/act-safe)
      shell: sh
      env:
        FORGEJO_TOKEN: $
        GITHUB_TOKEN: $
      run: |
        set -euo pipefail

        TOKEN="${FORGEJO_TOKEN:-${GITHUB_TOKEN:-}}"
        [ -n "${TOKEN:-}" ] || { echo "ERROR: No FORGEJO_TOKEN/GITHUB_TOKEN available for cloning."; exit 1; }

        SERVER="${GITHUB_SERVER_URL:-${CI_SERVER_URL:-}}"
        REPO="${GITHUB_REPOSITORY:-}"
        [ -n "${SERVER:-}" ] || { echo "ERROR: No GITHUB_SERVER_URL/CI_SERVER_URL set by runner."; exit 1; }
        [ -n "${REPO:-}" ] || { echo "ERROR: No GITHUB_REPOSITORY set by runner."; exit 1; }

        URL="${SERVER}/${REPO}.git"
        echo "Cloning: ${URL}"

        rm -rf ./* ./.??* 2>/dev/null || true

        case "$URL" in
          https://*) AUTH_URL="https://x-access-token:${TOKEN}@${URL#https://}" ;;
          http://*)  AUTH_URL="http://x-access-token:${TOKEN}@${URL#http://}" ;;
          *) echo "ERROR: Unexpected URL scheme: $URL"; exit 1 ;;
        esac

        git clone --depth 1 "$AUTH_URL" .
        echo "Checked out commit: $(git rev-parse HEAD)"

    - name: Validate / Configure
      shell: sh
      env:
        MIRROR_PASSWORD: $
        GH_PAT: $

        INPUT_GH_OWNER: $
        INPUT_TARGET_REPO: $
        INPUT_TARGET_BRANCH: $
        INPUT_INCLUDE_FORGEJO_DIR: $
        INPUT_CREATE_REPO: $
      run: |
        set -euo pipefail

        [ -n "${MIRROR_PASSWORD:-}" ] || { echo "Missing env.MIRROR_PASSWORD"; exit 1; }
        [ -n "${GH_PAT:-}" ] || { echo "Missing env.GH_PAT (GitHub PAT)"; exit 1; }
        [ -n "${INPUT_GH_OWNER:-}" ] || { echo "Missing input gh_owner"; exit 1; }

        echo "::add-mask::${MIRROR_PASSWORD}"
        echo "::add-mask::${GH_PAT}"

        origin="$(git remote get-url origin 2>/dev/null || true)"
        [ -n "$origin" ] || { echo "ERROR: No git origin found after checkout."; exit 1; }
        SRC_REPO_NAME="$(printf '%s' "$origin" | sed -E 's#\.git$##; s#^.*[:/]+([^/]+)/([^/]+)$#\2#')"
        [ -n "$SRC_REPO_NAME" ] || { echo "ERROR: Failed to derive source repo name from origin: $origin"; exit 1; }

        GH_OWNER="${INPUT_GH_OWNER}"

        TARGET_REPO="${INPUT_TARGET_REPO:-}"
        if [ -z "$TARGET_REPO" ]; then TARGET_REPO="$SRC_REPO_NAME"; fi

        TARGET_BRANCH="${INPUT_TARGET_BRANCH:-main}"
        INCLUDE_FORGEJO_DIR="${INPUT_INCLUDE_FORGEJO_DIR:-true}"
        CREATE_REPO="${INPUT_CREATE_REPO:-false}"

        # Normalise booleans (accept true/false, "true"/"false", 1/0)
        case "$INCLUDE_FORGEJO_DIR" in true|false) ;; "true"|"false") INCLUDE_FORGEJO_DIR="${INCLUDE_FORGEJO_DIR%\"}"; INCLUDE_FORGEJO_DIR="${INCLUDE_FORGEJO_DIR#\"}";; 1) INCLUDE_FORGEJO_DIR="true";; 0) INCLUDE_FORGEJO_DIR="false";; *) INCLUDE_FORGEJO_DIR="true";; esac
        case "$CREATE_REPO" in true|false) ;; "true"|"false") CREATE_REPO="${CREATE_REPO%\"}"; CREATE_REPO="${CREATE_REPO#\"}";; 1) CREATE_REPO="true";; 0) CREATE_REPO="false";; *) CREATE_REPO="false";; esac

        GH_REPO="${GH_OWNER}/${TARGET_REPO}"

        printf 'SRC_REPO_NAME=%s\n' "$SRC_REPO_NAME" > mirror.env
        printf 'GH_OWNER=%s\n' "$GH_OWNER" >> mirror.env
        printf 'TARGET_REPO=%s\n' "$TARGET_REPO" >> mirror.env
        printf 'TARGET_BRANCH=%s\n' "$TARGET_BRANCH" >> mirror.env
        printf 'INCLUDE_FORGEJO_DIR=%s\n' "$INCLUDE_FORGEJO_DIR" >> mirror.env
        printf 'CREATE_REPO=%s\n' "$CREATE_REPO" >> mirror.env
        printf 'GH_REPO=%s\n' "$GH_REPO" >> mirror.env

        echo "Configured:"
        echo "  Source repo:      $SRC_REPO_NAME"
        echo "  GitHub target:    $GH_REPO"
        echo "  Target branch:    $TARGET_BRANCH"
        echo "  Include .forgejo: $INCLUDE_FORGEJO_DIR"
        echo "  Create repo:      $CREATE_REPO"

    - name: Encrypt repo (file-by-file)
      shell: sh
      env:
        MIRROR_PASSWORD: $
      run: |
        set -euo pipefail
        umask 077
        . ./mirror.env

        SRC="$(pwd)"
        OUT="$(mktemp -d)"
        echo "Using OUT=${OUT}"

        if [ "${INCLUDE_FORGEJO_DIR}" = "true" ]; then
          FIND_CMD='find . -type f ! -path "./.git/*" -print0'
        else
          FIND_CMD='find . -type f ! -path "./.git/*" ! -path "./.forgejo/*" ! -path "./.forgejo" -print0'
        fi

        eval "$FIND_CMD" | while IFS= read -r -d '' f; do
          rel="${f#./}"
          mkdir -p "$OUT/$(dirname "$rel")"
          gpg --batch --yes --pinentry-mode loopback \
            --passphrase "$MIRROR_PASSWORD" \
            --symmetric --cipher-algo AES256 \
            -o "$OUT/$rel.gpg" "$f"
        done

        cat > "$OUT/README.txt" <<EOF
        This repository is an encrypted mirror of \`${SRC_REPO_NAME}\`.
        Each original file is stored as <path>.gpg and was encrypted symmetrically with a passphrase.
        EOF

        count="$(find "$OUT" -type f -name '*.gpg' | wc -l | tr -d ' ')"
        echo "Encrypted file count: $count"
        [ "$count" != "0" ] || { echo "ERROR: No files were encrypted."; exit 1; }

        bad="$(find "$OUT" -type f ! -name '*.gpg' ! -name 'README.txt' | head -n 20 || true)"
        if [ -n "$bad" ]; then
          echo "Refusing: found unexpected plaintext files in mirror output:"
          echo "$bad"
          exit 1
        fi

        printf 'OUT_DIR=%s\n' "$OUT" >> "$SRC/mirror.env"

    - name: Ensure GitHub repo exists (optional create)
      shell: sh
      env:
        GH_PAT: $
      run: |
        set -euo pipefail
        . ./mirror.env

        api="https://api.github.com"
        auth_hdr="Authorization: token ${GH_PAT}"
        accept="Accept: application/vnd.github+json"
        version="X-GitHub-Api-Version: 2022-11-28"

        status="$(curl -sS -o /tmp/gh_repo.json -w '%{http_code}' \
          -H "$auth_hdr" -H "$accept" -H "$version" \
          "${api}/repos/${GH_REPO}" || true)"
        echo "Repo check status: $status"

        if [ "$status" = "200" ]; then
          echo "GitHub repo exists: ${GH_REPO}"
          exit 0
        fi

        if [ "$status" != "404" ]; then
          cat /tmp/gh_repo.json || true
          echo "ERROR: Unexpected status checking repo."
          exit 1
        fi

        if [ "${CREATE_REPO}" != "true" ]; then
          echo "ERROR: GitHub repo missing: ${GH_REPO}"
          echo "Create it manually (private), or set create_repo=true (requires PAT perms)."
          exit 1
        fi

        # Create in user namespace (works for user; for org, token must permit and you should adapt endpoint)
        payload="$(jq -n --arg name "$TARGET_REPO" '{
          name: $name,
          private: true,
          has_issues: false,
          has_projects: false,
          has_wiki: false,
          auto_init: false
        }')"

        create_status="$(curl -sS -o /tmp/gh_create.json -w '%{http_code}' \
          -X POST -H "$auth_hdr" -H "$accept" -H "$version" \
          -d "$payload" \
          "${api}/user/repos" || true)"
        echo "Create status: $create_status"

        if [ "$create_status" != "201" ]; then
          cat /tmp/gh_create.json || true
          echo "ERROR: Failed to create repo. Use a classic PAT with 'repo' scope, or pre-create the repo."
          exit 1
        fi

        echo "Created GitHub repo: ${GH_REPO}"

    - name: Push encrypted mirror to GitHub
      shell: sh
      env:
        GH_PAT: $
      run: |
        set -euo pipefail
        . ./mirror.env

        : "${OUT_DIR:?OUT_DIR not set}"
        : "${GH_REPO:?GH_REPO not set}"
        : "${TARGET_BRANCH:?TARGET_BRANCH not set}"

        cd "$OUT_DIR"

        git init
        git config user.name  "forgejo-actions"
        git config user.email "[email protected]"
        git config --global init.defaultBranch main

        git add -A
        git commit -m "Encrypted mirror @ ${GITHUB_SHA:-unknown}" || true

        git branch -M "$TARGET_BRANCH"
        git remote add origin "https://x-access-token:${GH_PAT}@github.com/${GH_REPO}.git"
        git push --force --set-upstream origin "$TARGET_BRANCH"