Git Deployment

Overview

Accent CMS can fetch content from a remote git repository and serve pages directly from git tree objects. Push to your deploy branch, and the server updates automatically.

Git deployment is built into the Standard and Pro binaries (the git-aware Cargo feature); production serving requires a Standard or Pro license.

Prerequisites

  • Git (version 2.28+) must be installed and available in PATH. Accent uses the system git binary for fetch and push operations to ensure compatibility with all credential stores (macOS Keychain, Windows Credential Manager, SSH agents, etc.).

    # Verify git is available
    git --version
    
  • For HTTPS remotes: credentials must be configured in your system git credential store, or set the GIT_REMOTE_TOKEN environment variable.

  • For SSH remotes: an SSH key must be available via ssh-agent or GIT_SSH_COMMAND.

Configuration

Add a git section to your config.yaml:

git:
  enabled: true
  remote: "https://github.com/customer/my-site.git"
  deploy_branch: accent-pages
  allow_fs_fallback: false   # non-checkout content dir is a hard error (default)
  bootstrap:
    enabled: true            # clone on boot if the checkout is absent
    on_corrupt: error        # error | reclone -- if the dir exists but is invalid
  sync:
    poll_interval: 5     # minutes (0 = disabled)
    on_diverge: reset    # reset | reject

The remote field accepts a plain URL string or a struct with additional options:

# Simple form (most common)
remote: "https://github.com/customer/my-site.git"

# Struct form (when SSH key path is needed)
remote:
  url: "git@github.com:customer/my-site.git"
  ssh_key: /etc/accent/deploy_key

If remote is omitted, Accent falls back to the repository’s configured “origin” remote. In that mode accent git fetch and accent git push invoke system git by the remote name (not URL), so any [url].insteadOf rewrites and [remote "origin"].fetch refspecs configured in .git/config continue to apply.

SettingDefaultDescription
enabledfalseEnable git-based content deployment
remote.url(empty – uses repo origin)Remote repository URL (HTTPS or SSH). When omitted, Accent uses the repo’s configured origin remote.
remote.ssh_key(none)Path to SSH deploy key (optional)
deploy_branchaccent-pagesBranch to track for deployment
checkout_dir(none – uses content.directory)Repository root to clone and manage, when content lives in a sub-directory of a larger repo (monorepo). Defaults to content.directory (content-only repo). Must be an ancestor of (or equal to) content.directory. See Mixed content + code repositories.
allow_fs_fallbackfalseWhen false, a non-checkout content directory is a hard startup error. Set true to restore legacy warn-and-serve-baked-files behaviour.
bootstrap.enabledtrueClone the deploy branch on boot when the checkout is absent (publisher/author roles only)
bootstrap.on_corrupterrorWhat to do if the content dir exists but is invalid: error or reclone (delete + re-clone)
sync.poll_interval0Polling interval in minutes (0 = disabled)
sync.on_divergeresetHow to handle force-pushes: reset (discard local) or reject (keep local)

Authentication

HTTPS with Token

Set the GIT_REMOTE_TOKEN environment variable:

export GIT_REMOTE_TOKEN=ghp_your_github_token
accent git fetch

The token is injected into the git credential helper automatically. Works with GitHub, GitLab, Codeberg, and Gitea personal access tokens.

SSH with Deploy Key

Set the GIT_SSH_COMMAND environment variable:

export GIT_SSH_COMMAND="ssh -i /path/to/deploy_key -o StrictHostKeyChecking=no"
accent git fetch

Or configure the remote URL with SSH:

git:
  remote:
    url: "git@github.com:customer/my-site.git"

CLI Commands

accent git fetch

Fetch updates from the remote repository:

# Basic fetch
accent git fetch

# With verbose output
accent git fetch --verbose

# With custom config
accent git fetch --config /path/to/config.yaml

Output:

Branch 'accent-pages' updated: abc12345 -> def67890

Or if already up to date:

Already up to date.

accent git status

Show the current sync state:

accent git status

Output:

Git sync status:
  Deploy branch: accent-pages
  Local ref:     abc1234567ab
  Remote URL:    https://github.com/customer/my-site.git

If git.remote is omitted and Accent is reading the URL from the repo’s origin remote, the line is annotated to make the source visible:

  Remote URL:    https://github.com/customer/my-site.git  (from repo origin)

If the branch has not been fetched yet:

Git sync status:
  Deploy branch: accent-pages (not found locally)
  Run 'accent git fetch' to fetch the branch.
  Remote URL:    https://github.com/customer/my-site.git

Status also distinguishes “content directory does not exist yet”, “directory exists but is not a git repository”, and “git repository open failed” so a corrupt .git/ is not mistaken for a fresh deploy:

Git sync status:
  Deploy branch: accent-pages (content dir is not a git repository)
  Content dir:   /var/lib/accent/content
  Run 'accent git fetch' to populate it.
  Remote URL:    (not configured -- set git.remote in config.yaml or add an origin to .git/config)

accent git push

Push local commits (e.g., from MCP mutations) to the remote:

accent git push --config config.yaml

Output:

Push successful.

Or if nothing to push:

Nothing to push (already up to date).

Note: Push is blocked in publisher mode (server.role: publisher). Only the author role can push local commits back to the remote.

accent git clone

Bootstrap the content directory from the remote without starting the server. Clones the deploy branch into the content directory when it is absent or empty; reports an already-present checkout untouched:

# Bootstrap from config (remote.url, deploy_branch, content.directory)
accent git clone --config config.yaml

# Clone a branch other than the configured deploy branch
accent git clone --branch preview/redesign

# Recovery / clean slate: delete and re-clone from scratch
accent git clone --force

accent git clone is allowed in every role – it never pushes, so the publisher write guard does not block it. Unlike the other accent git subcommands it also runs regardless of git.enabled, so you can use it to bootstrap a fresh deployment before flipping git deployment on (it still needs remote.url configured). It exits non-zero with an actionable message on auth/network failure, so an entrypoint using set -e halts rather than booting into a degraded state.

This is the deterministic, scriptable form of the clone-on-boot bootstrap described under Bootstrap and the two-speed deploy model. --force is the real “nuclear option” recovery path: a few seconds of downtime for a guaranteed-clean checkout.

Run accent git clone as the serving user on a non-root container deploy. The checkout ends up owned by whoever runs the command. If you run it through a root shell (for example flyctl ssh console, which is root) while the server runs unprivileged, the resulting checkout is root-owned: the server can neither read its remote (git refuses a repository owned by another user – “dubious ownership”, which surfaces as a misleading no origin remote) nor delete it to re-clone. Run it as the same user the server runs as – e.g. flyctl ssh console -u accent ..., docker exec -u <uid> ..., or su <user> -c 'accent git clone --force'.

Bootstrap and the two-speed deploy model

A git-deploy publisher is a self-bootstrapping, read-only mirror of the remote. Deploy the image once; on first boot Accent clones the deploy branch into the content directory. On subsequent boots the existing checkout is left in place (no clone); the running server’s fetch loop (webhook or polling) fast-forwards it after startup. No entrypoint scripting, no baked content.

The image bakes code, not content

The container image contains the accent binary, the theme, plugins, and config.yaml – no content files. Content lives only in the git remote. The content directory is an empty volume mount on first boot.

# Binary + theme + plugins + config, no content/
FROM debian:stable-slim
RUN apt-get update && apt-get install -y git ca-certificates   # system git for fetch/clone/push
COPY accent /usr/local/bin/accent
COPY themes/ /app/themes/
COPY plugins/ /app/plugins/
COPY config.yaml /app/config.yaml
# content.directory (/data/content) is an empty volume mount at boot
CMD ["accent", "serve", "--production", "--config", "/app/config.yaml"]
# /app/config.yaml
content:
  directory: /data/content        # empty volume on first boot
server:
  role: publisher
git:
  enabled: true
  remote: { url: "https://github.com/customer/my-site.git" }
  deploy_branch: accent-pages
  bootstrap: { enabled: true }
  webhook: { enabled: true, secret: "${GIT_WEBHOOK_SECRET}" }

Deploy once, then publish by push

  1. Deploy the image (any container host) with an empty /data volume.
  2. First boot: Accent finds /data/content empty, runs git clone --branch accent-pages --single-branch <remote> /data/content, then serves from the git tree.
  3. Thereafter: an author runs git push to accent-pages; the forge webhook hits /_git/webhook; Accent fast-forwards the existing checkout and hot-reloads. Content ships in seconds, no redeploy.
  4. Theme/brand/plugin/binary changes ship by rebuilding and redeploying the image – the slow lane. Content is the fast lane.

Startup behaviour

On accent serve startup, when git.enabled and bootstrap.enabled:

Checkout stateAction
Absent / empty dirClone the deploy branch into the content directory
Valid checkout of the configured remoteLeft in place – no clone; the running fetch loop (webhook/polling) fast-forwards it after startup
Exists but is not a valid checkout of the remoteHard error, or re-clone when on_corrupt: reclone

On-boot bootstrap runs only for the publisher and author roles. The developer role keeps real working trees, so it bootstraps on demand with accent git clone instead of automatically on startup. accent build runs the same bootstrap before generating static output, so a build on a fresh checkout pulls content first instead of emitting an empty site.

Bootstrap is idempotent across restarts: a present, valid checkout is never re-cloned during bootstrap. Fast-forwarding to the latest commit happens in the running server’s fetch loop (webhook or polling), not as part of bootstrap.

Fail loud, not silent

In production (accent serve --production), git.enabled: true with a content directory that is not a git checkout is a hard startup error – the server refuses to boot rather than silently serving whatever plain files happen to be in the directory (which would never receive webhook updates). The error names the directory, remote, deploy branch, and the accent git clone remedy.

In dev mode (accent serve without --production), the on-boot bootstrap is skipped – developers keep real working trees – so the same non-checkout directory only warns and falls back to filesystem reads, keeping the local git-aware workflow usable. Run accent git clone to bootstrap it when you want the production behaviour locally.

If you genuinely want to serve baked files alongside an enabled git block in production, set git.allow_fs_fallback: true to restore the legacy warn-and-fall-back-to-filesystem behaviour.

Recovery / clean slate

accent git clone --force (or bootstrap.on_corrupt: reclone) deletes and re-clones the checkout – a few seconds of downtime for a guaranteed-clean state.

Ownership matters on a non-root deploy. Both recovery paths must run as the user the server runs as. A checkout created or re-cloned by a different user (the classic case: accent git clone --force over a root SSH console while the server runs unprivileged) is owned by that user, and:

  • the server cannot read its remote, so startup fails with a message naming the ownership mismatch and the serving uid; and
  • on_corrupt: reclone cannot delete the foreign-owned tree, so it fails with Permission denied that likewise names ownership as the cause.

Run the clone as the serving user (flyctl ssh console -u <user>, docker exec -u <uid>, su <user> -c ...). If a checkout is already wrong-owned, fix it from a privileged shell with chown -R <serving-user> <checkout-dir>, or recreate the volume.

Mixed content + code repositories

By default the bootstrap clones the deploy branch directly into the content directory, so that branch is content-only (the content/, themes/, plugins/, config.yaml layout this guide documents).

For a monorepo whose content lives in a sub-directory alongside theme, brand assets, or a plugin crate, set git.checkout_dir to the repository root and point content.directory at the sub-directory within it:

git:
  enabled: true
  remote: "https://github.com/customer/site.git"
  deploy_branch: main
  checkout_dir: /srv/site            # repo root -- cloned and managed here
content:
  directory: /srv/site/web/content   # served prefix within the repo

The bootstrap clones and verifies the repository at checkout_dir; the serving layer scopes content reads to the content.directory prefix within that tree. checkout_dir must be an ancestor of (or equal to) content.directory. When omitted, the two coincide (content-only repo) and behaviour is unchanged.

A content-only deploy branch (built by CI from a mixed repo) remains a valid alternative when you prefer not to clone the whole monorepo.

Webhook Integration

Configure a webhook on your git forge to trigger automatic fetches when the deploy branch is pushed:

git:
  webhook:
    enabled: true
    secret: "${GIT_WEBHOOK_SECRET}"
    path: /_git/webhook

Set GIT_WEBHOOK_SECRET to the same secret configured in your forge’s webhook settings.

The ${VAR} form is a general config feature: any value in config.yaml may reference an environment variable as ${NAME} (or ${NAME:-default} to supply a fallback), and it is expanded when the config is loaded. A value that is exactly one placeholder takes the type the environment supplies, so numeric and boolean fields can be environment-driven too: port: "${SMTP_PORT}" with SMTP_PORT=587 loads as the number 587, as if it were written literally. This keeps secrets – webhook keys, SMTP credentials, CSRF tokens – out of the committed file. If the referenced variable is not set, the placeholder is left untouched; for the webhook secret specifically the endpoint then refuses to start rather than run with the literal ${GIT_WEBHOOK_SECRET} string as its key, so a missing variable fails loudly instead of silently weakening the signature check.

Forge Setup

ForgeWebhook URLSignature Header
GitHubhttps://your-server/_git/webhookX-Hub-Signature-256 (HMAC-SHA256)
GitLabhttps://your-server/_git/webhookX-Gitlab-Token (plain secret)
Gitea/Codeberghttps://your-server/_git/webhookX-Gitea-Signature (HMAC-SHA256)

Set the content type to application/json and select “Push events” only.

The webhook handler:

  1. Validates the signature against the configured secret
  2. Extracts the branch from the payload’s ref field
  3. Ignores pushes to branches other than the deploy branch
  4. Triggers a background fetch (returns 200 immediately)

Note: The webhook endpoint is not registered when server.role: author. The author role is the writer (push), not the receiver (webhook). Use webhooks on publisher instances only.

Divergence Handling

When the remote branch has been force-pushed and local state diverges:

PolicyBehaviorUse Case
resetReset local to remote HEADPublisher role (remote is truth)
rejectKeep local state, log errorAuthor role (protect local commits)

Recommended defaults by role:

  • Publisher: on_diverge: reset (remote is always truth)
  • Author: on_diverge: reject (local commits must not be discarded)

A startup warning is logged if the divergence policy does not match the role’s recommended default.

Local Branch Preview

Reviewers can preview unmerged content branches before they land on the deploy branch. The branch must already be fetched locally; preview does not pull from the remote on demand (that would be slow and would let outsiders enumerate branches).

Configuration

git:
  preview:
    enabled: true
    branch_pattern: "preview/*"     # only branches matching this glob
    require_token: true              # gate behind a shared secret in production
    token: "${GIT_PREVIEW_TOKEN}"    # env-substituted shared secret
SettingDefaultDescription
enabledfalseMaster switch for ?preview=...
branch_patternpreview/*Glob: exact name, prefix*, or *
require_tokenfalseProduction: require ?preview_token=<value>
token(empty)Shared secret compared against ?preview_token

In dev mode (no --production flag) the token check is skipped so authors can iterate without a secret in their browser.

Workflow

  1. Author pushes preview/redesign to the git forge.

  2. Operator on the server fetches the branch:

    accent git fetch --branch preview/redesign
    
  3. Reviewer opens a normal page URL with ?preview=<branch>:

    https://site.example/blog/post?preview=preview/redesign
    # or, in production with require_token: true
    https://site.example/blog/post?preview=preview/redesign&preview_token=<value>
    
  4. Accent serves the page from preview/redesign’s tree and injects a yellow banner at the bottom showing the branch name and the short commit OID.

  5. After review, the author merges preview/redesign into the deploy branch and the normal webhook / poll path picks up the change.

Limitations

  • Content only. Templates, theme assets, and config.yaml come from the running server, not the preview branch. Template changes need a separate dev server (or a Fly.io machine running off the preview branch).
  • Navigation reflects the deploy branch. The page body is read from the preview branch, but the content index used by templates – {{ pages }}, breadcrumbs, sidebar listings, related links, and taxonomy pages – is built from the deploy branch. A page added in the preview branch renders its own content but will not appear in nav until the branch is merged.
  • No on-demand fetching. accent git fetch --branch <name> must run before any preview request – otherwise the request returns 404 with “branch not locally available”.
  • Bypasses the page cache. Preview requests are uncached; do not load-test against ?preview=....
  • Working-copy edits are not visible during preview. When you run with git.enabled: true, page reads come from the committed git tree, not the working copy. With dev.hot_reload: true the watcher still fires on .md saves and invalidates the cache, but the reload reads the same committed tree – so unsaved (uncommitted) edits silently disappear. Commit on the preview branch to see changes, or run accent serve with git.enabled: false for a filesystem-backed dev loop.

Edition Requirements

ComponentEdition
git: config sectionStandard+
accent git fetchStandard+
accent git fetch --branch <name>Standard+
accent git statusStandard+
accent git pushStandard+
?preview=<branch> (local branch preview)Standard+
Webhook endpoint (/_git/webhook)Standard+