This buildpack implements www hosting support for a static web app.
- Defines
project.tomlconfiguration,[com.heroku.static-web-server] - At build:
- Installs a static web server (currently Caddy).
- Inherits configuration from the Build Plan
[requires.metadata]of other buildpacks. - Transforms the configuration into native configuration for the web server.
- Optionally, runs a static build command.
- At launch, the default
webprocess:- Performs runtime app configuration,
PUBLIC_WEB_*environment variables are written into<head data-*>attributes of the default HTML file in the document root. - Starts the web server listing on the
PORT, using the server's native config generated during build. - Honors process signals for graceful shutdown.
- Performs runtime app configuration,
In the app source repo, add the buildpack to project.toml:
[[io.buildpacks.group]]
id = "heroku/static-web-server"Dynamic config used by the static web app at runtime, to support different app instances, such as a backend API URL that differs between Staging and Production.
These are set in the container's environment variables (Heroku Config Vars) and during CNB launch, written into the default HTML document. To access runtime app config, the javascript app's source code must read configuration values from the global document.head.dataset, HTML data-* attributes.
Do not set secret values into these environment variables. They will be injected into the website, where anyone on the internet can see the values. As a precaution, only environment variables prefixed with PUBLIC_WEB_ prefix will be exposed.
This feature parses and rewrites the HTML document. If the document's HTML syntax is invalid, the parser (Servo's html5ever) will correct the document using the same heuristics as web browsers.
This Runtime App Configuration feature can be disabled through Build-time Configuration.
Default: runtime config is written into public/index.html, unless document root or index document are custom configured.
For example, an app is started with the environment:
PUBLIC_WEB_API_URL=https://localhost:3001
PUBLIC_WEB_RELEASE_VERSION=v42
PORT=3000
HOME=/workspace
When the default HTML document is fetched by a web browser, loading the app, the PUBLIC_WEB_* vars can be accessed from javascript using the HTML Data Attribtes via document.head.dataset:
document.head.dataset.public_web_api_url
// → "https://api-staging.example.com"
document.head.dataset.public_web_release_version
// → "v42"
// Not exposed because not prefixed with PUBLIC_WEB_
document.head.dataset.port
// → null
document.head.dataset.home
// → nullThe variable names are case-insensitive, accessed as lowercase. Although enviroment variables are colloquially uppercased, the resulting HTML Data Attributes are set & accessed lowercased, because they are case-insensitive XML names.
For example, the public_web_api_url might be used for a fetch() call:
// If the PUBLIC_WEB_API_URL variable is not set, default to the production API host.
const apiUrl = document.head.dataset.public_web_api_url || 'https://api.example.com';
const response = await fetch(apiUrl, {
method: "POST",
// …
});Alternatively, default values can be preset in the HTML document's head element:
<html>
<!-- If the PUBLIC_WEB_API_URL variable is set, this value in the document will be overwritten -->
<head data-public_web_api_url="https://api.example.com">
<title>Example</title>
</head>
<body>
<h1>Example</h1>
</body>
</html> Then, the javascript does not need a default value specified.
const response = await fetch(document.head.dataset.public_web_api_url, {
method: "POST",
// …
});Static config that controls how the app is built, and how the web server delivers it.
This is set in the app source repo project.toml file and processed during CNB build. Rebuild is necessary to apply any changes.
The build process' environment may be configured by setting CNB Build variables in project.toml. These source-based variables are useful for standard configuration options that apply to any build of the app:
[[io.buildpacks.build.env]]
name = "CI"
value = "1"Build environment may also be set in the platform running the build, so that they are configured for deployments. To enhance security, Heroku doesn’t automatically expose config vars to the CNB build process. To get access to Heroku config variables at build-time, enable this feature.
[com.heroku.build.labs]
build_config_vars = trueDefault: (none)
This buildpack supports a executing a build command during CNB Build process. The output of this command is saved in the container image.
For apps built with Node.js, execution of the build command is typically handled automatically by heroku/nodejs CNB's build script hooks, and does not need to be configured here.
If your static web app is a static site generator built in a language other than JS, then you may need to configure the static site build command here. For example, Hugo written in Go:
[com.heroku.static-web-server.build]
command = "sh"
args = ["-c", "hugo"]Options may be passed to static build command using CNB Build variables in project.toml:
[[io.buildpacks.build.env]]
name = "HUGO_ENABLE_ROBOTS_TXT"
value = "true"When dependent on another language's compiled program like this, ensure that the app's buildpacks are ordered with heroku/static-web-server last, after the language buildpack.
[[io.buildpacks.group]]
id = "heroku/go"
[[io.buildpacks.group]]
id = "heroku/static-web-server"Default: true
The Runtime App Configuration feature may be disabled, such as when it is completely uneccesary or undesirable for a specific app.
[com.heroku.static-web-server.runtime_config]
enabled = falseDefault: the set index document, or else its default index.html
List of HTML files to rewrite with data-* attributes from Runtime App Configuration.
The files must be located within the document root, public/ by default.
[com.heroku.static-web-server.runtime_config]
html_files = ["index.html", "subsection/index.html"]* wildcards (globbing) are supported for websites that include many HTML files.
[com.heroku.static-web-server.runtime_config]
html_files = ["*.html"]Recursive globbing is also supported, for websites that include many HTML files nested within subdirectories.
[com.heroku.static-web-server.runtime_config]
html_files = ["**/*.html"]If a website contains an extremely large number (thousands) of globbed filenames, it's possible that the runtime configuration process could cause noticeable delays launching the web process.
Default: public
The directory in the app's source code to serve over HTTP.
[com.heroku.static-web-server]
root = "my_docroot"Default: index.html
The file to respond with, when a request does not specify a document, such as requests to a bare hostame like https://example.com.
[com.heroku.static-web-server]
index = "main.html"Default: (server's built-in headers)
Respond with custom headers for any request path, the wildcard *.
[com.heroku.static-web-server.headers."*"]
X-Server = "hot stuff"Respond with custom headers. These match exactly against the request URL's path.
# The index page (index.html is not specified in the URL).
[com.heroku.static-web-server.headers."/"]
Cache-Control = "max-age=604800, stale-while-revalidate=86400, stale-if-error=86400"
# HTML pages.
[com.heroku.static-web-server.headers."/*.html"]
Cache-Control = "max-age=604800, stale-while-revalidate=86400, stale-if-error=86400"
# Contents of a subdirectory.
[com.heroku.static-web-server.headers."/images/*"]
Cache-Control = "max-age=31536000, immutable"
# Set multiple headers for a match.
[com.heroku.static-web-server.headers."/downloads/*"]
Cache-Control = "public, max-age=604800"
Content-Disposition = "attachment"Default: (server's built-in errors)
Respond with a custom Not Found HTML page.
The path to this file is relative to the Document Root. The file should be inside the doc root.
[com.heroku.static-web-server.errors.404]
file_path = "error-404.html"Change the error response's HTTP status code.
Single-page app (SPA) client-side routing, where not found request URLs should respond with a single page (the app),
[com.heroku.static-web-server.errors.404]
file_path = "index.html"
status = 200Beyond pure static website delivery, some use-cases require dynamic server-side capabilities. This buildpack offers some server-specific configuration options, which tie the app to the specific server. Currently, only one web server is implemented: Caddy.
Default: not enabled
Per-request access logs may be enabled, sending them to stdout. These are normally disabled, because the Heroku router already emits request events to the app log. These access logs may be beneficial for other use-cases, running locally or on other hosts.
[com.heroku.static-web-server.caddy_server_opts.access_logs]
enabled = trueCaddy's log sampling may be configured as well, to reduce logging load on a high traffic server.
[com.heroku.static-web-server.caddy_server_opts.access_logs]
enabled = true
sampling_interval = 60_000_000_000 # sixty-seconds
sampling_first = 1000
sampling_thereafter = 1000Default: not enabled
Support for pretty, extensionless URLs, leaving .html off of the request path.
[com.heroku.static-web-server.caddy_server_opts]
clean_urls = trueFor example, a request to example.com/support will be tried in the document root:
- the literal path,
support - the path with HTML extension,
support.html - the path as a directory,
support/
Default: none
Define preset HTTP responses. Supports common use-cases such as: HTTP redirects, health check endpoint, or any custom status, headers, and body response.
[[com.heroku.static-web-server.caddy_server_opts.static_responses]]
# match the Host header indicated in request
host_matcher = "hostname.example.com"
# match the whole request path, supports `*` wildcards
path_matcher = "/resources/*"
# respond with HTTP status (default: 200)
status = 200
# respond with body content (default: none)
body = "I could be anything."
# set one or more response headers (default: none)
[com.heroku.static-web-server.caddy_server_opts.static_responses.headers]
"X-My-Header" = "Yells at cloud"
# For each additional static response, define another table…
[[com.heroku.static-web-server.caddy_server_opts.static_responses]]
# …host_matcher and path_matcher are both optional. At least one of them should be set for each static response. Static responses are processed in the order defined. When a static response is matched, its response is terminal. No further processing will occur for the request.
host_matcher matches HTTP requests' Host header, which typically requires that either:
- the DNS name resolves to the deployed app (such as Heroku custom domains)
- a CDN, proxy, or custom HTTP client set the
Hostheader to indicate the hostname being requested.
Caddy placeholders can be used for per-request dynamic values. The HTTP placeholders are useful with this feature:
{http.request.host}{http.request.uri}{http.request.uri.path}{http.request.uri.path.dir}{http.request.uri.path.file}{http.request.uri.query}
Permanently redirect to a different path with status 301 and a Location header, using the requested filename in the new path:
[[com.heroku.static-web-server.caddy_server_opts.static_responses]]
path_matcher = "/blog/*"
status = 301
[com.heroku.static-web-server.caddy_server_opts.static_responses.headers]
"Location" = "/feed/{http.request.uri.path.file}"Another example, permanently redirect any request for a specific host to a new server, passing through the original URI (path and querystring) and an additional custom header:
[[com.heroku.static-web-server.caddy_server_opts.static_responses]]
host_matcher = "original.example.com"
status = 301
[com.heroku.static-web-server.caddy_server_opts.static_responses.headers]
"Location" = "https://new.example.com{http.request.uri}"
"X-Redirected-From" = "original.example.com"[[com.heroku.static-web-server.caddy_server_opts.static_responses]]
path_matcher = "/health"
body = '{"status":"ok"}'
[com.heroku.static-web-server.caddy_server_opts.static_responses.headers]
"Content-Type" = "application/json"Default: not enabled
Password protect all requests to the web server using HTTP Basic Authorization.
[com.heroku.static-web-server.caddy_server_opts]
basic_auth = trueWEB_BASIC_AUTH_USERNAMEany name you wish, for examplevisitorWEB_BASIC_AUTH_PASSWORD_BCRYPTsee Generating hashed password
For example, to set username visitor and password geniuspass:
# As a local shell configuration
export WEB_BASIC_AUTH_USERNAME=visitor
export WEB_BASIC_AUTH_PASSWORD_BCRYPT="$(htpasswd -bnBC 10 "" geniuspass | tr -d ':\n')"
# As a Heroku App config
heroku config:set \
WEB_BASIC_AUTH_USERNAME=visitor \
WEB_BASIC_AUTH_PASSWORD_BCRYPT="$(htpasswd -bnBC 10 "" geniuspass | tr -d ':\n')"Without these env vars, the server will crash with an error:
provision http.authentication.providers.http_basic: account 0: username and password are required
WEB_BASIC_AUTH_DISABLED=true: after Basic Auth has been enabled at build-time using basic_auth = true, you may need to disable it later, at runtime, such as when a password-protected staging site is promoted to production.
# As a local shell configuration
export WEB_BASIC_AUTH_DISABLED=true
# As a Heroku App config
heroku config:set WEB_BASIC_AUTH_DISABLED=trueAs long as basic_auth = true, the required env vars WEB_BASIC_AUTH_USERNAME and WEB_BASIC_AUTH_PASSWORD_BCRYPT are still required to run the server.
Install htpasswd:
apt-get install apache2-utils # Debian/Ubuntu
yum install httpd-tools # RHEL/CentOS
brew install httpd # macOS
Use htpassword, for example:
htpasswd -bnBC 10 "" password | tr -d ':\n'
# Explanation:
# -b: batch mode (password on command line)
# -n: display on stdout instead of updating file
# -B: use bcrypt
# -C 10: cost factor of 10
# "": empty username (we just want the hash)
# tr -d ':\n': removes the colon and newlineDefault: false
Enables Caddy's server-side template rendering, to support per-request dynamic values.
To avoid stale content being displayed in browsers and served through CDNs, dynamic content may require different cache control headers than static files.
[com.heroku.static-web-server.caddy_server_opts]
templates = trueRequires: Templates enabled
Use CSP nonces by way of template tags in HTML files. In an HTML file where inline scripts should be allowed:
- Generate a nonce with
uuidv4 - Declare the nonce in a CSP header
- Set the nonce on script element
nonceattributes.
For example:
{{ $nonce := uuidv4 }}
{{ .RespHeader.Add "Content-Security-Policy" (print "nonce-" $nonce) }}
<!DOCTYPE html>
<html lang="en">
<head>
<script nonce="{{ $nonce }}">alert('Load me with a strict CSP')</script>
</head>
</html>Other buildpacks can return a Build Plan from detect for Static Web Server configuration.
Configuration defined in an app's project.toml takes precedence over this inherited Build Plan configuration.
This example sets root & index in the build plan, using supported configuration options:
[[requires]]
name = "static-web-server"
[requires.metadata]
root = "wwwroot"
index = "index.htm"Example using libcnb.rs:
fn detect(&self, context: DetectContext<Self>) -> libcnb::Result<DetectResult, Self::Error> {
let mut static_web_server_req = Require::new("static-web-server");
let _ = static_web_server_req.metadata(toml! {
root = "wwwroot"
index = "index.htm"
});
let plan_builder = BuildPlanBuilder::new()
.requires(static_web_server_req);
DetectResultBuilder::pass()
.build_plan(plan_builder.build())
.build()
}Build and run the server container image locally, or on any OCI-compatible host.
# Build the container image
pack build <APP_NAME> \
--builder heroku/builder:24 \
--path <SOURCE_DIR>
# Launch Web Server
docker run \
--env PORT=8888 -p 8888:8888 \
<APP_NAME>
# Interactively explore the container from a shell
docker run \
-it --entrypoint bash \
<APP_NAME>The static web server is configured to handle request URLs with the following path-matched precedence:
- [optional] Caddy: Basic Authorization
- [optional] Caddy: Static Responses (terminating)
- [optional] Caddy: Clean URLs
- exact URL path
- URL path +
.html(rewrite)
- File Server
- exact URL path
- for directories, URL path + default document
index.html