Static sites
Deploy static sites and SPAs on Skiffly — plain HTML with a `Staticfile`, Vite, Astro, SvelteKit, Angular, Create React App, Next.js `output: "export"`, Hugo and Jekyll, the Caddy server Railpack uses, SPA fallbacks, headers and App Sleeping.
A static site on Skiffly is a service whose container runs Caddy serving a directory. Railpack sets that up in two situations: a repository that is nothing but files, and a JavaScript build whose output is static. Either way the service gets a domain with TLS, can be put to sleep when idle, and costs a fraction of a vCPU.
Plain files#
Railpack's staticfile provider triggers when the root directory contains a Staticfile, an index.html, a public/ directory, or when RAILPACK_STATIC_FILE_ROOT is set. The directory served is, in order: RAILPACK_STATIC_FILE_ROOT, root in Staticfile, public/, then ..
root: site
index_fallback: true # SPA: unmatched paths serve index.htmlNo build step runs. Commit a Caddyfile in the root to replace Railpack's default one (custom headers, redirects, encode gzip, basic auth). Caddy listens on PORT; nothing to configure.
Built sites (Vite, Astro, Angular, CRA, …)#
With a package.json the Node provider runs npm run build and, when it recognises a static framework, serves the output with Caddy instead of starting a Node process:
| Framework | Detected by | Output served |
|---|---|---|
| Vite (React, Vue, Svelte, Solid) | vite.config.* or vite build in the build script | dist/ |
| Astro | astro.config.* with output other than server | dist/ |
| Next.js | output: "export" in next.config.* | out/ |
| Create React App | react-scripts | build/ |
| Angular | angular.json (RAILPACK_ANGULAR_PROJECT for multi-project workspaces) | dist/<project>/browser |
| React Router / Remix SPA mode | config or react-router build | build/client |
| Expo web | expo.web.output = static or single | dist/ |
Override the directory with RAILPACK_SPA_OUTPUT_DIR=build; disable the static mode (run the framework's own server instead) with RAILPACK_NO_SPA=true — needed for SvelteKit with adapter-node, Nuxt, Astro SSR and Next.js without output: "export". SvelteKit with adapter-static and Nuxt generate produce a directory Railpack does not detect by name: set RAILPACK_SPA_OUTPUT_DIR to it (build, .output/public).
The SPA fallback (every unknown path → index.html) is on in this mode, so client-side routers work.
Hugo, Jekyll, MkDocs and other generators#
Railpack has no provider for these. Two ways that work today:
- Through the Node provider. Add a
package.jsonwhosebuildscript runs the generator ("build": "hugo --minify"with thehugo-binpackage, or"build": "npx @11ty/eleventy"), and setRAILPACK_SPA_OUTPUT_DIRto the output directory (public,_site). Railpack serves it with Caddy. - Dockerfile. A two-stage build works for any generator, including Python ones (MkDocs, Pelican, Sphinx):
FROM hugomods/hugo:exts AS build
WORKDIR /src
COPY . .
RUN hugo --minify
FROM caddy:2-alpine
COPY --from=build /src/public /srv
CMD ["sh", "-c", "caddy file-server --root /srv --listen :$PORT"]The hugo-site template in the catalog is the runtime half of this (Caddy serving a volume at /srv); a repository with the Dockerfile above replaces it.
Headers, redirects, trailing slashes#
Put a Caddyfile in the repository root to customise the server Railpack ships (it is used verbatim, so keep :{$PORT} as the address):
:{$PORT} {
root * /app/dist
encode gzip zstd
header /assets/* Cache-Control "public, max-age=31536000, immutable"
header Cache-Control "public, max-age=300"
redir /old-page /new-page permanent
try_files {path} /index.html
file_server
}/app is the working directory of Railpack's runtime image; adjust root to the output directory.
Environment variables#
Static builds inline variables at build time (VITE_*, PUBLIC_*, NEXT_PUBLIC_*). Set them as service variables — every one is passed to the build — and trigger a new build after changing them (a restart does not rebuild).
Domains, sleeping, cost#
skiffly domaingives<name>-<id>.skiffly.cloud; custom domains need a CNAME and a TXT record (Networking).- App Sleeping (
sleep: true) fits documentation sites and landing pages: after 10 idle minutes the container is stopped, the next visitor waits a few seconds. Static sites need almost no memory: Settings → Resources → 0.25 vCPU / 256 MiB. - Skiffly has no CDN in front of the node yet; for global latency put Cloudflare (proxied CNAME) in front of the custom domain.
Common problems#
| Symptom | Fix |
|---|---|
A Node process starts instead of Caddy (next start, vite preview) | the framework was not recognised as static: set RAILPACK_SPA_OUTPUT_DIR, or for Next.js add output: "export" |
| Deep links 404 | index_fallback: true in Staticfile, or try_files {path} /index.html in a custom Caddyfile |
Blank page, console shows 404s for /assets/* | the app was built with the wrong base/publicPath, or RAILPACK_SPA_OUTPUT_DIR points at the wrong directory |
VITE_API_URL is undefined | set the variable, then deploy again so the build picks it up |
Site is served but the build ran for a server framework (RAILPACK_NO_SPA unset) | remove RAILPACK_NO_SPA, or set the start command to the framework's server |
| Caddy serves the repository root instead of the site | add a Staticfile with root:, or RAILPACK_STATIC_FILE_ROOT |