Static Service
The Static service serves static files (HTML, CSS, JavaScript, images, the build output of any frontend framework) through a pre-configured Nginx. You describe redirects, headers and CORS declaratively in zerops.yaml and Zerops generates the Nginx configuration for you.
Static or Nginx service?
Zerops offers two services for static content. Both run Nginx, but they are configured differently and support different zerops.yaml attributes:
| Static service | Nginx service | |
|---|---|---|
run.base | alpine/static or ubuntu/static | alpine/nginx@latest or ubuntu/nginx@latest |
| Nginx configuration | Generated from run.routing | Built-in default, or your own file via run.siteConfigPath |
| Redirects, custom headers, CORS | Declarative run.routing | Written by you in the Nginx configuration |
| Fallback for missing paths | /index.html (SPA friendly), plus extensionless .html pages | /index.html |
| Prerender.io for crawlers | Built in, enabled by PRERENDER_TOKEN | Not built in |
Reverse proxy (proxy_pass), caching, rate limiting, other ports | Not available | Anything Nginx can do, in your own configuration |
| Document root | run.routing.root | run.documentRoot |
Choose Static when you deploy framework build output or plain files and need at most redirects, headers and CORS. Choose Nginx when you need Nginx directives that routing cannot express, typically proxying to another service, response caching, or listening on additional ports.
run.routingis read only by the Static service. On any other service, including Nginx, it is silently ignored.run.documentRootis ignored by the Static service. Userun.routing.rootinstead.
You do not need an Nginx service just to send /api to a backend. Domain access routing on the project's L7 balancer maps public paths to services and ports, so a Static frontend and an API can share one domain.
Quick Start
Build your frontend with any runtime and hand the output to the Static service:
The ~ in dist/~ deploys the contents of dist directly into /var/www, so index.html ends up at the document root. If you deploy the folder as a whole (deployFiles: - dist), point the service at it with routing.root: dist.
If your files are already built, skip the build section and deploy them with zCLI.
The base carries the operating system: alpine/static or ubuntu/static. The bare shorthand static is accepted and means alpine/static.
How requests are served
The generated configuration serves files from the document root and resolves every request in this order:
- The exact path (
$uri) - The path with
.htmlappended ($uri.html), so/aboutservesabout.html index.htmlinside the directory ($uri/index.html), so/docsservesdocs/index.html/index.html, which makes client-side routing of Single Page Applications work- HTTP 404 if
/index.htmldoes not exist either
Other built-in behavior:
- The service listens on port 80 only. HTTPS is terminated on the Zerops balancer and forwarded as plain HTTP.
- Everything under
/.gitreturns 404. - Gzip compression is enabled for text-based content types.
- Prerender.io is wired in and becomes active once
PRERENDER_TOKENis set, see SEO with Prerender.
Single Page Applications work out of the box. No redirects are needed for client-side routing. A consequence of the fallback is that a request for a nonexistent path returns /index.html with status 200, not 404.
Document root
By default files are served from /var/www, the folder your deployFiles land in. To serve a subfolder, set run.routing.root. The path is relative to /var/www:
run.documentRoot has no effect on the Static service. It is only used by the Nginx and PHP services (and inside a custom .tmpl configuration).
Routing & Configuration
Configure redirects, headers and CORS in the run.routing section of your zerops.yaml:
Zerops turns this into location blocks inside the generated Nginx configuration. Every deploy regenerates the configuration, so routing changes take effect on the next deploy.
Path matching
The from field of a redirect and the for field of a header rule use the same matching rules:
- Without a wildcard (
/about) the rule matches that exact path only./about?x=1matches (the query string is not part of the path),/about/and/about/teamdo not. - With a trailing
*(/blog/*) the rule matches the path prefix./blog/,/blog/postand/blog/2024/post.htmlall match./blogwithout the trailing slash does not. *is only supported at the end of a path. Patterns such as/*.htmlor/*/editare not supported.- When several rules match, an exact rule wins over a prefix rule, and the longest matching prefix wins among prefix rules.
- Path rules apply to paths only. Redirects between domains use absolute redirects, which are evaluated before any path rule.
/* matches everything and is the rule to use when you want a header on all responses.
Redirects
Relative Redirects
Remember that SPA routing is already built into the default behavior. You don't need to add any custom redirects for client-side routing to work.
When both from and to are paths, the redirect is relative. Omitting status creates a masked redirect: the content of the target is served while the URL in the browser stays the same. With a status, the browser receives an HTTP redirect to the target:
routing:
redirects:
# Masked redirect - URL stays the same but shows content from about-us
- from: /about
to: /about-us
# Standard redirect with status code
- from: /old-page
to: /new-page
status: 301
# Preserve the path when redirecting between directories
- from: /blog/*
to: /articles/
preservePath: true
status: 302
# Preserve both path and query parameters
- from: /posts/*
to: /blog/
preservePath: true
preserveQuery: true
status: 302
statuscan be any redirect code, typically301,302,307or308.preservePathappends the part of the path after the wildcard toto./blog/*to/articles/redirects/blog/hello.htmlto/articles/hello.html. Endtowith a/, otherwise the result is/articleshello.html.preserveQueryappends the original query string. Without it the query string is dropped.- A masked redirect serves the target through the default rules, so
to: /about-usmay resolve toabout-us.htmlorabout-us/index.html. preservePathandpreserveQueryare not allowed on masked redirects. Setting them fails the deploy withPreserve path must not be set for masked redirects. A masked redirect for a prefix (from: /legacy/*) simply serves the sametofor every matching path.
Absolute Redirects
Use absolute URLs (http:// or https://) to redirect between domains or to an external site. Absolute redirects require a status.
A redirect with an absolute to and a path from works like a relative redirect, only the destination is external. Any status code is allowed:
A redirect with an absolute from matches on the domain of the request, which is why it only makes sense for domains that are pointed at this service. It is evaluated before any path rule and supports status 301 or 302 only:
routing:
redirects:
# Redirect an old domain to a new one, keeping the query string
- from: https://old-domain.com/*
to: https://new-domain.com
status: 301
preserveQuery: true
# Redirect with path preservation: https://old-site.com/blog/x -> https://new-site.com/blog/x
- from: https://old-site.com/*
to: https://new-site.com
status: 301
preservePath: true
- With an absolute
from,preservePathappends the complete request path (it always starts with/), so do not endtowith a/. - The domain match is a case-insensitive substring match on
host + path.https://old-domain.com/*therefore also matcheswww.old-domain.comand every other subdomain ofold-domain.com. https://*.old-domain.com/*matches subdomains ofold-domain.comonly, notold-domain.comitself.https://old-domain.com/without the trailing*matches the root path of that domain only.
Wildcard Matching
Use * as a wildcard:
- At the end of a path it matches the path prefix, see path matching.
- At the start of a domain in an absolute
from(https://*.domain.com/*) it matches all subdomains.
Example of domain management:
run:
routing:
redirects:
# Redirect a specific domain (and its subdomains) to an article
- from: https://promo-domain.com/*
to: https://main-site.com/special-offer
status: 302
# Redirect only subdomains of old-domain.com to the main site
- from: https://*.old-domain.com/*
to: https://main-site.com
status: 302
Matching Priority
Rules are matched in this order:
- Absolute redirects (matched on the request domain)
- Exact path rules (
fromwithout a wildcard) - Prefix rules (
fromending with*), longest prefix first - The default behavior for everything else
For example:
In this configuration:
/redirects to/homewith a 302 status/aboutshows content from/about-usbut keeps the URL as/about/about/and/about/teamdo not match the exact rule and use the default behavior/blog/post-123.htmlredirects to/articles/post-123.html- Any other path uses the default behavior
Common Redirect Patterns
Domain Migration
Use permanent (301) redirects when permanently moving content to maintain SEO value. Both preservePath and preserveQuery keep the visitor on the same page of the new domain.
Multiple Domain Management
run:
routing:
redirects:
# Product-specific domain
- from: https://product-promo.com/*
to: https://main-site.com/products
status: 302
# Campaign domain
- from: https://special-offer.com/*
to: https://main-site.com/campaign
status: 302
# Legacy subdomains
- from: https://*.legacy-domain.com/*
to: https://main-site.com
status: 302
Moving a section of the site
CORS Configuration
You can enable CORS for your static service by adding a cors directive:
The cors directive sets the following headers on every response, including redirects:
Access-Control-Allow-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-HeadersAccess-Control-Expose-Headers
All four headers receive the same value. If you need different values per header, set them individually with the headers directive instead.
The cors directive has a special case: if you specify just "*", it's automatically converted to '*'. For any other values, you need to include the proper Nginx syntax including quotes.
Custom Headers
For more control over HTTP headers, use the headers directive. The for field uses the same path matching as redirects, so use "/*" to cover the whole site. "/" alone would match the homepage only:
Header values are inserted directly into the Nginx configuration without additional quotes, which means:
- All values must include their own quotes (typically single quotes)
- If you need single quotes inside your header value, you must escape them in YAML (using double single quotes)
- To include the
alwaysdirective, add it after your quoted value - For complex values, you can use YAML's block scalar notation (
>-) for better readability
Here are examples for different header scenarios:
headers:
- for: "/*"
values:
# Simple header with proper quoting
X-Frame-Options: "'DENY'"
# Header with 'always' directive
X-XSS-Protection: "'1; mode=block' always"
# Header with internal single quotes - need double single quotes for escaping
Content-Security-Policy: '"default-src ''self'' https://cdn.example.com"'
# Complex header with block scalar notation for better readability
Content-Security-Policy: >-
"default-src 'self' https://cdn.example.com;
script-src 'self' 'unsafe-inline';
img-src * data:" always
When this configuration is processed, it translates to the following Nginx directives:
add_header X-Frame-Options 'DENY';
add_header X-XSS-Protection '1; mode=block' always;
add_header Content-Security-Policy "default-src 'self' https://cdn.example.com";
add_header Content-Security-Policy "default-src 'self' https://cdn.example.com; script-src 'self' 'unsafe-inline'; img-src * data:" always;
Headers are attached to the matched location, and only the single best-matching rule applies to a request. Rules are not merged:
- A request for
/docs/pagewith rules for/*and/docs/*receives only the/docs/*headers. Repeat the site-wide headers in the more specific rule if you need both. - A redirect defined in
redirectsdoes not pick up headers from a broader rule such as/*, only thecorsheaders. To add headers to a redirect response, add a header rule with the sameforpath as the redirect'sfrom. The two are merged. - A header rule for a path without a redirect serves files with the same default behavior as the rest of the site.
- Without
always, Nginx adds a header only to 2xx, 3xx and 304 responses, so usealwaysfor headers that must be present on error pages too.
Combining CORS and Custom Headers
You can use both CORS and custom headers together:
The cors directive sets default Access-Control headers for all routes, while the headers directive allows you to set additional headers for specific paths.
If you specify Access-Control headers in the headers directive, they will override the ones set by cors for that specific path.
SEO with Prerender
Single Page Applications render content with JavaScript, which most crawlers can't process—they see an empty page instead of your content. This affects traditional search engines, social media platforms, and AI tools like ChatGPT, Perplexity, and Claude.
The Static service includes built-in support for Prerender.io, which automatically detects crawlers (search engines, social media link previews, SEO tools and AI bots) and serves them pre-rendered HTML while your users get the full interactive experience. Requests for assets such as scripts, styles and images are never prerendered.
Setup
- Set the
PRERENDER_TOKENsecret variable with your Prerender.io token - Restart the service (or trigger a new deploy). The Nginx configuration is generated when a container starts, so it picks the token up on the next start
No changes to zerops.yaml are needed.
Custom Prerender Host
If you're using a custom Prerender host, add it to environment variables in zerops.yaml:
The default host is service.prerender.io if not specified.
Framework Integration
The Static service handles static builds from any modern framework. Here's the typical deployment pattern:
The key is pointing deployFiles to wherever your framework outputs its built files (dist/, build/, .output/public/, etc.). The trailing /~ deploys the folder's contents to the document root.
This configuration:
- Uses Node.js for building the application
- Installs dependencies and builds the application
- Deploys the resulting static files to the Static service
You can enhance this basic setup with:
- Custom redirects for URL management
- Prerender.io integration for SEO
- Additional routing rules as needed
For framework-specific examples, check out our recipe collection.
Advanced Topics
Custom Nginx configuration
The Static service also accepts your own Nginx configuration through run.siteConfigPath, the same attribute the Nginx service uses. The configuration is chosen with this precedence:
run.routingis set: the configuration is generated from it andsiteConfigPathis ignored- Only
run.siteConfigPathis set: your file is used as the completeserverconfiguration - Neither is set: the generated default configuration is used
A .tmpl file is rendered as a Go template with {{.DocumentRoot}} (the value of run.documentRoot, /var/www when unset) and {{.Environment.NAME}} for environment variables. Any other extension is copied verbatim. See the Nginx service guide for the requirements a custom configuration must meet.
If you find yourself writing a custom configuration, consider switching to the Nginx service. It is the same Nginx with documentRoot and siteConfigPath as first-class options, and it is what the rest of the documentation assumes for hand-written configurations.
Switching to Full Nginx
If you need more control over your Nginx configuration:
- Go to your Static service overview in the UI
- Click the three vertical dots in the left panel
- Select Need to switch to full Nginx service?
- Copy the generated Nginx configuration
- Use this configuration as a starting point for a full Nginx service
The same file is available inside any running container of the service at /etc/nginx/sites-enabled/default.site, for example over SSH.
To migrate, change run.base to alpine/nginx@latest (or ubuntu/nginx@latest), replace run.routing with run.siteConfigPath pointing at the copied configuration (with the root directive adjusted or replaced by {{.DocumentRoot}}), and remove routing, since the Nginx service ignores it. Prerender.io support is part of the generated configuration and will be carried over with it.
This allows you to move to a more customizable setup while maintaining your existing routing logic.
Complex Multi-Domain Setups
For advanced scenarios involving multiple domains and complex routing:
run:
routing:
redirects:
# Product-specific domain
- from: https://product-promo.com/*
to: https://main-site.com/products
status: 302
# Campaign domain
- from: https://special-offer.com/*
to: https://main-site.com/campaign
status: 302
# Legacy subdomains
- from: https://*.legacy-domain.com/*
to: https://main-site.com
status: 302
Complete Examples
Development Setup
Configuration for a development environment with CORS and an API on another domain:
This is a browser redirect, not a reverse proxy. The Static service cannot proxy requests. To serve an API under the same domain as the frontend, use domain access routing or the Nginx service with a proxy_pass configuration.
Production Setup with Security
Security-enhanced configuration for production environments: