Skip to content

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

sh
bun run build

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

sh
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):

VariableDescription
SSH_PRIVATE_KEYPrivate key base64-encoded (base64 -w 0 ~/.ssh/id_deploy). Mark as protected and masked.
DEPLOY_HOSTHostname or IP of the target server.
DEPLOY_USERSSH user on the target server.
DEPLOY_PATHRemote 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.

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:

caddy
(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:

caddy
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:

sh
# Write both the reference file and the caddy-csp hash file
bun run check-csp .vitepress/dist .csp-hashes .vitepress/dist/.csp-hashes.txt

Arguments (all optional):

ArgumentDefaultDescription
dist-dirdocs/.vitepress/distPath to the built site directory
hashes-file.csp-hashesPath to the committed reference file (quoted format)
caddy-fileIf 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:

caddy
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:

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