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 systemgitbinary 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_TOKENenvironment 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.
| Setting | Default | Description |
|---|---|---|
enabled | false | Enable 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_branch | accent-pages | Branch 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_fallback | false | When false, a non-checkout content directory is a hard startup error. Set true to restore legacy warn-and-serve-baked-files behaviour. |
bootstrap.enabled | true | Clone the deploy branch on boot when the checkout is absent (publisher/author roles only) |
bootstrap.on_corrupt | error | What to do if the content dir exists but is invalid: error or reclone (delete + re-clone) |
sync.poll_interval | 0 | Polling interval in minutes (0 = disabled) |
sync.on_diverge | reset | How 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 cloneas 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 exampleflyctl 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> ..., orsu <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
- Deploy the image (any container host) with an empty
/datavolume. - First boot: Accent finds
/data/contentempty, runsgit clone --branch accent-pages --single-branch <remote> /data/content, then serves from the git tree. - Thereafter: an author runs
git pushtoaccent-pages; the forge webhook hits/_git/webhook; Accent fast-forwards the existing checkout and hot-reloads. Content ships in seconds, no redeploy. - 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 state | Action |
|---|---|
| Absent / empty dir | Clone the deploy branch into the content directory |
| Valid checkout of the configured remote | Left in place – no clone; the running fetch loop (webhook/polling) fast-forwards it after startup |
| Exists but is not a valid checkout of the remote | Hard 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: reclonecannot delete the foreign-owned tree, so it fails withPermission deniedthat 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
| Forge | Webhook URL | Signature Header |
|---|---|---|
| GitHub | https://your-server/_git/webhook | X-Hub-Signature-256 (HMAC-SHA256) |
| GitLab | https://your-server/_git/webhook | X-Gitlab-Token (plain secret) |
| Gitea/Codeberg | https://your-server/_git/webhook | X-Gitea-Signature (HMAC-SHA256) |
Set the content type to application/json and select “Push events” only.
The webhook handler:
- Validates the signature against the configured secret
- Extracts the branch from the payload’s
reffield - Ignores pushes to branches other than the deploy branch
- 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:
| Policy | Behavior | Use Case |
|---|---|---|
reset | Reset local to remote HEAD | Publisher role (remote is truth) |
reject | Keep local state, log error | Author 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
| Setting | Default | Description |
|---|---|---|
enabled | false | Master switch for ?preview=... |
branch_pattern | preview/* | Glob: exact name, prefix*, or * |
require_token | false | Production: 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
-
Author pushes
preview/redesignto the git forge. -
Operator on the server fetches the branch:
accent git fetch --branch preview/redesign
-
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> -
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. -
After review, the author merges
preview/redesigninto the deploy branch and the normal webhook / poll path picks up the change.
Limitations
- Content only. Templates, theme assets, and
config.yamlcome 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 returns404with “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. Withdev.hot_reload: truethe watcher still fires on.mdsaves 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 runaccent servewithgit.enabled: falsefor a filesystem-backed dev loop.
Edition Requirements
| Component | Edition |
|---|---|
git: config section | Standard+ |
accent git fetch | Standard+ |
accent git fetch --branch <name> | Standard+ |
accent git status | Standard+ |
accent git push | Standard+ |
?preview=<branch> (local branch preview) | Standard+ |
Webhook endpoint (/_git/webhook) | Standard+ |