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

FlagDescriptionDefault
--output / -oOutput directory for generated filesoutput
--base-urlBase 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 / -cPath to config file./config.yaml
--cleanRemove output directory before buildingfalse
--laxReport 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 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.

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.yaml changed.
  • 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.wasm or its plugins.config entry; theme.yaml; the media, code, diagrams, and markdown sections of config.yaml; the site URL and base path (so a build under a different --base-url rebuilds 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.enabled on. 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 (which site.last_modified reads), 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 /about directly, 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.

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 page Content-Security-Policy — are simply absent, along with any route-scoped CSP.
  • Cache-Control falls back to whatever the host defaults to.
  • Download dispositions degrade. A published .sh may 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)
TargetFile written at the output rootFor
netlify_headersNetlify
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 every http_headers.security.routes entry as its own scoped rule. The page CSP includes a script-src that 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.defaults mapped onto the file extensions that produce each category, plus every http_headers.cache.routes override. A year-long assets or fonts policy 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.extensions entry as Content-Disposition: attachment with Content-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 build writes /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. Set example.com to redirect www.example.com, or www.example.com to redirect the apex. It does not redirect every other host: preview and staging deployments are legitimately reachable under another name. The target is always https.
  • redirects — served in configuration order. A to starting with / is site-relative and carries base_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 /repo prefix, 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:

AspectDynamic ServerStatic Build
PaginationRendered on requestPhysical page/N/index.html files
PluginsExecute at request timeExecute once at build time
Media processingOn-demand (first request triggers processing)All media pre-processed at build time
Content updatesEdit file, refresh browserEdit file, rebuild, redeploy
Config changesReload with SIGHUP or POST /_admin/reload, or restartRebuild required
RSS/SitemapGenerated on requestGenerated as static files
Response headersApplied per request from http_headersUp 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