This chapter shows you how to put a WebFluent site on the web. wf build
writes one folder, build/, and that folder is the whole site: any host
that serves static files can serve it, and there is no server process to
run.
The folder holds HTML, CSS, JavaScript, images, sitemap.xml,
robots.txt, _headers (the security headers, for hosts that read the
file), and a .gz file beside every text file. The same folder is also what
a desktop shell shows: Desktop apps makes a Tauri or
an Electron app of it.
Do these once, before the first deploy:
-
Set
meta.site_urlinwebfluent.app.jsonto the address the site will have. If the site lives under a sub-path (/docs), also setbuild.base_path. -
Decide between a static build and a single-page build (Static sites and SPAs). A single-page build needs the host to answer every path with
index.html. -
Build the site and load it in a real browser:
wf build wf verify
wf verifyloads every page in headless Chrome and fails on a script error, a failed request or a blank page.
A static build writes each page as its own index.html
(/contact is contact/index.html). A page whose path is * is written
to the root as 404.html, which is the file most static hosts serve for a
path they have no file for.
To build on the host's build machine, install wf and run the build. Every
recipe in this chapter uses these two lines:
curl -sSL https://raw.githubusercontent.com/monzeromer-lab/WebFluent/master/install.sh | bash
~/.webfluent/bin/wf buildPin a version so that a new release cannot change a deploy without you
knowing. Set WF_VERSION for the install script:
curl -sSL https://raw.githubusercontent.com/monzeromer-lab/WebFluent/master/install.sh | WF_VERSION=v5.12.0 bashYou can also build on your own machine and upload build/. It is only
files.
GitHub serves a project site under https://<you>.github.io/<repo>/, so the
build must know that sub-path.
-
In
webfluent.app.json, set"base_path": "/<repo>"inbuildand"site_url": "https://<you>.github.io/<repo>"inmeta. -
GitHub Pages answers
/contactwith a301redirect to/contact/. To make the canonical address of every page one it serves without a redirect, also set"clean_urls": "file"(or"directory") inbuild(Content). -
Add
.github/workflows/pages.yml:name: Deploy on: push: branches: [main] permissions: contents: read pages: write id-token: write jobs: deploy: runs-on: ubuntu-latest environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - uses: actions/checkout@v4 - run: curl -sSL https://raw.githubusercontent.com/monzeromer-lab/WebFluent/master/install.sh | WF_VERSION=v5.12.0 bash - run: ~/.webfluent/bin/wf build - uses: actions/upload-pages-artifact@v3 with: path: build - id: deployment uses: actions/deploy-pages@v4
-
In the repository's Settings → Pages, set the source to GitHub Actions.
-
Push to
main.
GitHub Pages serves 404.html for a path it has no file for. It ignores
_headers, but the Content-Security-Policy still ships as a <meta> tag in
every page.
Verify it: open the URL the workflow's deploy step prints, then open a
page other than the home page directly and reload it. With clean_urls set,
the address stays as you typed it, with no redirect.
-
Add
netlify.tomlat the project's root:[build] command = "curl -sSL https://raw.githubusercontent.com/monzeromer-lab/WebFluent/master/install.sh | bash && ~/.webfluent/bin/wf build" publish = "build"
-
For a single-page build, add
public/_redirectswith this line. The build copies it to the root ofbuild/:/* /index.html 200 -
Connect the repository in Netlify, or push to the branch it deploys.
Netlify reads _headers from the build, so the security headers apply with
no further setting.
Verify it: in the browser's network panel, select the page's document
request. Its response headers include Content-Security-Policy and
X-Content-Type-Options: nosniff, which came from _headers.
- In the Cloudflare Pages project, set the build command to the one in the
Netlify recipe above:
curl -sSL https://raw.githubusercontent.com/monzeromer-lab/WebFluent/master/install.sh | bash && ~/.webfluent/bin/wf build. - Set the build output directory to
build. - For a single-page build, add
public/_redirectsas for Netlify.
Cloudflare Pages reads _headers and _redirects the same way Netlify
does.
Verify it: check the response headers in the network panel, as for Netlify, and open a deep link directly.
-
Add
vercel.jsonat the project's root:{ "buildCommand": "curl -sSL https://raw.githubusercontent.com/monzeromer-lab/WebFluent/master/install.sh | bash && ~/.webfluent/bin/wf build", "outputDirectory": "build", "headers": [ { "source": "/(.*)", "headers": [ { "key": "X-Content-Type-Options", "value": "nosniff" }, { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, { "key": "X-Frame-Options", "value": "DENY" } ] } ] } -
Vercel does not read
_headers. Copy every header the build wrote inbuild/_headersintoheaders, including theContent-Security-Policyline. -
For a single-page build, add
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }].
Verify it: compare the response headers in the network panel with
build/_headers. Each header in the file should be there.
-
Add a
serverblock. Pick thetry_filesline for your kind of build:server { listen 443 ssl http2; server_name example.com; root /var/www/site; gzip_static on; # serve the .gz files the build wrote location / { try_files $uri $uri/ $uri.html =404; # a static build # try_files $uri $uri/ /index.html; # a single-page build } error_page 404 /404.html; location = /sw.js { add_header Cache-Control "no-cache"; } location ~* \.(webp|avif|jpg|jpeg|png)$ { add_header Cache-Control "public, max-age=31536000, immutable"; } add_header X-Content-Type-Options nosniff always; add_header Referrer-Policy strict-origin-when-cross-origin always; add_header X-Frame-Options DENY always; # add_header Content-Security-Policy "…as in build/_headers…" always; }
-
Upload the build with any tool. For example:
rsync -a --delete build/ server:/var/www/site/
-
Reload nginx.
Verify it: curl -I a page and check the headers. Ask for a compressed
copy and check that nginx sends the build's .gz file:
curl -sI -H "Accept-Encoding: gzip" https://example.com/app.jsThe response has Content-Encoding: gzip.
-
Put this in
public/.htaccess. The build copies it to the root ofbuild/:Options -MultiViews RewriteEngine On # A single-page build: # RewriteCond %{REQUEST_FILENAME} !-f # RewriteRule ^ /index.html [L] ErrorDocument 404 /404.html Header always set X-Content-Type-Options "nosniff" Header always set X-Frame-Options "DENY"
-
For a single-page build, uncomment the two rewrite lines.
-
Compress the text files. Either turn on
mod_deflate, which compresses on the way out, or serve the build's.gzfiles withMultiViews(in place of-MultiViews) andAddEncoding gzip .gz. -
Upload
build/to the site's document root.
Verify it: curl -I a page and check for X-Content-Type-Options and
X-Frame-Options. Open a path that does not exist and check that you get
the site's 404.html.
-
Add a site block to the
Caddyfile:example.com { root * /var/www/site encode gzip file_server { precompressed gzip } try_files {path} {path}/ {path}.html /index.html header X-Content-Type-Options nosniff }precompressed gzipserves the build's.gzfiles. The last entry oftry_files,/index.html, is the single-page fallback; remove it for a static build, so an unknown path is a 404. -
Upload
build/to/var/www/siteand reload Caddy.
Verify it: open a deep link directly and reload it, then check the
headers with curl -I.
-
Add a
Dockerfile. The first stage installswfand builds the site; the second serves it with nginx:FROM debian:stable-slim AS build RUN apt-get update && apt-get install -y curl ca-certificates RUN curl -sSL https://raw.githubusercontent.com/monzeromer-lab/WebFluent/master/install.sh | bash WORKDIR /site COPY . . RUN /root/.webfluent/bin/wf build FROM nginx:alpine COPY --from=build /site/build /usr/share/nginx/html
-
To use the settings from Serve with nginx, copy them into the second stage as
/etc/nginx/conf.d/default.conf. When TLS ends in front of the container, writelisten 80;in place of thelisten 443 ssl http2;line. -
Build and run the image:
docker build -t site . docker run -p 8080:80 site
Verify it: open http://localhost:8080 and a deep link, and check the
headers with curl -I http://localhost:8080/.
To let browsers keep files without serving a stale site, set
Cache-Control by the kind of file:
| Files | Cache-Control | Why |
|---|---|---|
HTML, app.js, pages/*, styles.css |
no-cache (revalidate every time) |
their names do not change between builds |
sw.js |
no-cache |
a stale worker holds a stale site (Offline) |
resized images (hero.<hash>.960.webp) |
public, max-age=31536000, immutable |
the name changes when the file does |
public/ files |
your choice | they keep the names you gave them |
no-cache still lets the browser keep a copy. The browser asks the server
each time, and a 304 Not Modified answer means it does not download the
file again.
With build.csp on, every page carries its Content-Security-Policy as a
<meta> tag. build/_headers holds the same policy, plus
X-Content-Type-Options, Referrer-Policy, X-Frame-Options and the
policy's frame-ancestors, which only work as real HTTP headers. This is
the file a build writes:
/*
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: DENY
Netlify and Cloudflare Pages read the file. On any other host, set the same headers as the recipes above show. Serve the site over HTTPS, and turn on HSTS once you are sure HTTPS works everywhere. Security has the full checklist.
To call a backend without CORS, serve the site and the backend from one
origin: the site at /, the backend at /api. A session cookie then works
with no extra setting. In nginx:
location /api/ {
proxy_pass http://127.0.0.1:8080/;
}Then api Backend(base: "/api") needs nothing else. Netlify, Vercel and
Cloudflare Pages do the same with a rewrite to an external URL.
After each deploy, check these:
- Open a deep link directly and reload it. A 404 means the host does not
fall back to
index.html(a single-page build), orbase_pathis wrong. - View a page's source. In a static build, the page's text is in the HTML.
- Check the response headers in the browser's network panel.
- Run a link-preview debugger on a page, and submit
sitemap.xmlto the search engines' consoles.