feat: add configurable index fallback for static files (#534)

* feat(staticfile): add configurable index fallback for SPAs

- Fixes #https://github.com/railwayapp/railpack/issues/533
- Introduce index_fallback option in Staticfile config to toggle SPA routing.
- Improve root directory detection by trimming whitespace from environment variables and configuration.

* feat: add index fallback example for static files

- Create a new example directory demonstrating the index_fallback configuration.
- Include a test case to verify that non-existent routes correctly serve index.html.

Generated-by: aiautocommit

* test: update snapshots

Generated-by: aiautocommit

* test: update static file config and index test cases

Generated-by: aiautocommit

* test: add SPA index fallback verification to test suite

Generated-by: aiautocommit

* docs: document RAILPACK_VERBOSE and clarify Staticfile config

Generated-by: aiautocommit
This commit is contained in:
Michael Bianco
2026-03-28 09:34:59 -06:00
committed by GitHub
parent 24c2708308
commit 8fd6a9226b
14 changed files with 220 additions and 33 deletions
@@ -44,7 +44,7 @@
},
{
"assets": {
"Caddyfile": "{\n\tadmin off\n\tpersist_config off\n\tauto_https off\n\n\tlog {\n\t\tformat json\n\t}\n\n\tservers {\n\t\ttrusted_proxies static private_ranges\n\t}\n}\n\n:{$PORT:80} {\n\tlog {\n\t\tformat json\n\t}\n\n\trespond /health 200\n\n\t# Security headers\n\theader {\n\t\t# Enable cross-site filter (XSS) and tell browsers to block detected attacks\n\t\tX-XSS-Protection \"1; mode=block\"\n\t\t# Prevent some browsers from MIME-sniffing a response away from the declared Content-Type\n\t\tX-Content-Type-Options \"nosniff\"\n\t\t# Keep referrer data off of HTTP connections\n\t\tReferrer-Policy \"strict-origin-when-cross-origin\"\n\t\t# Enable strict Content Security Policy\n\t\tContent-Security-Policy \"default-src 'self'; img-src 'self' data: https: *; style-src 'self' 'unsafe-inline' https: *; script-src 'self' 'unsafe-inline' https: *; font-src 'self' data: https: *; connect-src 'self' https: *; media-src 'self' https: *; object-src 'none'; frame-src 'self' https: *;\"\n\t\t# Remove Server header\n\t\t-Server\n\t}\n\n\troot * hello\n\n\t# Handle static files\n\tfile_server {\n\t\thide .git\n\t\thide .env*\n\t}\n\n\t# Compression with more formats\n\tencode {\n\t\tgzip\n\t\tzstd\n\t}\n\n\t# Try files with HTML extension and handle SPA routing\n\ttry_files {path} {path}.html {path}/index.html /index.html\n\n\t# Handle 404 errors\n\thandle_errors {\n\t\trewrite * /{err.status_code}.html\n\t\tfile_server\n\t}\n}\n"
"Caddyfile": "{\n\tadmin off\n\tpersist_config off\n\tauto_https off\n\n\tlog {\n\t\tformat json\n\t}\n\n\tservers {\n\t\ttrusted_proxies static private_ranges\n\t}\n}\n\n:{$PORT:80} {\n\tlog {\n\t\tformat json\n\t}\n\n\trespond /health 200\n\n\t# Security headers\n\theader {\n\t\t# Enable cross-site filter (XSS) and tell browsers to block detected attacks\n\t\tX-XSS-Protection \"1; mode=block\"\n\t\t# Prevent some browsers from MIME-sniffing a response away from the declared Content-Type\n\t\tX-Content-Type-Options \"nosniff\"\n\t\t# Keep referrer data off of HTTP connections\n\t\tReferrer-Policy \"strict-origin-when-cross-origin\"\n\t\t# Enable strict Content Security Policy\n\t\tContent-Security-Policy \"default-src 'self'; img-src 'self' data: https: *; style-src 'self' 'unsafe-inline' https: *; script-src 'self' 'unsafe-inline' https: *; font-src 'self' data: https: *; connect-src 'self' https: *; media-src 'self' https: *; object-src 'none'; frame-src 'self' https: *;\"\n\t\t# Remove Server header\n\t\t-Server\n\t}\n\n\troot * hello\n\n\t# Handle static files\n\tfile_server {\n\t\thide .git\n\t\thide .env*\n\t}\n\n\t# Compression with more formats\n\tencode {\n\t\tgzip\n\t\tzstd\n\t}\n\n\t# Try files with HTML extension\n\ttry_files {path} {path}.html {path}/index.html\n\n\thandle_errors {\n\t\trewrite * /{err.status_code}.html\n\t\tfile_server\n\t}\n}\n"
},
"commands": [
{
@@ -0,0 +1,75 @@
{
"deploy": {
"base": {
"image": "ghcr.io/railwayapp/railpack-runtime:mise-2026.3.16"
},
"inputs": [
{
"include": [
"/railpack/caddy"
],
"step": "packages:caddy"
},
{
"include": [
"."
],
"step": "build"
}
],
"startCommand": "caddy run --config Caddyfile --adapter caddyfile 2\u003e\u00261"
},
"steps": [
{
"commands": [
{
"cmd": "mise install-into [email protected] /railpack/caddy"
},
{
"path": "/railpack/caddy"
},
{
"path": "/railpack/caddy/bin"
}
],
"inputs": [
{
"image": "ghcr.io/railwayapp/railpack-builder:mise-2026.3.16"
}
],
"name": "packages:caddy",
"variables": {
"MISE_PARANOID": "1"
}
},
{
"assets": {
"Caddyfile": "{\n\tadmin off\n\tpersist_config off\n\tauto_https off\n\n\tlog {\n\t\tformat json\n\t}\n\n\tservers {\n\t\ttrusted_proxies static private_ranges\n\t}\n}\n\n:{$PORT:80} {\n\tlog {\n\t\tformat json\n\t}\n\n\trespond /health 200\n\n\t# Security headers\n\theader {\n\t\t# Enable cross-site filter (XSS) and tell browsers to block detected attacks\n\t\tX-XSS-Protection \"1; mode=block\"\n\t\t# Prevent some browsers from MIME-sniffing a response away from the declared Content-Type\n\t\tX-Content-Type-Options \"nosniff\"\n\t\t# Keep referrer data off of HTTP connections\n\t\tReferrer-Policy \"strict-origin-when-cross-origin\"\n\t\t# Enable strict Content Security Policy\n\t\tContent-Security-Policy \"default-src 'self'; img-src 'self' data: https: *; style-src 'self' 'unsafe-inline' https: *; script-src 'self' 'unsafe-inline' https: *; font-src 'self' data: https: *; connect-src 'self' https: *; media-src 'self' https: *; object-src 'none'; frame-src 'self' https: *;\"\n\t\t# Remove Server header\n\t\t-Server\n\t}\n\n\troot * .\n\n\t# Handle static files\n\tfile_server {\n\t\thide .git\n\t\thide .env*\n\t}\n\n\t# Compression with more formats\n\tencode {\n\t\tgzip\n\t\tzstd\n\t}\n\n\t# Try files with HTML extension and handle SPA routing\n\ttry_files {path} {path}.html {path}/index.html /index.html\n\n\thandle_errors {\n\t\trewrite * /{err.status_code}.html\n\t\tfile_server\n\t}\n}\n"
},
"commands": [
{
"name": "Caddyfile",
"path": "Caddyfile"
},
{
"cmd": "caddy fmt --overwrite Caddyfile"
}
],
"inputs": [
{
"step": "packages:caddy"
},
{
"include": [
"."
],
"local": true
}
],
"name": "build",
"secrets": [
"*"
]
}
]
}
@@ -44,7 +44,7 @@
},
{
"assets": {
"Caddyfile": "{\n\tadmin off\n\tpersist_config off\n\tauto_https off\n\n\tlog {\n\t\tformat json\n\t}\n\n\tservers {\n\t\ttrusted_proxies static private_ranges\n\t}\n}\n\n:{$PORT:80} {\n\tlog {\n\t\tformat json\n\t}\n\n\trespond /health 200\n\n\t# Security headers\n\theader {\n\t\t# Enable cross-site filter (XSS) and tell browsers to block detected attacks\n\t\tX-XSS-Protection \"1; mode=block\"\n\t\t# Prevent some browsers from MIME-sniffing a response away from the declared Content-Type\n\t\tX-Content-Type-Options \"nosniff\"\n\t\t# Keep referrer data off of HTTP connections\n\t\tReferrer-Policy \"strict-origin-when-cross-origin\"\n\t\t# Enable strict Content Security Policy\n\t\tContent-Security-Policy \"default-src 'self'; img-src 'self' data: https: *; style-src 'self' 'unsafe-inline' https: *; script-src 'self' 'unsafe-inline' https: *; font-src 'self' data: https: *; connect-src 'self' https: *; media-src 'self' https: *; object-src 'none'; frame-src 'self' https: *;\"\n\t\t# Remove Server header\n\t\t-Server\n\t}\n\n\troot * .\n\n\t# Handle static files\n\tfile_server {\n\t\thide .git\n\t\thide .env*\n\t}\n\n\t# Compression with more formats\n\tencode {\n\t\tgzip\n\t\tzstd\n\t}\n\n\t# Try files with HTML extension and handle SPA routing\n\ttry_files {path} {path}.html {path}/index.html /index.html\n\n\t# Handle 404 errors\n\thandle_errors {\n\t\trewrite * /{err.status_code}.html\n\t\tfile_server\n\t}\n}\n"
"Caddyfile": "{\n\tadmin off\n\tpersist_config off\n\tauto_https off\n\n\tlog {\n\t\tformat json\n\t}\n\n\tservers {\n\t\ttrusted_proxies static private_ranges\n\t}\n}\n\n:{$PORT:80} {\n\tlog {\n\t\tformat json\n\t}\n\n\trespond /health 200\n\n\t# Security headers\n\theader {\n\t\t# Enable cross-site filter (XSS) and tell browsers to block detected attacks\n\t\tX-XSS-Protection \"1; mode=block\"\n\t\t# Prevent some browsers from MIME-sniffing a response away from the declared Content-Type\n\t\tX-Content-Type-Options \"nosniff\"\n\t\t# Keep referrer data off of HTTP connections\n\t\tReferrer-Policy \"strict-origin-when-cross-origin\"\n\t\t# Enable strict Content Security Policy\n\t\tContent-Security-Policy \"default-src 'self'; img-src 'self' data: https: *; style-src 'self' 'unsafe-inline' https: *; script-src 'self' 'unsafe-inline' https: *; font-src 'self' data: https: *; connect-src 'self' https: *; media-src 'self' https: *; object-src 'none'; frame-src 'self' https: *;\"\n\t\t# Remove Server header\n\t\t-Server\n\t}\n\n\troot * .\n\n\t# Handle static files\n\tfile_server {\n\t\thide .git\n\t\thide .env*\n\t}\n\n\t# Compression with more formats\n\tencode {\n\t\tgzip\n\t\tzstd\n\t}\n\n\t# Try files with HTML extension\n\ttry_files {path} {path}.html {path}/index.html\n\n\thandle_errors {\n\t\trewrite * /{err.status_code}.html\n\t\tfile_server\n\t}\n}\n"
},
"commands": [
{
+3 -4
View File
@@ -33,7 +33,7 @@
-Server
}
root * {{.STATIC_FILE_ROOT}}
root * {{.StaticFileRoot}}
# Handle static files
file_server {
@@ -47,10 +47,9 @@
zstd
}
# Try files with HTML extension and handle SPA routing
try_files {path} {path}.html {path}/index.html /index.html
# Try files with HTML extension{{if .IndexFallback}} and handle SPA routing{{end}}
try_files {path} {path}.html {path}/index.html{{if .IndexFallback}} /index.html{{end}}
# Handle 404 errors
handle_errors {
rewrite * /{err.status_code}.html
file_server
+48 -23
View File
@@ -6,6 +6,7 @@ package staticfile
import (
_ "embed"
"fmt"
"strings"
"github.com/railwayapp/railpack/core/generate"
"github.com/railwayapp/railpack/core/plan"
@@ -19,32 +20,26 @@ const (
CaddyfilePath = "Caddyfile"
)
// Staticfile in a directory is parsed into this structure
type StaticfileConfig struct {
RootDir string `yaml:"root"`
// IndexFallback controls whether to fall back to index.html for unmatched routes (default: true)
IndexFallback *bool `yaml:"index_fallback"`
}
type StaticfileProvider struct {
RootDir string
}
type StaticfileProvider struct{}
func (p *StaticfileProvider) Name() string {
return "staticfile"
}
func (p *StaticfileProvider) Initialize(ctx *generate.GenerateContext) error {
rootDir, err := getRootDir(ctx)
if err != nil {
return err
}
p.RootDir = rootDir
return nil
}
func (p *StaticfileProvider) Detect(ctx *generate.GenerateContext) (bool, error) {
rootDir, err := getRootDir(ctx)
if rootDir != "" && err == nil {
_, err := getRootDir(ctx)
if err == nil {
return true, nil
}
@@ -52,15 +47,20 @@ func (p *StaticfileProvider) Detect(ctx *generate.GenerateContext) (bool, error)
}
func (p *StaticfileProvider) Plan(ctx *generate.GenerateContext) error {
rootDir, err := getRootDir(ctx)
if err != nil {
return err
}
// NOTE `latest` is not used intentionally here as the Caddyfile template should be tested against major caddy upgrades
installCaddyStep := ctx.NewInstallBinStepBuilder("packages:caddy")
installCaddyStep.Default("caddy", "latest")
installCaddyStep.Default("caddy", "2")
build := ctx.NewCommandStep("build")
build.AddInput(plan.NewStepLayer(installCaddyStep.Name()))
build.AddInput(ctx.NewLocalLayer())
err := p.addCaddyfileToStep(ctx, build)
if err != nil {
if err := p.addCaddyfileToStep(ctx, build, rootDir, getIndexFallback(ctx)); err != nil {
return err
}
@@ -79,17 +79,24 @@ func (p *StaticfileProvider) Plan(ctx *generate.GenerateContext) error {
func (p *StaticfileProvider) CleansePlan(buildPlan *plan.BuildPlan) {}
func (p *StaticfileProvider) StartCommandHelp() string {
return ""
return "Railpack serves static files using Caddy. To configure the static file root, Railpack will check:\n\n" +
"1. The RAILPACK_STATIC_FILE_ROOT environment variable\n\n" +
"2. The \"root\" field in a Staticfile in your project root:\n" +
" root: dist\n\n" +
"3. A \"public\" directory\n\n" +
"4. An index.html in your project root\n\n" +
"To enable SPA-style index.html fallback for unmatched routes, set \"index_fallback: true\" in your Staticfile."
}
func (p *StaticfileProvider) addCaddyfileToStep(ctx *generate.GenerateContext, setup *generate.CommandStepBuilder) error {
ctx.Logger.LogInfo("Using root dir: %s", p.RootDir)
func (p *StaticfileProvider) addCaddyfileToStep(ctx *generate.GenerateContext, setup *generate.CommandStepBuilder, rootDir string, indexFallback bool) error {
ctx.Logger.LogInfo("Using staticfile root dir: %s", rootDir)
data := map[string]any{
"STATIC_FILE_ROOT": p.RootDir,
caddyfileTemplateVariables := map[string]any{
"StaticFileRoot": rootDir,
"IndexFallback": indexFallback,
}
caddyfileTemplate, err := ctx.TemplateFiles([]string{"Caddyfile.template", "Caddyfile"}, caddyfileTemplate, data)
caddyfileTemplate, err := ctx.TemplateFiles([]string{"Caddyfile.template", "Caddyfile"}, caddyfileTemplate, caddyfileTemplateVariables)
if err != nil {
return err
}
@@ -110,14 +117,21 @@ func (p *StaticfileProvider) addCaddyfileToStep(ctx *generate.GenerateContext, s
return nil
}
// only returns an empty string if no root dir can be found
func getRootDir(ctx *generate.GenerateContext) (string, error) {
if rootDir, _ := ctx.Env.GetConfigVariable("STATIC_FILE_ROOT"); rootDir != "" {
return rootDir, nil
rootDir = strings.TrimSpace(rootDir)
if rootDir != "" {
return rootDir, nil
}
}
staticfileConfig, err := getStaticfileConfig(ctx)
if staticfileConfig != nil && err == nil {
return staticfileConfig.RootDir, nil
rootDir := strings.TrimSpace(staticfileConfig.RootDir)
if rootDir != "" {
return rootDir, nil
}
}
if ctx.App.HasMatch("public") {
@@ -129,6 +143,17 @@ func getRootDir(ctx *generate.GenerateContext) (string, error) {
return "", fmt.Errorf("no static file root dir found")
}
func getIndexFallback(ctx *generate.GenerateContext) bool {
// TODO we probably want to add a ENV var for this config option in the future
config, err := getStaticfileConfig(ctx)
if config != nil && err == nil && config.IndexFallback != nil {
return *config.IndexFallback
}
return false
}
// convert a Staticfile in the app source into a struct that we can read options from
func getStaticfileConfig(ctx *generate.GenerateContext) (*StaticfileConfig, error) {
if !ctx.App.HasFile(StaticfileConfigName) {
return nil, nil
@@ -58,6 +58,24 @@ func TestGetRootDir(t *testing.T) {
want: "/custom/path",
expectError: false,
},
{
name: "trimmed env var",
path: "../../../examples/staticfile-index",
envVars: map[string]string{
"RAILPACK_STATIC_FILE_ROOT": " /custom/path ",
},
want: "/custom/path",
expectError: false,
},
{
name: "blank env var falls back",
path: "../../../examples/staticfile-index",
envVars: map[string]string{
"RAILPACK_STATIC_FILE_ROOT": " ",
},
want: ".",
expectError: false,
},
{
name: "from staticfile config",
path: "../../../examples/staticfile-config",
@@ -30,6 +30,7 @@ To configure more parts of the build, it is recommended to use a [config file](/
These environment variables affect the behavior of Railpack:
| Name | Description |
| :------------ | :------------------------------------------ |
| `FORCE_COLOR` | Force colored output even when not in a TTY |
| Name | Description |
| :---------------- | :------------------------------------------ |
| `FORCE_COLOR` | Force colored output even when not in a TTY |
| `RAILPACK_VERBOSE` | Enable verbose logging (equivalent to `--verbose` flag) |
@@ -29,13 +29,21 @@ The provider determines the root directory in this order:
### Staticfile
You can configure the root directory with a `Staticfile` that contains the contents:
You can configure static file serving with a `Staticfile` in your project
root:
```yaml
# root directory to serve
root: dist
# enable SPA routing: fall back to index.html for unmatched routes
index_fallback: true
```
`index_fallback` defaults to `false`. Enable it for single-page applications
(React, Vue, Angular, etc.) where the frontend router handles all routes and
the server should always serve `index.html` for unmatched paths.
### Config Variables
| Variable | Description | Example |
@@ -6,5 +6,13 @@
"expected": 200,
"internalPort": 80
}
},
{
// SPA index fallback: unmatched routes should return index.html so client-side router can handle them
"httpCheck": {
"path": "/nonexistent",
"expected": 200,
"internalPort": 80
}
}
]
+7
View File
@@ -1,4 +1,11 @@
[
{
"httpCheck": {
"path": "/invalid",
"expected": 404,
"internalPort": 80
}
},
{
"httpCheck": {
"internalPort": 80,
@@ -0,0 +1 @@
index_fallback: true
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<title>Railpack Static File (index fallback)</title>
<link rel="stylesheet" href="https://smol.jakerunzer.com/smol.css" />
</head>
<body>
<main>
<header>
<h1>Railpack Static File</h1>
<p>
This is a static file served by Railpack with index fallback enabled.
</p>
</header>
</main>
</body>
</html>
@@ -0,0 +1,19 @@
[
{
"httpCheck": {
"internalPort": 80,
"path": "/",
"expected": 200,
"expectedOutput": "Railpack Static File"
}
},
{
// non-existent routes should fall back to index.html
"httpCheck": {
"internalPort": 80,
"path": "/nonexistent",
"expected": 200,
"expectedOutput": "Railpack Static File"
}
}
]
+8
View File
@@ -9,5 +9,13 @@
"internalPort": 80,
"expectedOutput": "Railpack Static File"
}
},
// by default SPA mode / index fallback should not be enabled
{
"httpCheck": {
"path": "/invalid",
"expected": 404,
"internalPort": 80
}
}
]