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 | Base URL for absolute links and sitemaps | (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 as warnings that do not fail the build | false (a broken link 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 sets the base for absolute URLs in sitemaps, RSS feeds, and canonical links. Always set it to your production domain. If its path component is non-root – for example https://username.github.io/repo – that path is also used to derive the site’s base path automatically, so every internal link and asset is prefixed to match. See Deploy to GitHub Pages below.
Incremental builds
accent build writes a .build-manifest.json file into the output directory and reads it on the next run, so a page whose inputs did not change is skipped. A page is rebuilt when its own markdown, a partial it includes, 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. Five 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. - 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 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 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.
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. - 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 or config-derived: they make Apache serve the URLs accent already advertises.
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. It also adds Options -Indexes, because the rewrite makes index-less directories (theme/assets/, media/) directly addressable.
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 Options (or All). 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 (apache only)
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"] 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.
build.routing has no equivalent in the netlify target, which carries redirects in a separate _redirects file that this generator does not write. Configuring both is a build error rather than a deployment silently missing its rules.
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 immutable 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"
[[redirects]]
from = "/*"
to = "/404.html"
status = 404
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;
}
# Cache static assets aggressively
location /assets/ {
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 |
| 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