Static Site Build
The accent build command generates a directory of static HTML, CSS, and assets that can be served by any web server or hosting provider. Static builds are free – no license key is required.
The accent build Command
accent build --output ./public --base-url https://example.com
Flags
| Flag | Description | Default |
|---|---|---|
--output / -o | Output directory for generated files | output |
--base-url | The URL the site is served at, used as site.url for the build | (none; falls back to site.url) |
--base-path | Explicit sub-path prefix for every internal URL (overrides config and any value derived from --base-url/site.url) | (none; derived or falls back to site.base_path) |
--config / -c | Path to config file | ./config.yaml |
--clean | Remove output directory before building | false |
--lax | Report broken internal links, and include-file tags that cannot be rendered, as warnings that do not fail the build | false (a broken link or an unrenderable include-file fails the build) |
The output directory must lie outside every directory the next build reads back: the content directory, each filesystem mount source, the media directory, the theme’s assets/ directory, and the .well-known directory when it is enabled. A site whose content.directory is ., which is what accent serve <dir> --write-config writes, needs an explicit --output outside the tree: the default output would sit inside it, and the build would copy its own output as page media on every run. An output inside one of the copied directories would be copied back on the next build, and one inside a mount source would be read back as content. The build refuses such a path before it writes or cleans anything, naming the directory and its config key. With --clean, the output directory may not be, contain, or lie inside any directory the build reads, the theme, models, data, and plugin directories included, since the clean would delete them or part of them.
The --base-url flag is important for production builds. It replaces site.url for the build, so every absolute URL uses it: the sitemap, robots.txt, the feeds, canonical links, og:url, absolute_url() and JSON-LD. Always set it to your production domain. Its path component – for example /repo in https://username.github.io/repo – is the site’s base path unless --base-path or site.base_path sets one, so every internal link and asset is prefixed to match. See Deploy to GitHub Pages below. accent serve-static reads the base path from config.yaml, not from the build, so to preview a build whose --base-url path differs from site.url’s, pass the same path to accent serve-static --base-path.
Names with spaces and other encoded characters
A page’s URL percent-encodes every character that is not a letter, a digit,
-, _ or ., so a directory named My Notes is the page /My%20Notes.
The build writes it where a static host looks for that URL, which decodes it
once: My Notes/index.html, with its markdown twin at My Notes.md and its
media under content-media/My Notes/. A version before 0.27 wrote such a
page under its encoded name (My%20Notes/); build once with --clean
after upgrading so those copies go, as the build reminds you while they
are there. A name the build machine cannot write
as a file name stays encoded, and the build logs it: one whose decoded form
holds /, \ or NUL, and on Windows also : * ? " < > |, a control
character, a trailing dot or space, or a device name such as CON. A
static host cannot serve such a page, so give it a plainer name. A
frontmatter url: that names another page’s file once decoded, such as
url: /My Notes on one page beside the page /My%20Notes, is a conflict
like two pages with one URL: the page keeps its own URL, and the log says
so.
Incremental builds
accent build writes a build manifest into the project, under .accent/build/ beside config.yaml, named after the output directory (for --output public, .accent/build/public-<hash>.json), and reads it on the next run, so a page whose inputs did not change is skipped. The manifest lists every page, drafts included, so it never goes into the output: a deploy that uploads the output directory publishes only the site. A manifest an older version left inside the output as .build-manifest.json is read once and then deleted; delete any copy an earlier deploy uploaded to your host, since a mirror that never removes remote files keeps serving it. The output carries only an .accent-build file holding a digest of the manifest, so a build trusts a manifest only for the output it describes: an output deleted, emptied, or restored from another build is built in full, and a page whose output file is missing is rendered again. --clean removes the manifest with the output. A project that cannot be written to, such as a read-only source mount, still builds; the next build is then a full one. Add .accent/ to .gitignore; to keep incremental builds in CI, cache .accent/build/ together with the output directory. A page is rebuilt when its own markdown, a partial it includes, a file an include-file tag on it shows, or its template (or any template that template extends or includes) changes, and when its status changed since the recorded build: a scheduled page is written on the day its publish_date passes, a page that reached its unpublish_date is written again as archived, and a page that is a draft again has its output removed. Six changes rebuild every page, and the log names the one that applied:
config.yamlchanged.theme.yaml, a template, a shortcode, or a partial changed.- The schema fingerprint changed. The fingerprint is one digest of the document models of the project, the theme, and every plugin; a plugin’s
plugin.wasmor itsplugins.configentry;theme.yaml; themedia,code,diagrams, andmarkdownsections ofconfig.yaml; the site URL and base path (so a build under a different--base-urlrebuilds every link); and the Accent build itself (its version and git commit). A model counts by what it declares – its fields, types, defaults, constraints, and rules – not by how its file is written, so adding a comment to a model file or reformatting it rebuilds nothing. - A fingerprinted asset changed, with
build.fingerprint.enabledon. Pages embed the fingerprinted URL of every asset they link, so a stylesheet edit changes the HTML of every page that links it, whether the page’s own inputs moved or not. - A page was added or removed, or changed what menus and listings show of it: its title or menu title, its order or menu visibility, its date, author, tags, model, redirect, or authored lead or
description, or its status on the build date. Pages show other pages in menus, listings, tag clouds, and prev/next links, and the manifest does not record which, so every page renders again. The output of a page that was removed goes too. Left out, so an edit to a page’s body still renders that page alone: a lead the build takes from a page’s body, the date its file last changed (whichsite.last_modifiedreads), and the backlinks a page’s wikilinks give other pages. A listing that shows them refreshes on the next full build. - The manifest was written by an older Accent whose format the current one no longer reads.
The manifest also records the redirect stubs the build wrote. A later build into the same directory rewrites them when their targets move, as they all do under another --base-path, and removes the ones it no longer emits, such as the .md stubs after you turn content.md_extension_redirects off. A stub the build finds byte for byte as it would write it is its own even without a record; one that differs and was never recorded is left alone as another pass’s output, so a directory built by a version that did not record its stubs needs one --clean build after a prefix change.
Every build logs the fingerprint at info level (Schema fingerprint: <hex>) and, on a full rebuild, the reason, for example Full rebuild: schema changed (models, theme.yaml, plugins, or render config). Pass --clean to ignore the manifest and build from nothing; --dry-run prints the plan without writing anything: the pages it would render, the passes it would skip, and what it would remove from the output, which is the output of pages that are gone or no longer published, redirect stubs no page needs any more, and the pages of a tag that is gone. The same fingerprint keys every derived artifact in accent serve – rendered pages, diagrams, and processed media variants – so a model edit is never served from a cache filled before it.
Parallel rendering
accent build renders pages on all your cores. Each page reads the same
immutable content index and writes its own files, so the work divides cleanly
and the render phase scales with the cores you give it. Building this
documentation site (644 pages) takes about 12s on one thread and 2.2s on 28.
The output does not depend on how many threads run. Pages are collected in URL order, so the generated tree, the page count, and the build log’s summary are identical whatever the machine does.
The log is a different matter: anything a page reports while rendering now appears in completion order, not page order, and that order changes between runs. This includes warnings you act on – an unresolved wikilink, an island directive error, a diagram that failed to render, a redirect whose target is not public. Every warning still appears; only their order moves, so sort the output if you parse it.
One consequence is worth knowing. Some warnings name the page they came from
and some do not, and until now you could infer the rest from position, because
the log followed page order. That no longer holds. If a warning does not name
its page, run the build with RAYON_NUM_THREADS=1 to get the ordered log back
while you track it down.
Set RAYON_NUM_THREADS to limit the pool, which is worth doing on a shared CI
runner where a build competing with other jobs is slower than a smaller one:
RAYON_NUM_THREADS=2 accent build --output ./public
Setting it to 1 renders serially.
If your site uses plugins that transform content, and a parallel build logs
hook warnings a serial one does not, raise plugins.render_budget_ms or cap
the pool. The per-call budget is wall-clock, so a busy machine can charge a
plugin for time it spent waiting rather than working.
When more than one page fails, the build reports them all together in URL order rather than stopping at the first. You see every broken page in one run, and the same commit fails the same way every time.
Output Directory Structure
A typical build produces the following:
public/
index.html # Home page
about/
index.html # About page
blog/
index.html # Blog listing (page 1)
page/
2/index.html # Blog listing (page 2)
my-first-post/
index.html # Blog post
docs/
index.html # Docs index
getting-started/
index.html
feed.xml # RSS feed
sitemap.xml # XML sitemap
robots.txt # Robots configuration
assets/ # Theme CSS, JS, and images
css/
style.css
js/
main.js
media/ # Pre-processed media files
blog/
my-first-post/
hero.webp
hero-thumb.webp
Every page becomes an index.html inside a directory matching its URL path. This produces clean URLs without file extensions on any web server.
Trailing slashes on static hosts
Accent’s canonical URL form has no trailing slash (/about, not /about/) — that is what every link, the <link rel="canonical"> tag, the sitemap, and feeds emit, and accent serve/serve-static redirect the slash form to it. Because the build output is directory-per-page (about/index.html), some static hosts apply their own trailing-slash policy on top, and several do the opposite of Accent’s default:
- GitHub Pages, Netlify (default), and Cloudflare Pages redirect
/about→/about/, so a visitor following a canonical link takes one host-side redirect to the slash form. - Vercel (with
cleanUrls: true, as in the config below) serves/aboutdirectly, matching Accent.
This is cosmetic — every link still resolves — but if you want the served form to match Accent’s canonical no-slash form exactly, configure the host to prefer no trailing slash (e.g. Netlify’s Pretty URLs off, or a Cloudflare Pages _redirects rule). Relative links between pages are rewritten to absolute clean URLs at build time, so they are unaffected by whichever form the host serves.
Checking links in the build output
Because the deployed site has no Accent runtime, a link that resolves under accent serve — through a version redirect, a .md rewrite, or the host’s own trailing-slash policy — can still 404 once the output is uploaded. Every build therefore ends by resolving each internal link in the emitted HTML, and in markdown-only output each .md page, against the output tree itself. A link whose target the build did not write fails the build, so a broken link stops a deployment instead of shipping a 404.
The error lists every broken link in one run. Each line names the source file to edit and, when Accent knows the target page, why the build did not write it:
Error: broken internal links in the build output: 2
content/docs/default.md -> /docs/integrations/grpc-api (draft: not published)
content/docs/default.md -> /docs/integrations/mcp-server (draft: not published)
Each link names a page the build did not write. Fix the links, or run with --lax to report them as warnings.
The reason is one of draft: not published, in review: not published, or scheduled: publishes <date>. A page built from no source file, such as a tag list, is named by its output file. When three or more pages link the same missing target, a closing note says so: a link on every page usually comes from the theme, not from the page files the lines name.
To build anyway while you fix the links, such as for the first build after an upgrade, pass --lax. Each broken link is then a warning, and the build ends by saying how many it relaxed:
accent build --output ./public --lax
The check understands clean URLs (/docs/guide is satisfied by docs/guide/index.html), redirect stubs, and --base-path prefixes. Paths shown inside code samples are ignored — they are documentation, not links.
The check covers internal links only. External links are not checked: a build that fetched every external URL would pass or fail with the reachability of other people’s servers. A relative link that Accent cannot resolve to a page is written as is and is not checked here either; accent validate reports it, so a deployment pipeline runs both commands.
--strict-links turned this check into an error before it became the default. It is still accepted, prints a notice that it has no effect, and is removed in 0.27. Remove it from your deploy scripts.
You may also see small *.md/index.html files (e.g. blog.md/index.html, or docs/README.md/index.html for a section’s index file) next to the real pages. These are redirect stubs that bounce GitHub-style .md links to the clean URL; each is a meta-refresh with a canonical link, and they are excluded from sitemap.xml and feed.xml. To omit them, set content.md_extension_redirects: false in config.yaml. That setting only affects .md destinations: relative links written in URL form (guide/syntax) are still rewritten to absolute clean URLs, because their meaning would otherwise depend on which trailing-slash form the host serves.
Markdown-only output
When you set content.markdown_view.default_format: markdown, every page the site serves is markdown, and the build writes each one as a markdown file named after its URL instead of as an index.html in a directory:
| Page | URL | File |
|---|---|---|
| Content page | /docs/guide | docs/guide.md |
| Home page | / | index.md |
| Listing, page 2 | /blog/page/2 | blog/page/2.md |
| Tag or category index | /tags | tags.md |
| Tag or category page | /tags/cli | tags/cli.md |
| Error page | Your host’s error document | 404.md |
A page’s URL is its clean URL in this mode too. Links, canonical links, the sitemap, feeds, and llms.txt all use /docs/guide, so switching a site between formats breaks no inbound link. Redirects stay index.html meta-refresh stubs, which serve a host that has no redirect rules. The link check reads the links in each .md page as well as in the .html files, skipping code spans and fenced code blocks, so a link to a page the build did not write fails the build.
The host rule
A host serving this output needs one rule, in two parts:
- Route. Answer a URL from
<path>.mdwhen no file and no<path>/index.htmlanswers it, answer/fromindex.md, and answer a missing page with404.mdand status 404. - Type. Send every
.mdresponse astext/markdown; charset=utf-8. Most servers send no type for a.mdfile, or a generic one.
accent serve-static follows this rule. accent serve answers the same URLs with the same markdown, with one difference: it answers /docs/guide.md with a 301 to /docs/guide, where a static host serves the file with a 200.
What Accent generates
When you list a host in build.header_artifacts, the build writes the rule into its artifact, and turns each redirect the build recorded into a real redirect, which an HTTP client follows and a meta refresh it does not:
apache: the.htaccessaddsindex.mdtoDirectoryIndex, rewrites a page URL to its.mdfile after theindex.htmlrewrite, sets the type of.mdfiles withForceType, names404.mdas the error document unlessbuild.routing.error_documentnames another, and answers each redirect withRedirectMatchand its status. Tested on Apache 2.4.67.netlify:_redirectsgets a200rewrite from each page’s URL to its file below every redirect, and a/* /404.md 404catch-all unlessbuild.routing.error_documentnames another._headerstypes/*.md; Netlify matches_headersagainst the rewritten path, so a page’s clean URL is typed too. This follows the Netlify CLI’s dev server and was not tested on a deployed Netlify site.
Other hosts
For Caddy (tested with Caddy 2.11.4):
example.com {
root * /var/www/example.com
@markdown path *.md
route {
try_files {path}/index.html {path}/index.md {path} {path}.md
header @markdown Content-Type "text/markdown; charset=utf-8"
file_server
}
handle_errors 404 {
rewrite * /404.md
header Content-Type "text/markdown; charset=utf-8"
file_server
}
}
The route block makes Caddy set the type after it has found the .md file, so /docs/guide is sent as markdown as well as /docs/guide.md.
For nginx (tested with nginx 1.27.5):
server {
listen 80;
server_name example.com;
root /var/www/example.com;
error_page 404 /404.md;
location / {
try_files $uri $uri/index.html @markdown;
}
# A page URL answers from its .md file, and / from index.md.
location @markdown {
rewrite ^/$ /index.md last;
rewrite ^(.+)$ $1.md last;
}
location ~ \.md$ {
types { }
default_type "text/markdown; charset=utf-8";
}
}
The rewrite sends a page URL to the .md location, which is what sets the type; a file served from location / would get the type mime.types gives it. On Caddy and nginx a redirect is its meta-refresh stub, served with a 200; add a redir or return 301 for each redirect an HTTP client has to follow.
A host that cannot answer one path from another file, such as GitHub Pages, cannot serve this output: it serves each page at its .md URL only, so every link between pages returns 404.
Preview with serve-static
Before deploying, preview the build output locally using serve-static:
accent build --output ./public --base-url https://example.com accent serve-static --dir ./public # Visit https://127.0.0.1:4403 (accept the self-signed certificate warning)
The preview uses HTTPS by default, which is what makes it a faithful check of service workers and mixed-content policies. For plain HTTP – a CI smoke test whose HTTP client cannot accept a self-signed certificate, say:
accent serve-static --dir ./public --no-tls # Visit http://127.0.0.1:4403
This is especially useful in CI pipelines for running smoke tests against the built output before deploying.
serve-static applies the same http_headers policy accent serve does, including the baseline security headers and the strict media policy. That makes it a truthful model of a correctly configured host — one that has been given the response-header artifact described in the next section. A host with no header configuration serves bare files, and the preview cannot detect that for you.
Response headers on static hosting
Everything under http_headers is applied by the server at request time. A static build writes files, and files carry no HTTP headers, so on a static host the entire header story disappears unless the host is told about it separately:
- The baseline security headers —
X-Content-Type-Options,X-Frame-Options,Referrer-Policy, the pageContent-Security-Policy— are simply absent, along with any route-scoped CSP. Cache-Controlfalls back to whatever the host defaults to.- Download dispositions degrade. A published
.shmay render as text instead of downloading, and the force-download that keeps a scriptable SVG from executing in your site’s origin is gone — that one is a lost security control, not a cosmetic difference.
Most static hosts can express response headers through a config artifact deployed alongside the files. accent build generates that artifact from the same http_headers config the server reads, so there is one source of truth instead of a hand-maintained copy that drifts:
build: header_artifacts: ["netlify", "apache"] # default: [] (emit nothing)
| Target | File written at the output root | For |
|---|---|---|
netlify | _headers | Netlify |
apache | .htaccess (mod_headers directives) | Apache, including shared hosting |
The default is an empty list: a site that never sets header_artifacts produces exactly the output tree it did before, with no extra files.
What gets translated
- Security headers —
nosniff, frame options, referrer policy, the page CSP as the site-wide default, the strict media CSP on/media/and/content-media/, and everyhttp_headers.security.routesentry as its own scoped rule. The page CSP includes ascript-srcthat allows your theme’s scripts by hash and origin; see the script policy in the configuration reference. - Cache policy — the per-category
http_headers.cache.defaultsmapped onto the file extensions that produce each category, plus everyhttp_headers.cache.routesoverride. A year-longassetsorfontspolicy applies as configured only to the files the build fingerprinted, up to 200; every other file of those types is kept an hour at most. - Dispositions — the SVG force-download on media routes, and each
media.attachments.extensionsentry asContent-Disposition: attachmentwithContent-Type: application/octet-stream.
Rules are written least specific first, and a later rule overrides an earlier one for the same header name — the resolution order both target formats use.
Routing (apache only)
The .htaccess also carries routing directives, and they are not optional: they make Apache serve the URLs accent already advertises. The one setting among them is directory listing, below.
accent’s canonical URL has no trailing slash — /about, not /about/ — and that is the form in sitemap.xml, the feed, llms.txt, and every page’s <link rel="canonical">. Apache’s mod_dir canonicalises the other way and 301-redirects /about to /about/ before serving. Without these directives, every URL your sitemap lists is a redirect, which Search Console reports as “Page with redirect” instead of indexing the page.
The emitted block turns that off and maps the no-slash URL to its index.html directly, so /about answers 200.
To turn directory listings off for the directories the build does not give an index.html (theme/assets/, media/), set build.routing.directory_listing: deny. The artifact then adds Options -Indexes, which Apache accepts only where the host’s AllowOverride includes Options. Where it does not, Apache refuses the whole .htaccess and every page answers 500, so check before a production deploy: upload a .htaccess holding only Options -Indexes into a scratch directory and request a file there. A 500 means the host does not allow it; leave the setting at its default, host, which lets the host’s own configuration decide.
If your home page lives in a home/ directory, the build serves it at the site root as well, and the artifact adds a 301 from /home to / so only the advertised URL is reachable. A site that has its own page at / keeps both — they are two different pages, and neither redirects.
Requirements: mod_rewrite, and AllowOverride covering FileInfo and Indexes (or All), because DirectoryIndex and DirectorySlash need Indexes; Options too only with directory_listing: deny. On Debian and Ubuntu neither mod_rewrite nor mod_headers is enabled by default — sudo a2enmod rewrite headers.
A host without mod_rewrite is not broken by this: the DirectorySlash change sits inside the module guard, so such a host simply keeps the old redirect behaviour.
When site.base_path is set, emitted patterns carry the prefix (/media/* becomes /docs/media/*). Apache’s <FilesMatch> blocks match filenames rather than paths, so they carry no prefix.
Deployment routing you configure
The directives above are derived — they express the URL shape the build already committed to, so there is nothing to choose. Three more are the opposite: only you know them, and without them a generated artifact is not yet a complete replacement for a hand-written one.
build: header_artifacts: ["apache", "netlify"] routing: error_document: /404.html canonical_host: example.com redirects: - from: /install.sh to: https://example.invalid/install.sh - from: /old-page to: /new-page status: 302 # optional; 301 when omitted
error_document—accent buildwrites/404.html, but a static host only serves it when told to. Without this you get the host’s stock error page.canonical_host— 301s the host’s www/apex counterpart to it, so two names do not both serve 200 for identical bytes and split your link equity. Setexample.comto redirectwww.example.com, orwww.example.comto redirect the apex. It does not redirect every other host: preview and staging deployments are legitimately reachable under another name. The target is alwayshttps.redirects— served in configuration order. Atostarting with/is site-relative and carriesbase_path; anything else is used verbatim, which is how you point off-site.
The block is inert when absent, so adding header_artifacts alone changes nothing here.
Values are validated when the artifact is written, and a bad one fails the build rather than emitting a directive Apache would misread — a canonical_host carrying a scheme, a relative from, a status outside 301/302/307/308, or whitespace in any value (Apache splits directive arguments on whitespace).
Ordering inside the file is not cosmetic: the canonical-host rule is emitted before the clean-URL rewrite, because mod_rewrite re-runs a per-directory ruleset after an internal rewrite. Emitted the other way round, a request for www.example.com/about lands on https://example.com/about/index.html — leaking the index file into the address bar and contradicting the page’s own canonical tag.
The netlify target writes these rules to a _redirects file beside _headers, from the same config, as from to status lines (first match wins). It also writes the redirects the build already knows — a frontmatter redirect: with its code, a permalink or .md-extension move as a 301, and each home page’s authoring URL — so a Netlify host answers a real 301 where it would otherwise serve the 200 meta-refresh stub. The stubs stay in the output for a host that does not read _redirects. The error document becomes the /* catch-all on the last line (/* /404.html 404). Cloudflare Pages reads the same _redirects format. Two exceptions: canonical_host has no _redirects form — Netlify sets a primary domain in its own site configuration, so configuring it with the netlify target fails the build naming the key — and directory_listing only shapes the Apache file, so the netlify target ignores it.
Fail-closed translation
Each target’s pattern syntax has real limits, and the generator never papers over them. If a configured rule cannot be expressed exactly in a requested target, the build fails with an error naming both the rule and the target. A quietly weaker artifact would recreate exactly the invisible divergence this feature exists to close.
In practice the limit you are most likely to hit is glob syntax in the _headers format. Netlify’s * matches across / and there is no **, so a route pattern like /content-media/**/*.html has no exact _headers equivalent and stops the build:
build.header_artifacts: the `netlify` target cannot express
http_headers.security.routes[0] (/content-media/**/*.html) -- the pattern
`/content-media/**/*.html` uses glob syntax that _headers cannot express ...
Rewrite the route as an exact path or a /prefix/* subtree. The apache target expresses the same pattern as a regular expression and accepts it unchanged — the two targets genuinely differ, and neither is held back by the other’s limits.
Correction blocks in _headers
Netlify’s path matching is looser than it looks: a trailing / is optional, so /media/* also matches a page at /media-kit; and a . in a pattern is not escaped, so /*.css also matches a tag page at /tags/css. Left alone, those pages would be served with the strict media CSP or the asset cache policy.
The generator checks every pattern it writes against the real URLs of the finished build, and where a pattern reaches further than the config does it appends an exact-path block putting the right headers back. Those blocks are labelled:
# Correction: a pattern above matches /tags/css more loosely than the site
# config does. These are its intended headers.
/tags/css
Cache-Control: public, max-age=300, s-maxage=3600
...
They are ordinary _headers syntax and need no action from you. The one case that cannot be corrected is a page whose URL collides with an attachment extension — a page at /bash picks up Content-Disposition: attachment from /*.sh, and _headers has no way to unset a header. That fails the build and names the URL; rename the page or drop the netlify target.
This check has no equivalent in the apache target, whose regexes are anchored and escaped.
One file, one owner
If a _headers or .htaccess is already present in the output tree — shipped from theme assets, or written by hand — the build fails rather than overwriting or merging it. Merging generated and hand-written rules is out of scope; pick one source. A file generated by a previous build is recognised by its # Generated by accent build marker and replaced, so rebuilding without --clean is not a conflict.
Hosts without a header mechanism
GitHub Pages has no way to set custom response headers. On GitHub Pages the security and disposition features degrade: no CSP, no frame options, and an attachment-listed file may render inline in the browser instead of downloading. If any of those matter for your site, host it somewhere that supports response headers.
Cloudflare Pages reads the same _headers file format as Netlify, but resolves a header set by two overlapping rules by joining the values with a comma rather than letting the later rule win. The generated _headers relies on override ordering, so it is a Netlify artifact — a dedicated Cloudflare target is tracked as follow-up work. For Cloudflare today, configure headers through Cloudflare’s own transform rules.
nginx and Caddy are not generated yet; the header_artifacts list is designed to grow. See the nginx and Caddy sections below for hand-written configuration.
Deploy to Cloudflare Pages
Cloudflare Pages provides free hosting with a global CDN. Deploy with GitHub Actions:
# .github/workflows/deploy.yml name: Deploy to Cloudflare Pages on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install Accent CMS run: | curl -fsSL https://accentcms.dev/install.sh | sh echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Build site run: accent build --output ./public --base-url ${{ vars.SITE_URL }} - name: Deploy uses: cloudflare/wrangler-action@v3 with: command: pages deploy ./public --project-name=my-site apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
Set SITE_URL as a repository variable and CLOUDFLARE_API_TOKEN as a repository secret in your GitHub settings.
Deploy to Netlify
Create a netlify.toml in your project root:
[build]
command = "accent build --output ./public --base-url https://example.com"
publish = "public"
[build.environment]
# Netlify will use this to install Accent CMS
ACCENTCMS_VERSION = "latest"
Let the build write your redirects instead of maintaining [[redirects]] blocks by hand. Set build.header_artifacts: ["netlify"] and build.routing in config.yaml (see Deployment routing you configure above), and accent build emits a _redirects file with your configured redirects, the moves the build already knows, and the error document as the /* catch-all:
build: header_artifacts: ["netlify"] routing: error_document: /404.html redirects: - from: /docs/* to: /media/docs/:splat
If you prefer to keep redirects in netlify.toml, the manual form still works — add a [[redirects]] block with from = "/*", to = "/404.html", status = 404, and omit the netlify target so the two do not both try to own the file.
Alternatively, deploy with the Netlify CLI:
accent build --output ./public --base-url https://your-site.netlify.app npx netlify deploy --prod --dir=./public
Deploy to GitHub Pages
GitHub Pages serves a site in one of two ways, and the distinction matters for how you build:
- User or organization sites (
username.github.io) serve at the domain root. Nothing special is needed – treat it like any other domain. - Project sites (
username.github.io/repo) serve under a sub-path. Every page link, theme asset, media path, and search fetch path needs the/repoprefix, or the deployed site 404s on its own assets.
Accent handles project sites automatically: the path component of --base-url sets the site’s base path, so every internal URL Accent emits is prefixed to match. The recipe below works as written – there is no post-build rewriting step required.
Use GitHub Actions to build and deploy to GitHub Pages:
# .github/workflows/pages.yml name: Deploy to GitHub Pages on: push: branches: [main] permissions: contents: read pages: write id-token: write jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install Accent CMS run: | curl -fsSL https://accentcms.dev/install.sh | sh echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Build site run: accent build --output ./public --base-url https://username.github.io/repo - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: ./public deploy: needs: build runs-on: ubuntu-latest environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4
Enable GitHub Pages in your repository settings under Settings > Pages and select GitHub Actions as the source.
For explicit control – or when --base-url carries no path component but you still want a prefix – pass --base-path directly, or set site.base_path in config.yaml. An explicit value always wins over anything derived from --base-url/site.url:
accent build --output ./public --base-url https://username.github.io/repo --base-path /repo
accent build finishes with a conformance check over the emitted output: if any generated file still contains an unprefixed root-absolute internal URL, the build fails and reports the offending file and line rather than shipping a broken site. During rendering it also warns – with file and line – about theme templates that hardcode a root-absolute href, src, or action instead of using the url() template function; see Theme Portability for the pattern.
Preview the project-site build locally
accent serve-static picks up the resolved site.base_path automatically, so previewing a prefixed build needs no extra flags:
accent build --output ./public --base-url https://username.github.io/repo accent serve-static --dir ./public # Visit https://127.0.0.1:4403/repo
Deploy to Vercel
Create a vercel.json in your project root:
{ "buildCommand": "accent build --output ./public --base-url https://example.vercel.app", "outputDirectory": "public", "cleanUrls": true }
Deploy with the Vercel CLI:
npx vercel --prod
Deploy to Any Web Server
Copy the public/ directory to the document root of any web server:
accent build --output ./public --base-url https://example.com # Copy to a remote server rsync -avz --delete ./public/ user@server:/var/www/example.com/ # Or serve locally with nginx sudo cp -r ./public/* /var/www/html/
nginx Configuration for Static Files
server {
listen 80;
server_name example.com;
root /var/www/example.com;
# Clean URLs: try directory index first
location / {
try_files $uri $uri/index.html =404;
}
# Theme files keep their names across releases, so cache them for an
# hour; a fingerprinted file is renamed when it changes, so it can be
# kept for a year. Match the hash length to build.fingerprint.length.
location /theme/assets/ {
expires 1h;
}
location ~ "\.[0-9a-f]{10}\.(css|js)$" {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Custom 404 page
error_page 404 /404.html;
}
Caddy Configuration
example.com {
root * /var/www/example.com
file_server
try_files {path} {path}/index.html
}
Differences from Dynamic Serving
The static build output is functionally identical to what the dynamic server renders, with a few differences:
| Aspect | Dynamic Server | Static Build |
|---|---|---|
| Pagination | Rendered on request | Physical page/N/index.html files (page/N.md in markdown-only output) |
| Plugins | Execute at request time | Execute once at build time |
| Media processing | On-demand (first request triggers processing) | All media pre-processed at build time |
| Content updates | Edit file, refresh browser | Edit file, rebuild, redeploy |
| Config changes | Reload with SIGHUP or POST /_admin/reload, or restart | Rebuild required |
| RSS/Sitemap | Generated on request | Generated as static files |
| Response headers | Applied per request from http_headers | Up to the host — set build.header_artifacts to generate its config (see above) |
Templates, markdown rendering, and page output are identical between the two modes.
CI/CD Pipeline Example
A complete GitHub Actions workflow that builds, tests, and deploys:
# .github/workflows/deploy.yml name: Build and Deploy on: push: branches: [main] pull_request: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install Accent CMS run: | curl -fsSL https://accentcms.dev/install.sh | sh echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Validate content run: accent validate - name: Build site run: accent build --output ./public --base-url ${{ vars.SITE_URL }} - name: Check for broken links run: | npx broken-link-checker-local ./public --recursive - name: Deploy (main branch only) if: github.ref == 'refs/heads/main' && github.event_name == 'push' run: | # Replace with your deployment command rsync -avz --delete ./public/ user@server:/var/www/site/
Pull requests run the build and validation steps without deploying, so you can catch issues before merging.
Next Steps
- Production Server – run Accent CMS as a dynamic service
- Deployment Overview – compare deployment strategies
- Getting Started – initial project setup