Deployment
A Scavold site builds to a plain directory of static files. Any web server that serves static files can host it. This page walks through building the site, deploying it to a remote server, hardening it with security headers, and resolving the issues that most commonly come up.
Building
bun run buildThe built site is written to .vitepress/dist/. Everything under that directory is self-contained — copy it to your server's web root and it works.
Deploying via SFTP
rclone is the recommended tool for SFTP deployment. It mirrors the local build directory to the remote server, adding new files, updating changed files, and deleting files that no longer exist in the build output.
rclone sync .vitepress/dist/ \
":sftp,host=<host>,user=<user>,key_file=~/.ssh/id_deploy:<remote-path>"GitLab CI
The Scavold repository ships a ready-to-use GitLab CI configuration (.gitlab-ci.yml) covering build, CSP hash file generation, and SFTP deployment. To reuse it in your own site, copy the docs-build and docs-deploy jobs and adjust the script paths for your project.
Also copy the workflow: and default: blocks at the top of the file. They configure two important behaviours:
- Duplicate-pipeline prevention — GitLab fires both a branch pipeline and an MR pipeline for the same commit when an MR is open. The
workflow.rulesblock keeps only the MR pipeline and suppresses the branch one, so each push triggers exactly one pipeline. - Auto-cancellation —
auto_cancel.on_new_commit: interruptiblecombined withdefault: interruptible: truecancels any still-running pipeline for the same MR or branch the moment a new commit is pushed, freeing runner capacity immediately.
workflow:
auto_cancel:
on_new_commit: interruptible
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
- if: $CI_PIPELINE_SOURCE == "schedule"
default:
interruptible: trueRequired CI/CD variables (GitLab → Settings → CI/CD → Variables):
| Variable | Description |
|---|---|
SSH_PRIVATE_KEY | Private key base64-encoded (base64 -w 0 ~/.ssh/id_deploy). Mark as protected and masked. |
DEPLOY_HOST | Hostname or IP of the target server. |
DEPLOY_USER | SSH user on the target server. |
DEPLOY_PATH | Remote path to deploy into. Use a path relative to the SFTP landing directory (no leading /) if the user is chrooted. |
Where a content mistake stops the pipeline
A build that finished can still carry a wrong link. Scavold reads its own output at the end of every build and writes what it found to .cratly/build-report.json:
{
"specVersion": 0,
"adapter": { "name": "scavold", "version": "0.2.0-rc.5" },
"generated": "2026-09-07T10:00:00.000Z",
"fatal": false,
"problems": {
"pages": [],
"assets": [],
"content": [
{ "page": "de/index.html", "reference": "./www.example.com.html", "source": "de/index.md" }
]
}
}fatal says which of two things happened. pages and assets end the build — the site is unusable and there is nothing to publish. content does not: the site works and one link in it does not, which is a mistake an author can fix and a state a site may even choose (a link to a path the web server provides rather than the build). source names the file that author would open, so a reader of this file never has to map a built page back to its markdown.
For the same reason Scavold sets VitePress' ignoreDeadLinks to true by default. VitePress would end the build on a dead link, and end it before writing anything, so the reason lives in a job log — which whoever wrote the link often has no access to, and a single typo would stand between a finished page and its publication. Ask for the abort back with ignoreDeadLinks: false in your own config.
What remains is a policy question, and it belongs in CI rather than in the config, because the answer differs per branch: a merge request is where fixing a link is still cheap, and the default branch is where blocking would only punish a broken link with a stale site. The site template makes exactly that split:
- |
if [ "$CI_PIPELINE_SOURCE" = "merge_request_event" ] && [ -f .cratly/build-report.json ]; then
bun -e '
const report = await Bun.file( ".cratly/build-report.json" ).json();
const problems = report.problems.content;
if ( problems.length ) {
console.error( `ERROR: ${problems.length} link(s) or image(s) point at files this build does not contain:` );
for ( const { source, page, reference } of problems ) {
console.error( ` ${source ?? page} → ${reference}` );
}
process.exit( 1 );
}
'
fiA site whose CI predates this
The template is copied into a site once, so an update to it never reaches a site that already exists. Two edits are needed in such a site's own .gitlab-ci.yml:
- add the step above to the build job, after
bun run build; - add
.cratly/build-report.jsonto the job'sartifacts.pathsand setartifacts.when: alwayson it — the report matters most in the run that failed, and withoutwhen: alwaysGitLab uploads nothing from a failed job.
Add .cratly/build-report.json to .gitignore as well. Unlike .cratly/sections.json, it is not a committed generated file: it describes one build, not the site.
Security headers
Web server used in these examples
The examples on this page use Caddy as the web server. Caddy is a good fit for static sites: it handles TLS automatically via Let's Encrypt, has a concise configuration syntax, and reloads configuration without downtime. If you are already running a different server, the header directives and CSP approach described here map straightforwardly to nginx, Apache, or any other server that lets you set response headers.
Setting appropriate HTTP security headers is recommended for any public-facing site. securityheaders.com lets you scan your deployed site and explains what each header does and why it matters — it is a good starting point for deciding which headers make sense for your situation.
The examples below are suggestions. Adjust values to fit your site's actual needs; blindly copying a header policy that is too strict will break legitimate functionality.
Recommended Caddy snippet
A reusable Caddy snippet covers the most common security headers. Pair it with the caddy-csp module that injects inline-script hashes automatically (see Content Security Policy) per server block:
(sec-headers) {
header {
Referrer-Policy "no-referrer"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' data:; frame-ancestors 'none';"
-Server
}
}
example.com {
import sec-headers
csp_hashes /var/www/example.com/.csp-hashes.txt
root * /var/www/example.com
file_server
}The csp_hashes directive reads .csp-hashes.txt from the web root (see Content Security Policy below), watches it for changes, and appends the hash tokens to the script-src directive on every request — no Caddy reload needed after a deployment.
Customising the CSP beyond script hashes
If a site needs a policy that differs structurally — for example, additional connect-src origins or a different frame-ancestors value — declare a header block with the > replace prefix before the import directive. Caddy applies response middleware in reverse order, so the site block's header runs last and takes precedence over the snippet's. The csp_hashes directive still appends the hashes to whichever header is present:
example.com {
header >Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' https://api.example.com; frame-ancestors 'none';"
import sec-headers
csp_hashes /var/www/example.com/.csp-hashes.txt
root * /var/www/example.com
file_server
}Content Security Policy
VitePress injects a small number of inline scripts into the built HTML for theme initialisation. These must be explicitly allowed in a Content-Security-Policy header via their SHA-256 hashes. Do not use 'unsafe-inline' on script-src: it would permit any inline script to run, negating the protection that a CSP provides against cross-site scripting attacks.
One of those scripts embeds content-addressed chunk filenames that change on every build, so the hash set changes with every deployment. Updating a hardcoded Caddyfile entry after each build would require a server reload. The recommended solution avoids this entirely.
Automatic hash injection with caddy-csp
The caddy-csp Caddy module reads the script hashes from a plain-text file in the web root, watches the file for changes, and appends the hashes to the script-src directive on each request. New hashes take effect the moment a deployment writes the file — no Caddy reload needed.
The CI pipeline generates this file as part of every build. It is deployed alongside the site files automatically.
Hash file format
The file .csp-hashes.txt in the built site contains one bare hash per line:
sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=
sha256-RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc=Scavold's check-csp script generates this file. To produce it manually after a build:
# Write both the reference file and the caddy-csp hash file
bun run check-csp .vitepress/dist .csp-hashes .vitepress/dist/.csp-hashes.txtArguments (all optional):
| Argument | Default | Description |
|---|---|---|
dist-dir | docs/.vitepress/dist | Path to the built site directory |
hashes-file | .csp-hashes | Path to the committed reference file (quoted format) |
caddy-file | — | If given, also writes the caddy-csp format file to this path |
With caddy-csp in place, no further configuration is needed — Caddy picks up updated hashes automatically after each deployment.
Troubleshooting
Hydration mismatches / the wrong page is served
If the browser console reports "Hydration completed but contains mismatches" and navigation is broken on every page except the home page, the web server is most likely serving the root index.html for every route instead of the per-section file.
VitePress generates a complete static tree — a real index.html for every section (/guide/index.html, /guide/deployment.html, …). A static file server serves those directory indexes natively, so no URL rewriting is needed.
The usual cause is an SPA-style catch-all such as try_files {path} /index.html in the Caddyfile. For a request to /guide/, that rule resolves the directory, fails the file check, and falls through to the root /index.html — so the home page's HTML is served under the /guide/ URL. The browser then hydrates the requested page's JavaScript over the home page's markup, which is exactly the mismatch reported.
The fix is to remove the catch-all and let file_server serve directory indexes on its own, as in the recommended snippet above. If you want a proper 404 page (VitePress builds 404.html), use error handling rather than a catch-all:
example.com {
import sec-headers
csp_hashes /var/www/example.com/.csp-hashes.txt
root * /var/www/example.com
file_server
handle_errors {
rewrite * /404.html
file_server
}
}To confirm the diagnosis, request a sub-page's file directly and compare it to the directory URL:
curl -s https://example.com/guide/ | grep -o '<title>[^<]*'
curl -s https://example.com/guide/index.html | grep -o '<title>[^<]*'If the two titles differ, the server is misrouting directory requests.
CSP blocks inline scripts after an upgrade
If pages render unstyled or the console reports blocked inline scripts after upgrading VitePress or changing the theme, the inline-script hashes have changed and the deployed .csp-hashes.txt is stale. Rebuild and redeploy so the hash file is regenerated; with caddy-csp watching the file, the new hashes take effect without a Caddy reload.