How to Set Up Nginx for Server Side Includes (SSI)

How to Set Up Nginx for Server Side Includes (SSI)

How to Set Up Nginx for Server Side Includes (SSI)

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:

It cannot:

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

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>&copy; 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;
}

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

  1. Verify basic include works: curl http://example.com/index.shtml | grep -A2 "<nav>"
  2. Test a broken include to confirm error visibility during development: Temporarily rename header.html and reload the page; you should see the [an error occurred while processing the directive] message if ssi_silent_errors is off.
  3. Test conditional logic by setting different variable values and confirming the correct branch renders.
  4. 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.
  5. 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

Performance Tips

Real-World Use Cases

Best Practices Summary

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:

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.

Exit mobile version