I inherited a legacy site a while back that was built almost entirely with static HTML files, but with a shared header and footer that needed updating across hundreds of pages whenever the navigation changed. Rebuilding it with a full framework felt like overkill for what it actually needed, and that’s exactly the situation Server Side Includes were built for decades ago and, honestly, still handle well today. In this article, I’ll walk through how to enable and configure SSI in Nginx, what it can and can’t do, and where it genuinely still makes sense in a modern stack.
What SSI Actually Is
Server Side Includes is a simple templating mechanism where the web server processes special comment-like directives embedded in HTML files before sending the response to the client. Instead of a browser ever seeing <!--# include virtual="/header.html" -->, the server replaces that directive with the actual content of header.html before the response goes out. The client just sees plain, assembled HTML.
This predates most modern templating and frameworks by a long way, but it’s still genuinely useful for lightweight content reuse, especially on largely static sites where pulling in a full templating engine or CMS would be disproportionate to the actual need.
What SSI Can and Can’t Do
SSI is deliberately limited. It can:
- Include the content of other files (
include) - Set and use simple variables (
set,echo) - Perform basic conditional logic (
if,elif,else,endif) - Display file metadata like last-modified dates (
flastmod,fsize) - Execute a subrequest to another URI, including dynamic content (
include virtual)
It cannot:
- Perform loops or complex logic
- Directly access a database
- Do string manipulation beyond very basic variable substitution
- Replace a real templating engine or backend framework for anything non-trivial
If you find yourself wanting more than basic includes and light conditionals, it’s a sign you’ve outgrown SSI and should look at a proper templating system or static site generator instead.
Requirements
- Nginx installed with the
ngx_http_ssi_module, which is included by default in most standard Nginx builds (unlike some other modules, this one rarely needs special compilation) - Static or semi-static HTML content where shared fragments (headers, footers, navigation, sidebars) are duplicated across many files
Confirm the module is available:
nginx -V 2>&1 | grep -o http_ssi_module
If it’s compiled in as a standard (non-dynamic) module, it may not show up explicitly in some -V outputs since it’s built in by default; check your distro’s Nginx package documentation if you’re unsure, or simply try enabling ssi on; and see if directives get processed — that’s the most reliable practical test.
Step 1: Enable SSI in Your Nginx Configuration
SSI needs to be explicitly turned on; it isn’t active by default even though the module is typically compiled in:
server {
listen 80;
server_name example.com;
root /var/www/example.com;
location / {
ssi on;
index index.shtml index.html;
}
}
By convention, files using SSI directives are often named with a .shtml extension to distinguish them from plain .html files, though this is a convention, not a strict Nginx requirement — you can enable SSI processing on .html files too if you configure the location block appropriately.
Step 2: Create Your Include Fragments
Let’s build out a simple example. First, a header fragment at /var/www/example.com/includes/header.html:
<header>
<nav>
<a href="/">Home</a>
<a href="/about.shtml">About</a>
<a href="/contact.shtml">Contact</a>
</nav>
</header>
And a footer fragment at /var/www/example.com/includes/footer.html:
<footer>
<p>© 2026 Example Company. All rights reserved.</p>
</footer>
Step 3: Reference Includes in Your Pages
Now, /var/www/example.com/index.shtml:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Example Company</title>
</head>
<body>
<!--#include virtual="/includes/header.html" -->
<main>
<h1>Welcome to Example Company</h1>
<p>This is the homepage content.</p>
</main>
<!--#include virtual="/includes/footer.html" -->
</body>
</html>
The virtual attribute specifies a path relative to the document root (or, more precisely, resolved the same way a normal HTTP request would be, going through Nginx’s usual URI processing — meaning it can even point at a dynamically generated resource, not just a static file). There’s also a file attribute for specifying a filesystem path directly, but virtual is generally preferred since it respects your Nginx configuration (rewrites, other location blocks, etc.) rather than bypassing it.
Step 4: Test and Reload
sudo nginx -t
sudo systemctl reload nginx
Request the page:
curl http://example.com/index.shtml
You should see the fully assembled HTML with the header and footer content inlined, and no trace of the <!--#include --> directive itself in the response.
Using Variables and Conditionals
SSI supports basic variable assignment and conditional logic, which can be genuinely useful for small amounts of dynamic behavior without a full backend.
<!--#set var="greeting" value="Welcome back" -->
<!--#if expr="$greeting" -->
<p><!--#echo var="greeting" -->, valued customer!</p>
<!--#else -->
<p>Welcome, guest!</p>
<!--#endif -->
You can also access built-in variables like the current date, client information, or server details:
<p>This page was last modified on <!--#flastmod file="index.shtml" -->.</p>
<p>You're connecting from <!--#echo var="REMOTE_ADDR" -->.</p>
Including Dynamic Content via Subrequests
One of SSI’s more powerful (and less commonly known) features is that include virtual can point at a genuinely dynamic backend resource, not just a static file. This effectively lets you compose a page from a mix of static HTML and dynamically generated fragments:
<!--#include virtual="/api/current-promotions.php" -->
Nginx will make an internal subrequest to that path, following your normal configuration for how that path is handled (proxied to PHP-FPM, another backend, etc.), and inline the resulting output into the page. This is a lightweight way to sprinkle small amounts of dynamic content into an otherwise static site without restructuring the whole thing around a framework.
Configuring SSI-Specific Directives
A few directives let you fine-tune SSI behavior:
location / {
ssi on;
ssi_silent_errors off;
ssi_min_file_chunk 1k;
ssi_value_length 256;
}
ssi_silent_errors off;— when an include fails (missing file, broken subrequest), Nginx normally inserts a visible error message ([an error occurred while processing the directive]) into the output, which is useful for catching broken includes during development. Set this toonin production if you’d rather fail silently and not expose broken-include messages to end users, though I’d generally recommend catching these during testing rather than hiding them in production.ssi_min_file_chunk— a performance tuning parameter controlling the minimum file size Nginx will handle in a certain internal buffering mode; the default is usually fine for most use cases.ssi_value_length— controls the maximum length of SSI parameter values; increase if you’re hitting truncation with longer variable content.
Combining SSI with Caching
Since SSI processing happens on every request (Nginx re-assembles the page dynamically each time, even though the source files are static), it does add a small amount of processing overhead compared to serving a fully pre-assembled static file. For high-traffic sites, you can combine SSI with Nginx’s proxy_cache (if fronting a backend that generates the SSI-tagged content) or simply rely on the fact that file-based SSI processing is quite fast and rarely the bottleneck compared to network latency.
If you want to avoid repeated SSI processing entirely for maximum performance, an alternative is to pre-render the final assembled HTML at build/deploy time (using a static site generator or a simple build script) rather than relying on Nginx to assemble it per-request. This trades flexibility (instant updates to shared fragments) for raw performance (no per-request assembly cost). For most small-to-medium sites, the SSI processing overhead is genuinely negligible, and I wouldn’t over-engineer around it unless you have actual data suggesting it’s a bottleneck.
Testing Thoroughly
- Verify basic include works:
curl http://example.com/index.shtml | grep -A2 "<nav>" - Test a broken include to confirm error visibility during development: Temporarily rename
header.htmland reload the page; you should see the[an error occurred while processing the directive]message ifssi_silent_errorsis off. - Test conditional logic by setting different variable values and confirming the correct branch renders.
- Test a dynamic subrequest include if you’re using one, confirming the backend resource’s output appears correctly inlined and that errors from that backend (like a 500) are handled the way you expect.
- Check response headers to make sure caching headers aren’t causing browsers or intermediate caches to serve a stale assembled page after you update a shared fragment:
curl -I http://example.com/index.shtml
Troubleshooting Common Issues
Problem: Include directives show up literally in the page instead of being processed.
This means ssi on; isn’t active for that location, or the file isn’t being served through a location where SSI processing applies. Double-check your location block scoping and confirm ssi on; is actually inherited by the block serving that file.
Problem: [an error occurred while processing the directive] appears where an include should be.
Check the file path in your virtual attribute — it should be relative to your document root and resolved the way a normal request would be. A common mistake is using a filesystem-style relative path when virtual expects a URI-style absolute path from the root.
Problem: Includes work for static files but not for a dynamic subrequest.
Confirm the target path is correctly handled by another location block (proxied to your backend, etc.) and that the backend itself is returning a successful response when hit directly. include virtual respects your normal Nginx routing, so anything broken about that route independently will also break here.
Problem: Changes to a shared fragment don’t appear to take effect.
Check for caching — either browser caching via Cache-Control/Expires headers, or an intermediate CDN or proxy_cache layer sitting in front of Nginx. SSI itself re-processes on every request by default, so if updates aren’t showing, the issue is almost always caching somewhere else in the chain, not SSI itself.
Security Considerations
- Be careful with
include virtualpointing at any path influenced by user input; if you’re dynamically constructing the include path from a query parameter or similar, you risk a local file inclusion-style vulnerability if not carefully validated. Never let user-supplied data determine which file gets included without strict allowlisting. - Keep
ssi_silent_errors off;during development so broken includes are caught early, but consider your production stance carefully — visible error messages can leak internal path structure to end users if left on in production without review. - If using dynamic subrequest includes that hit backend services, apply the same authentication and input validation you’d apply to any other request to that backend; SSI subrequests don’t inherently bypass your normal security controls, but it’s worth explicitly confirming this for your specific setup rather than assuming.
- Restrict who can edit shared include fragments (headers, footers, navigation) at the filesystem or deployment level, since a compromised or careless edit to a widely included fragment affects every page that includes it.
Performance Tips
- SSI processing overhead is small but non-zero; for very high-traffic static sites where every millisecond counts, consider pre-rendering fragments into final HTML at build time instead of relying on per-request SSI assembly.
- Keep included fragments small and focused (headers, footers, small dynamic snippets) rather than including large chunks of content repeatedly, which adds unnecessary I/O per request.
- If combining SSI with dynamic subrequest includes, remember that a slow backend behind an
include virtualwill slow down every page that includes it — apply the same timeout and performance discipline you would to any other proxied request. - Use
gzipcompression on the final assembled response as you would with any other text content, since SSI-generated pages are still standard HTML from the client’s perspective.
Real-World Use Cases
- Small static or mostly-static business sites where a shared header, footer, and navigation need centralized management without adopting a full CMS or framework.
- Legacy site maintenance where the existing codebase already uses SSI and a full rewrite isn’t justified by the actual needs of the site.
- Documentation sites with a large number of static pages sharing common navigation and layout elements, where a lightweight solution is preferable to a heavier static site generator toolchain.
- Hybrid static/dynamic pages, using
include virtualto sprinkle small amounts of dynamic content (like a promotions banner or a login status indicator) into an otherwise fully static page without restructuring the whole site around a backend framework.
Best Practices Summary
- Enable
ssi on;explicitly in the relevant location or server block; it’s not on by default even when the module is compiled in. - Use
virtualrather thanfilefor includes so they respect your normal Nginx routing and configuration. - Keep error visibility on during development (
ssi_silent_errors off;) to catch broken includes early. - Validate and restrict any dynamic path construction used in includes to avoid file inclusion risks.
- Consider pre-rendering at build time instead of relying on SSI if you’re dealing with very high traffic volumes and want to eliminate per-request assembly overhead entirely.
- Recognize SSI’s limits — once you need real logic, loops, or database access, it’s time to move to an actual templating system or backend framework.
Nesting Includes and Managing Larger Sites
For a larger static site, you’ll likely want to nest includes — a page including a layout wrapper, which itself includes a header and footer. SSI supports this naturally, since each included file is processed for its own SSI directives too:
<!-- layout.html -->
<!--#include virtual="/includes/header.html" -->
<div class="content">
<!--#include virtual="/includes/sidebar.html" -->
</div>
<!--#include virtual="/includes/footer.html" -->
<!-- index.shtml -->
<!--#include virtual="/includes/layout.html" -->
I’d caution against nesting too deeply, though. Once you’re three or four levels of includes deep, tracing where a particular piece of content actually lives becomes tedious, and this is usually the signal that a proper static site generator (like Hugo, Eleventy, or Jekyll) with real templating and build-time composition would serve you better than continuing to layer SSI includes. SSI shines for one or two levels of shared structure; beyond that, the maintenance burden starts to outweigh the simplicity it was originally chosen for.
Comparing SSI to Modern Alternatives
It’s worth being honest about where SSI sits relative to more modern options, since the landscape has changed a lot since SSI was originally designed:
- Static site generators (Hugo, Eleventy, Jekyll, Astro) do the equivalent composition at build time rather than per-request, which is both faster (no runtime processing cost at all) and more powerful (real templating languages, data files, loops, plugins). If you’re starting a new project from scratch, I’d lean toward one of these over SSI for anything beyond the simplest use case.
- Edge-side includes (ESI), a related but more powerful specification supported by some CDNs and caching layers, extend the SSI concept with more robust caching integration, though Nginx doesn’t natively support ESI without additional modules.
- Client-side includes via JavaScript (fetching and injecting header/footer HTML client-side) shift the work to the browser but add a flash-of-missing-content problem and an extra round trip, which SSI avoids entirely by assembling everything server-side before the response ever reaches the client.
Where SSI still wins, in my experience, is genuine simplicity for small, infrequently-changing static sites where introducing a build step or a JavaScript-based solution feels like more infrastructure than the actual problem warrants.
Frequently Asked Questions
Does SSI work with HTTPS?
Yes, SSI processing is entirely independent of whether the connection uses TLS. It operates on the response content before it’s sent, regardless of the transport-layer security configuration.
Can I use SSI alongside a reverse-proxied backend application, not just static files?
Yes — ssi on; can apply to a location block that proxies to a backend, and SSI directives embedded in that backend’s HTML response will still be processed by Nginx afterward, as long as the response’s Content-Type is one SSI processes (text/html by default; configurable via ssi_types). This is a genuinely useful pattern for injecting small amounts of static, centrally-managed content (like a site-wide banner) into pages otherwise generated by a dynamic backend.
Is there a performance cost to enabling SSI even on pages that don’t use any directives?
There’s a small parsing overhead since Nginx scans the response for SSI comment patterns, but for pages without any actual directives, this cost is negligible in virtually all practical scenarios. I wouldn’t worry about enabling ssi on; broadly out of performance concerns alone.
Can SSI directives be nested inside HTML comments so they’re invisible if SSI processing is somehow disabled?
They already are — the SSI directive syntax (<!--#include ... -->) is itself a valid HTML comment, which is precisely the design: if SSI processing isn’t enabled for some reason, browsers will simply render it as an ordinary, invisible HTML comment rather than displaying broken syntax to users. This graceful degradation is one of the more thoughtful aspects of the original SSI design.
What file extensions does SSI require?
None, strictly — Nginx doesn’t require .shtml; it processes SSI directives on any response served through a location where ssi on; is active and the content type matches ssi_types (which defaults to text/html). The .shtml convention exists mostly for human readability and historical convention from other web servers, not as a hard Nginx requirement.
Wrapping Up
Server Side Includes is an old technology, but “old” doesn’t mean “wrong for the job” — for genuinely static or mostly-static sites that just need shared headers, footers, and the occasional bit of dynamic content, SSI in Nginx does exactly what’s needed with almost no overhead or operational complexity. It’s not going to replace a real templating system or framework for anything beyond simple content reuse, and it shouldn’t try to. But for the specific, narrow problem of “I have the same header and footer duplicated across 200 static HTML files and I don’t want to touch all of them every time the navigation changes,” it’s still one of the simplest, most maintenance-free solutions available.