Skip to content

Search docs

Search the Skiffly documentation

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

Staticfile
root: site
index_fallback: true     # SPA: unmatched paths serve index.html

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

FrameworkDetected byOutput served
Vite (React, Vue, Svelte, Solid)vite.config.* or vite build in the build scriptdist/
Astroastro.config.* with output other than serverdist/
Next.jsoutput: "export" in next.config.*out/
Create React Appreact-scriptsbuild/
Angularangular.json (RAILPACK_ANGULAR_PROJECT for multi-project workspaces)dist/<project>/browser
React Router / Remix SPA modeconfig or react-router buildbuild/client
Expo webexpo.web.output = static or singledist/

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.json whose build script runs the generator ("build": "hugo --minify" with the hugo-bin package, or "build": "npx @11ty/eleventy"), and set RAILPACK_SPA_OUTPUT_DIR to 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):
Dockerfile
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):

Caddyfile
:{$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 domain gives <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#

SymptomFix
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 404index_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 undefinedset 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 siteadd a Staticfile with root:, or RAILPACK_STATIC_FILE_ROOT