Keep the server in sync

self-hostingsync

The server is a reader. It clones your repository once, then pulls it — it never commits, never pushes, and never writes anything back to the remote. Everything on this page is about getting new commits into the server.

Three ways changes arrive

WayWhen it runsTurn it off with
Startup syncOnce, as the server finishes bootingDOCUCOMMIT_SYNC_ON_STARTUP=false
Scheduled pullEvery DOCUCOMMIT_SYNC_INTERVAL, default 5m, measured from the end of the previous pullDOCUCOMMIT_SYNC_SCHEDULED=false
WebhookOn demand, when your Git host or CI posts to /webhook/sync/{repoId}Clear DOCUCOMMIT_WEBHOOK_SECRET

All three run the same code path, so they behave identically — the webhook is only about latency. Each variable is described in the configuration reference.

Leave the scheduled pull on even when you use the webhook. It is the safety net for a webhook that never arrived, and five minutes is the worst case rather than the normal case.

What a sync actually does

For each configured repository, in its own directory under <workdir>/<id>:

  1. If there is no checkout yet, initialise one, fetch, and check out the tracked branch. If no branch is configured, the remote’s default branch is used.
  2. Otherwise fetch +refs/heads/*:refs/remotes/origin/* (deleted remote branches are pruned) and fast-forward only onto the remote tip.

Every sync ends in one of four outcomes: CLONED, UPDATED, UP_TO_DATE, or FAILED. A repository is locked for the duration of its own sync, so a webhook firing during a scheduled pull waits rather than colliding with it.

After a sync that brings in new commits, the server rebuilds the rendered library and the search index, reconciles comments and notification subscriptions against the new file set, and queues change notifications for followers. UP_TO_DATE does no work beyond the fetch.

There is no merge and no rebase. The server has no local commits of its own, so a fast-forward is always enough — until the remote history changes underneath it, which is the one failure worth planning for.

The webhook

POST /webhook/sync/{repoId}
X-DocuCommit-Token: <DOCUCOMMIT_WEBHOOK_SECRET>

{repoId} is the repository’s configured id — the same value as DOCUCOMMIT_SYNC_REPOS_0_ID. The body is ignored; the header is the whole authentication story, and the secret is compared in constant time.

ResponseMeaning
202 {"status":"accepted","repoId":"docs"}Queued. The pull happens after the debounce window.
401 {"status":"unauthorized"}Wrong token, missing header, or DOCUCOMMIT_WEBHOOK_SECRET is blank.
404 {"status":"unknown_repo","repoId":"…"}No repository with that id is configured and licensed.

Because a blank secret returns 401 for everything, the endpoint is safe to expose before you have configured it. It is also exempt from the licence gate, so it answers even on a server whose licence key is not yet valid.

This is not a stock GitHub webhook. GitHub signs the body with an HMAC; DocuCommit wants the secret as a literal header value. Anything that can set a request header will do — a CI step, a Gerrit webhooks.config entry, or plain curl.

From any CI system

curl -fsS --retry 3 --retry-delay 2 -X POST \
  -H "X-DocuCommit-Token: $DOCUCOMMIT_WEBHOOK_SECRET" \
  https://docs.example.com/webhook/sync/docs

From GitHub Actions

Add this as .github/workflows/notify-docucommit.yml in the documentation repository, and store the secret as a repository secret named DOCUCOMMIT_WEBHOOK_SECRET:

name: Notify DocuCommit on push

on:
  push:
    branches: [main]

# No tokens or permissions needed — we don't touch the GitHub API.
permissions: {}

jobs:
  notify:
    runs-on: ubuntu-latest
    steps:
      - name: POST to DocuCommit webhook
        env:
          DOCUCOMMIT_WEBHOOK_SECRET: ${{ secrets.DOCUCOMMIT_WEBHOOK_SECRET }}
        run: |
          curl -fsS --retry 3 --retry-delay 2 -X POST \
            -H "X-DocuCommit-Token: $DOCUCOMMIT_WEBHOOK_SECRET" \
            -H "Content-Type: application/json" \
            https://docs.example.com/webhook/sync/docs

Change the branch, the hostname, and the docs at the end of the URL to your repository id. The job needs no GITHUB_TOKEN and no permissions, because it never calls the GitHub API.

This is the same workflow that keeps the public DocuCommit demo server current.

Debounce

DOCUCOMMIT_WEBHOOK_DEBOUNCE (default 3s) is a trailing debounce, kept per repository. Each call cancels the pending sync and schedules a new one three seconds out. A branch push of twelve commits, or a merge that fires several hook deliveries in a row, produces exactly one pull — three seconds after the last one lands, not after the first.

The trade-off is that a steady drip of pushes closer together than the debounce window keeps deferring the pull. Keep the window short; 3s is enough to collapse a burst and short enough that nothing waits noticeably.

When a sync fails

A failed sync is logged and nothing else changes: the previously rendered library stays up and keeps serving. The server does not go down because a pull failed.

Logged messageCause
auth/network failure — …The fetch could not authenticate or could not reach the remote. Bad token, wrong SSH key, missing known_hosts entry, DNS or firewall.
remote branch not found: origin/<branch>The configured branch was renamed or deleted on the remote.
local directory exists but is not a Git repository: …Something else is occupying <workdir>/<id>.
non-fast-forward update for branch '<branch>' (upstream rebased or force-pushed); manual intervention requiredThe remote history was rewritten.

A rewritten upstream

The server only ever fast-forwards. If someone force-pushes, rebases, or amends a commit that the server has already fetched, the remote tip is no longer a descendant of the local tip and the merge is refused. Every subsequent sync fails the same way until an operator acts. There is no automatic reset — deliberately, because silently discarding a checkout is not something the server should decide on its own.

Two supported fixes:

Restore the history on the remote. If the force-push was a mistake, push the original commits back. The next sync fast-forwards as usual and nothing else is needed.

Delete the checkout and let it re-clone. The directory under the working directory holds nothing you cannot recreate — it is a clone of your remote, and comments, accounts, and subscriptions live elsewhere in the volume. Remove it and restart; the startup sync clones fresh.

The server image is distroless and has no shell, so docker compose exec is not available. Reach the volume with a throwaway container instead:

docker compose stop docucommit-server
docker run --rm -v docucommit-repos:/var/repos alpine rm -rf /var/repos/docs
docker compose start docucommit-server

docucommit-repos is the volume name from the compose file and docs is the repository id. Nothing else in /var/repos should be touched — .auth/, .comments/, and .docucommit-license/ are the server state described in Backup and restore.

Watching sync state

There is no sync-status page and no status API. Monitoring is the container logs.

docker compose logs -f docucommit-server

A successful sync logs one line per repository:

sync docs: UPDATED (9f2c1ab…)
sync docs: UP_TO_DATE (9f2c1ab…)

A failure logs at error level:

sync docs FAILED: auth/network failure — TransportException: …

Grep for sync and alert on FAILED in whatever log pipeline you already run. The commit id in the success line is the exact commit the site is serving, which is the quickest way to confirm that a push actually landed.