Sync local markdown files to Atlassian Confluence Cloud pages.
- Directory -> page hierarchy - your
docs/tree maps 1:1 to a Confluence page tree (each dir is a page; nested dirs are child pages). - Full CommonMark + GFM via goldmark: headings, emphasis, strikethrough, inline/fenced code, lists (nested), task lists, tables, links, autolinks, blockquotes, horizontal rules, hard/soft breaks.
- Images - remote URLs embed directly; local images upload as page attachments automatically.
- Confluence code blocks - fenced code renders as Confluence's native Code Snippet macro with syntax highlighting.
- Incremental sync - pages whose rendered body matches what's already on Confluence are skipped, so re-runs only touch what changed.
- Dry-run - preview the page tree resolved from your directory structure without hitting Confluence.
- Single file upload and page listing commands.
Grab the archive for your platform from the releases page,
extract it, and drop the binary somewhere on your $PATH:
tar -xzf md2confluence_<version>_Linux_x86_64.tar.gz
sudo mv md2confluence /usr/local/bin/
md2confluence --versionWindows builds ship as .zip; macOS / Linux as .tar.gz.
Requires Go 1.26+.
git clone <this-repo>
cd md2confluence
go build -o md2confluence ./cmd/md2confluenceThe example/ directory contains a ready-to-run config and a small
docs tree that exercises every supported markdown feature:
md2confluence sync --config ./example/config.yaml --dry-run--dry-run is offline, so you can preview the page tree before
supplying real credentials.
Config is loaded with viper. The default location is
~/.config/md2confluence/config.yaml; override with --config /path/to/file.yaml.
Environment variables take precedence over values in the config file and use the MD2CONFLUENCE_ prefix:
| Env var | Overrides |
|---|---|
MD2CONFLUENCE_EMAIL |
email |
MD2CONFLUENCE_API_TOKEN |
apiToken |
MD2CONFLUENCE_BASE_URL |
baseUrl |
The spaces list is only read from the config file - it is not settable via env vars.
Example config file:
email: "your-email@example.com"
apiToken: "ATATT3xFfGF0..."
baseUrl: "https://your-domain.atlassian.net/wiki"
spaces:
- key: "DOCS"
path: "./docs"
parentTitle: "Documentation Home"
autoCreateParent: true
- key: "ENG"
path: "./engineering"| Field | Required | Description |
|---|---|---|
email |
Yes | Your Atlassian account email |
apiToken |
Yes | API token from Atlassian Account |
baseUrl |
Yes | Confluence instance URL (e.g. https://your-domain.atlassian.net/wiki) |
spaces[].key |
Yes | Target space key (e.g. DOCS, ENG) |
spaces[].path |
Yes | Local directory to scan for .md files |
spaces[].parentTitle |
No | Parent page title under which pages are created |
spaces[].autoCreateParent |
No | Create parent page if it doesn't exist (default: false) |
spaces[].imageWidth |
No | Default ac:width (pixels) for images without an explicit size hint (default: 760) |
- Go to Atlassian Account -> Security
- Click Create API token
- Give it a label and copy the token - you won't see it again
md2confluence syncScans every directory listed in your config, converts each .md file to Confluence Storage Format, and creates or
updates pages accordingly. Pages are matched by space key + title (derived from filename).
See what would change without making any modifications:
md2confluence sync --dry-runmd2confluence upload ./docs/guide.md --space DOCSmd2confluence list --space DOCSShows each markdown file and whether it already exists on Confluence.
Every directory becomes a Confluence page. The page's body comes from:
{dirname}.mdinside the directory, if present, elseindex.md, else- empty body (placeholder page).
Every other .md / .markdown file in a directory (not the content file) becomes a child page of that directory's
page.
Example:
docs/
├── docs.md -> root page body ("Documentation Home")
├── installation/
│ ├── installation.md -> "Installation" page body
│ └── prerequisites.md -> child page under "Installation"
├── guides/
│ ├── index.md -> "Guides" page body (index.md fallback)
│ └── deploy/
│ ├── deploy.md -> "Deploy" page body (child of "Guides")
│ └── rollback.md -> child of "Deploy"
└── api-reference/ -> empty-body page (no matching md file)
└── endpoints.md -> child of "Api Reference"
Page titles come from the first # Heading in the content file (outside any code fence), falling back to the
title-cased dirname or filename.
Rendered by goldmark with GFM extensions.
| Markdown | Confluence output |
|---|---|
# Heading - ###### Heading |
<h1> - <h6> |
**bold**, *italic*, ~~strike~~ |
<strong>, <em>, <s> |
`inline` |
<code> |
```bash ``` fenced block |
Confluence Code Snippet macro (<ac:structured-macro ac:name="code">) with syntax highlighting |
- item / 1. item / nested |
<ul> / <ol>, nested correctly |
- [x] / - [ ] task |
[x] / [ ] prefix in list item |
[text](url) |
<a href="url">text</a> |
<https://…> autolink |
<a href="…">…</a> |
 |
Uploaded as attachment; referenced via <ac:image><ri:attachment/></ac:image> |
 |
Inlined via <ac:image><ri:url ri:value="…"/></ac:image> |
 |
Per-image size override (w/h also accepted as short keys) |
| GFM table | <table> with <th>/<td> cells |
> quote |
<blockquote> |
--- |
<hr/> |
Common aliases are mapped to Confluence's language names
| Doc | Confluence |
|---|---|
sh/shell/zsh |
bash |
js |
javascript |
py |
python |
yml |
yaml |
rb |
ruby |
c++ |
cpp |
c# |
csharp |
objective-c |
objectivec |
Anything else is passed through as-is.
--dry-run is offline - it prints the page tree that would be synced without touching Confluence:
$ md2confluence sync --dry-run
[DRY-RUN] Space DOCS:
- Documentation Home <- /abs/path/docs/docs.md
- Installation <- /abs/path/docs/installation/installation.md
- Prerequisites <- /abs/path/docs/installation/prerequisites.md
- Guides <- /abs/path/docs/guides/index.md$ md2confluence list --space DOCS
Pages in space DOCS:
- Documentation Home id=abc123 v5
- Installation id=def456 v2
- Prerequisites new- Verify your email and API token are correct. The token is not your Atlassian password.
- Generate a new token at Atlassian Account -> Security.
- Ensure the token has not been revoked.
- If you rename a markdown file or its
# Heading, md2confluence treats it as a new page (pages are matched by title). The old Confluence page remains until you delete it manually. - Use
md2confluence list --space <KEY>to see which local files map to existing pages.
- If
parentTitleis set but the parent doesn't exist, md2confluence prints a warning and creates pages at the space root (the sync does not fail). - Set
autoCreateParent: trueon the space to have md2confluence create an empty parent page on your behalf, or create it manually in Confluence first.
- Local image uploads run after the page is created/updated. The first render may briefly show broken images until the attachment request completes; reload the page.
- Images are looked up by basename - two different files with the same filename collide to one attachment. Rename one of them.
- Confluence Cloud's Fabric editor is strict: the code macro requires
ac:schema-version="1",ac:macro-id="{uuid}", hyphenatedac:plain-text-body, and CDATA-wrapped body content - we emit all of these. If you're still seeing an error, check your Confluence space permissions or report the generated storage XML.
Auth credentials are passed as env vars, so in CI you only need to commit a minimal config file with the spaces list -
secrets stay in the CI secret store.
A repo-side md2confluence.yaml only carries the spaces mapping (no secrets):
spaces:
- key: "DOCS"
path: "./docs"The image is published to ghcr.io/yousysadmin/md2confluence (multi-arch: linux/amd64 + linux/arm64, public).
| Tag | Source | When to use |
|---|---|---|
:vX.Y.Z, :latest |
tag push -> goreleaser (release.yml) |
Production. Reproducible. |
:master, :master-<short-sha> |
every push to master (docker.yml) |
Tracking unreleased changes. |
A full ready-to-copy workflow showing all three integration styles below lives at
example/github-actions/sync-docs.yml.
Use the action wrapper at the repo root - shortest invocation, no docker run boilerplate:
# .github/workflows/sync-docs.yml
name: Sync Docs to Confluence
on:
push:
branches: [ main ]
paths: [ "docs/**", "md2confluence.yaml" ]
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: yousysadmin/md2confluence@v1
with:
email: ${{ secrets.CONFLUENCE_EMAIL }}
api-token: ${{ secrets.CONFLUENCE_API_TOKEN }}
base-url: ${{ secrets.CONFLUENCE_BASE_URL }}
config: ./md2confluence.yaml
# Optional: --dry-run to preview without pushing.
# args: --dry-run
# Optional: pin a specific image (defaults to :latest).
# image: ghcr.io/yousysadmin/md2confluence:v1.2.3Action inputs: email, api-token, base-url (required), config (default ./md2confluence.yaml), command (
default sync; also upload / list), args, image.
Run every step in the job inside the image, with the repo mounted at /work. Useful when you want multiple
md2confluence steps in the same job:
jobs:
sync:
runs-on: ubuntu-latest
container:
image: ghcr.io/yousysadmin/md2confluence:latest
env:
MD2CONFLUENCE_EMAIL: ${{ secrets.CONFLUENCE_EMAIL }}
MD2CONFLUENCE_API_TOKEN: ${{ secrets.CONFLUENCE_API_TOKEN }}
MD2CONFLUENCE_BASE_URL: ${{ secrets.CONFLUENCE_BASE_URL }}
steps:
- uses: actions/checkout@v4
- run: md2confluence sync --config ./md2confluence.yamlUse when the surrounding job is not containerized (e.g. needs other tools that aren't in the distroless image):
- name: Sync to Confluence
env:
MD2CONFLUENCE_EMAIL: ${{ secrets.CONFLUENCE_EMAIL }}
MD2CONFLUENCE_API_TOKEN: ${{ secrets.CONFLUENCE_API_TOKEN }}
MD2CONFLUENCE_BASE_URL: ${{ secrets.CONFLUENCE_BASE_URL }}
run: |
docker run --rm \
-v "$PWD:/work" -w /work \
-e MD2CONFLUENCE_EMAIL -e MD2CONFLUENCE_API_TOKEN -e MD2CONFLUENCE_BASE_URL \
ghcr.io/yousysadmin/md2confluence:latest \
sync --config ./md2confluence.yamlDrop the binary onto the runner and call it directly - no Docker needed:
- name: Install md2confluence
run: |
VERSION=1.0.0
curl -sSL "https://github.com/yousysadmin/md2confluence/releases/download/v${VERSION}/md2confluence_${VERSION}_Linux_x86_64.tar.gz" \
| tar -xz md2confluence
install -m0755 md2confluence /usr/local/bin/
- name: Sync to Confluence
run: md2confluence sync --config ./md2confluence.yaml