Skip to content
All posts

Confluence12 min readYamuno Team

How to Sync Docs from GitHub to Confluence Automatically

How to Sync Docs from GitHub to Confluence Automatically

Short answer: Keep the Markdown in GitHub and let a GitHub Actions workflow push changes to Confluence. When files under docs/ change on main, the workflow sends each changed .md file to the Markdown Importer for Confluence REST API, which creates or updates the page. The open source confluence-markdown-sync Action does this without writing your own script.

Documentation drift is one of the most common problems in engineering teams. The Markdown files in your GitHub repo are accurate, because they're updated in the same pull request that changes the code. The Confluence pages that were supposed to mirror them were last updated six months ago by someone who has since left the team.

This guide shows how to wire GitHub and Confluence together so the repo is the single source of truth and Confluence picks up every change on its own. It covers the ongoing sync. If you're moving a docs folder into Confluence for the first time, start with the one-off GitHub and GitLab migration guide and come back here once the pages exist.


How the Sync Works

The setup has three parts:

  • Markdown Importer for Confluence, which includes a REST API for importing Markdown
  • A GitHub Actions workflow that runs when files under docs/ change on main
  • A bash script that finds the changed .md files and sends each one to the API

The API is deliberately simple, and it's worth knowing its shape before you build on it:

  • One endpoint per site. Each Confluence site gets its own endpoint URL, shown in the app's API Token Management page. It looks like https://your-site.atlassian.net/wiki/apps/<app-id>/<env-id>/webtrigger.
  • App-issued Bearer token. You create the token inside the app. It is not an Atlassian account API token, and there is no Basic auth.
  • JSON in, one page per request. The body is spaceId, parentId, pageTitle, content (the raw Markdown) and an optional overwrite flag.
  • Create or update. A new page returns 201. With overwrite: true, a page with the same title in the space is updated, placed under the parent you send, and returns 200.
  • Content only. The API doesn't upload attachments, so local images need a separate step (covered below).

The Quick Option: Use the Ready-Made Action

If you don't need to write the script yourself, use confluence-markdown-sync, an open source GitHub Action that wraps the same API. After Steps 1 and 2 below, the whole workflow step is:

- uses: actions/checkout@v4
- uses: Yamuno-Software/confluence-markdown-sync@v1
  with:
    endpoint: ${{ secrets.CONFLUENCE_API_ENDPOINT }}
    token: ${{ secrets.CONFLUENCE_API_TOKEN }}
    space-id: ${{ vars.CONFLUENCE_SPACE_ID }}
    parent-id: ${{ vars.CONFLUENCE_PARENT_ID }}
    path: docs

It takes the page title from front matter, the first heading or the filename, skips empty files, retries rate limits and writes a results table to the job summary. Every file goes under one parent page, and it sends every matching file on each run. If you need per-folder parent pages or only the changed files sent, use the script in Step 3 instead.


Step 1: Create an API Token

  1. In Confluence, go to Confluence Settings, then Apps, then Markdown Importer for Confluence, and open the API tab. You need admin access to Confluence global settings.
  2. Click Create New Token, give it a label such as github-docs-sync, and choose an expiry.
  3. Copy the token straight away. It is shown only once.
  4. Copy the endpoint URL from the "API Documentation" section on the same page.

Tokens last at most 30 days (you can choose from 1 day to 1 month), so plan to rotate the CI token at least monthly. When it expires, requests fail with 403 and you'll see it in the workflow logs. Tokens also act with the Confluence permissions of the person who created them, so many teams create the token from a dedicated account that can only write to the docs space.


Step 2: Collect the Space and Parent IDs

Every request needs a spaceId and a parentId. The easiest way to find both is inside the app: open Markdown Importer for Confluence, pick the space in the Space selector, and pick the parent page in the Page selector. Both show the ID you need. Use the numeric space ID: the space key (such as DOCS) is rejected with a 400.

Then add these to your repository under Settings > Secrets and variables > Actions:

Name Type Value
CONFLUENCE_API_ENDPOINT Secret Your site's endpoint URL
CONFLUENCE_API_TOKEN Secret The token from Step 1
CONFLUENCE_SPACE_ID Variable The numeric space ID (not the space key)
CONFLUENCE_PARENT_ID Variable The page that files in the root of docs/ go under

Step 3: Add the Sync Script

Save this as scripts/sync-to-confluence.sh in your repo. It works out which Markdown files under docs/ changed in the push, picks a title and a parent page for each, and posts them one at a time.

#!/usr/bin/env bash
# scripts/sync-to-confluence.sh
# Sends changed Markdown files under docs/ to Confluence via the Markdown Importer REST API.
set -euo pipefail

: "${CONFLUENCE_API_ENDPOINT:?missing}"
: "${CONFLUENCE_API_TOKEN:?missing}"
: "${CONFLUENCE_SPACE_ID:?missing}"
: "${CONFLUENCE_PARENT_ID:?missing}"

# One Confluence parent page per folder. Create these pages once, then add their IDs here.
declare -A PARENTS=(
  ["docs"]="$CONFLUENCE_PARENT_ID"
  # ["docs/how-to"]="123456790"
  # ["docs/reference"]="123456791"
)

# Files changed in this push. Fall back to every file on the first run
# or when the previous commit isn't available.
BEFORE="${BEFORE_SHA:-}"
if [[ -n "$BEFORE" && ! "$BEFORE" =~ ^0+$ ]] && git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
  mapfile -t FILES < <(git diff --name-only --diff-filter=ACMR "$BEFORE" HEAD -- 'docs/*.md')
else
  mapfile -t FILES < <(git ls-files -- 'docs/*.md')
fi

if [[ ${#FILES[@]} -eq 0 ]]; then
  echo "No Markdown changes under docs/."
  exit 0
fi

# Page title: the first H1 in the file, or the filename if there isn't one.
title_for() {
  local h1
  h1=$(grep -m1 -E '^# ' "$1" | sed -E 's/^# +//; s/[[:space:]]+$//' || true)
  if [[ -n "$h1" ]]; then
    echo "$h1"
  else
    basename "$1" .md | sed -E 's/[-_]+/ /g'
  fi
}

failures=0
for file in "${FILES[@]}"; do
  [[ -s "$file" ]] || { echo "skip  $file (empty)"; continue; }

  dir=$(dirname "$file")
  parent="${PARENTS[$dir]:-}"
  if [[ -z "$parent" ]]; then
    echo "skip  $file (no parent page mapped for $dir)"
    continue
  fi

  title=$(title_for "$file")

  status=$(
    jq -n \
      --arg spaceId "$CONFLUENCE_SPACE_ID" \
      --arg parentId "$parent" \
      --arg pageTitle "$title" \
      --rawfile content "$file" \
      '{spaceId: $spaceId, parentId: $parentId, pageTitle: $pageTitle, content: $content, overwrite: true}' |
    curl -sS -o response.json -w '%{http_code}' -X POST "$CONFLUENCE_API_ENDPOINT" \
      -H "Authorization: Bearer $CONFLUENCE_API_TOKEN" \
      -H "Content-Type: application/json" \
      --data-binary @-
  ) || status="000"

  case "$status" in
    201) echo "created  $file -> \"$title\"" ;;
    200) echo "updated  $file -> \"$title\"" ;;
    *)   echo "FAILED   $file ($status): $(cat response.json 2>/dev/null)"; failures=$((failures + 1)) ;;
  esac

  sleep 1  # stay well clear of rate limits
done

rm -f response.json
[[ $failures -eq 0 ]] || { echo "$failures file(s) failed"; exit 1; }

A few notes on the choices in the script:

  • jq --rawfile reads the file and escapes it as a JSON string, so quotes, backslashes and code blocks survive intact. The payload is piped to curl rather than passed as an argument, which avoids shell length limits on longer pages.
  • overwrite: true makes the script safe to re-run. The first request for a file creates the page, and later ones update it.
  • The title rule is simple: the first line starting with # . If a file has no H1, the filename is used with hyphens and underscores turned into spaces. The check doesn't understand code blocks, so a file with no H1 but a shell comment like # install inside a fence would pick up that comment. Giving every doc an H1 avoids this.
  • A file in a folder with no mapped parent is skipped with a message instead of landing in the wrong place.

Step 4: Add the GitHub Actions Workflow

Create .github/workflows/sync-docs-to-confluence.yml:

name: Sync docs to Confluence

on:
  push:
    branches: [main]
    paths:
      - "docs/**/*.md"
  workflow_dispatch:

concurrency:
  group: confluence-docs-sync
  cancel-in-progress: false

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # full history so the diff against the previous push works

      - name: Sync changed pages
        env:
          CONFLUENCE_API_ENDPOINT: ${{ secrets.CONFLUENCE_API_ENDPOINT }}
          CONFLUENCE_API_TOKEN: ${{ secrets.CONFLUENCE_API_TOKEN }}
          CONFLUENCE_SPACE_ID: ${{ vars.CONFLUENCE_SPACE_ID }}
          CONFLUENCE_PARENT_ID: ${{ vars.CONFLUENCE_PARENT_ID }}
          BEFORE_SHA: ${{ github.event.before }}
        run: bash scripts/sync-to-confluence.sh

jq, curl and git are already installed on GitHub's Ubuntu runners. The paths filter means the workflow only runs when Markdown under docs/ changes. The concurrency group stops two pushes from syncing at the same time. A manual run from the Actions tab (workflow_dispatch) has no previous commit to compare against, so the script sends every file, which is handy for a full resync.


Folders and Page Hierarchy

The API creates one page per request under the parentId you give it. It does not build a tree from folder paths. To mirror a structure like this:

docs/
├── getting-started.md
├── installation.md
├── how-to/
│   ├── first-steps.md
│   └── advanced-usage.md
└── reference/
    ├── api.md
    └── config.md

you need one parent page per folder: one for the root, one for "How To" and one for "Reference". Create those pages once, put their IDs in the PARENTS map, and the script routes each file to the right place.

A practical way to get there is to do the first import through the app's UI. Zip the docs/ folder and upload it, and the importer turns folders into a page tree for you (the bulk import guide walks through it). Then copy the folder page IDs into the map. Because overwrite matches on title within the space, check that the titles the UI created match what the script will send (the first H1 or the filename). Where they differ, adjust one or the other, or the API will try to create a second page.


Images and Attachments

The REST API sends page content only. Markdown image syntax works when the image is at a publicly reachable URL, but local images such as ./assets/diagram.png are not uploaded.

You have two options:

  • Link images by absolute URL if they're already hosted somewhere public.
  • Upload attachments separately with Confluence's own REST API (the attachments endpoint on the page), authenticated with an Atlassian account API token, after the page has been created. This is a separate step from the importer, and you need the Confluence page ID for each page.

If your docs are image-heavy and change rarely, re-uploading the folder as a ZIP through the app's UI is often simpler. The UI import uploads local images as attachments automatically.


Things the Sync Doesn't Do

Be clear about these before you rely on the workflow:

  • Deletes and renames. Deleting a file in Git doesn't remove the Confluence page. Changing a file's H1 sends a new title, so a new page is created and the old one stays. Clean these up by hand.
  • Edits made in Confluence. The next sync overwrites the page with the Markdown from the repo. Many teams add a line at the top of each doc ("Edit this page in GitHub") so readers know where changes belong.
  • Links between files. Relative links such as [Config](../reference/config.md) are not guaranteed to resolve to the matching Confluence page. Check a few after the first sync and switch to absolute links where needed.

Testing the Workflow

  1. Run the script locally first with the four environment variables set and BEFORE_SHA unset. It will send every file under docs/, so point it at a sandbox space.
  2. Push a small change to one doc on main and open the run in the Actions tab. You should see a single updated line for that file.
  3. Open the page in Confluence and check the change, including code blocks and tables.

Other Patterns

Several doc sources. Run the script twice with different values, for example docs/public/ into a customer-facing space and docs/internal/ into an internal one. Change the docs/*.md pathspec and the PARENTS map for each run.

GitLab or other CI. The script only needs bash, git, curl and jq, so it runs the same way in GitLab CI. Pass the previous commit (CI_COMMIT_BEFORE_SHA in GitLab) as BEFORE_SHA. The migration guide has a GitLab pipeline example.

Staging preview. Point a second workflow at a staging space on pull requests so reviewers can see how pages render before merge. Keep secrets out of workflows that run on pull requests from forks.


FAQ

Do I need an Atlassian account API token? Not for the import itself. The Markdown Importer API uses its own Bearer token, created in the app's API tab. You only need an Atlassian account token if you also upload attachments with Confluence's REST API.

Can one request import a whole folder or a ZIP? No. Each request creates or updates one page from one Markdown string. The script loops over files for that reason. ZIP uploads are a feature of the app's UI, not the API.

How long does a token last? Between 1 and 30 days, depending on what you choose when you create it. Set a reminder to create a new token and update the GitHub secret before it expires.

What happens if I run the workflow twice? With overwrite: true, the second run updates the same pages again. Without it, the API returns a 400 "already exists" error for pages that are already there.

Does this work with GitLab or Bitbucket? Yes. The API is plain HTTPS and JSON, so any CI system that can run curl can call it.


Result

Once the workflow is running, the documentation process becomes:

  1. Edit Markdown in the repo
  2. Open a pull request, get it reviewed, merge
  3. The workflow updates the matching Confluence pages a few minutes later

The engineers who change the code update the docs in the same commit, and Confluence follows along. For more on the API itself, see the REST API announcement and the API reference.

Get Markdown Importer for Confluence on the Atlassian Marketplace

Script Toolkit for Jira

Scripted fields, workflow rules, scheduled jobs, web triggers, panels and gadgets in JavaScript

Use caseTechnical documentationMarkdown, docs from Git, code, math and clean PDFs in ConfluenceSee the full setup

Related articles

All posts