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.
Required 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. |
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.