From ceb137d2664747a3d14d730cc598865e1373822d Mon Sep 17 00:00:00 2001 From: nfebe Date: Fri, 17 Jul 2026 14:42:59 +0100 Subject: [PATCH] feat(nginx): Cache static assets at the proxy, opt-in per domain A deployment can now let the proxy set a long browser cache on its static assets (styles, scripts, images, fonts). It is off by default and enabled per domain. The cache applies only to responses whose path has a static extension, keyed off the request path, so dynamic and API responses are untouched and keep whatever cache headers the app already sets. Turning it on serves a repeat visitor's static files from their browser instead of re-fetching them through the proxy. --- internal/nginx/manager.go | 23 +++++++++++++++++++++++ internal/nginx/manager_test.go | 28 ++++++++++++++++++++++++++++ pkg/models/deployment.go | 4 ++++ 3 files changed, 55 insertions(+) diff --git a/internal/nginx/manager.go b/internal/nginx/manager.go index e994fb4..5a6f6f8 100644 --- a/internal/nginx/manager.go +++ b/internal/nginx/manager.go @@ -44,11 +44,20 @@ const mapsConfigFile = "00-flatrun-maps.conf" // closing it, so nginx can reuse a pooled keepalive connection to the // container. Closing here would defeat upstream keepalive on every ordinary // request. WebSocket requests still get "upgrade". +// +// $flatrun_static_expires drives per-location static-asset caching. It keys off +// the request path's extension, so only static files get a long expiry; every +// other response maps to "off", which leaves the app's own Cache-Control intact. +// A location opts in by using it in an `expires` directive. const mapsConfigContent = `# Managed by FlatRun. Do not edit. map $http_upgrade $connection_upgrade { default upgrade; '' ""; } +map $uri $flatrun_static_expires { + default off; + ~*\.(?:css|js|mjs|json|png|jpe?g|gif|ico|svg|webp|avif|woff2?|ttf|otf|eot|map)$ 30d; +} ` // infraConfigFiles are conf.d files the agent manages that are not deployment @@ -772,6 +781,7 @@ func (m *Manager) groupDomainsByHost(domains []models.DomainConfig, deploymentNa StripPrefix: d.StripPrefix, OriginalPath: d.PathPrefix, ProxyTimeout: timeout, + StaticCache: d.StaticCache, }) if d.SSL.Enabled { @@ -909,6 +919,7 @@ type locationData struct { StripPrefix bool OriginalPath string ProxyTimeout int + StaticCache bool // Upstream is the value assigned to $upstream: an upstream block name when // keepalive is supported, otherwise the literal service:port. Upstream string @@ -1109,6 +1120,9 @@ server { proxy_connect_timeout 60s; proxy_send_timeout {{.ProxyTimeout}}s; proxy_read_timeout {{.ProxyTimeout}}s; +{{- if .StaticCache}} + expires $flatrun_static_expires; +{{- end}} {{- if $.SecurityEnabled}} log_by_lua_block { security.capture_event() @@ -1200,6 +1214,9 @@ server { proxy_connect_timeout 60s; proxy_send_timeout {{.ProxyTimeout}}s; proxy_read_timeout {{.ProxyTimeout}}s; +{{- if .StaticCache}} + expires $flatrun_static_expires; +{{- end}} {{- if $.SecurityEnabled}} log_by_lua_block { security.capture_event() @@ -1286,6 +1303,9 @@ server { proxy_connect_timeout 60s; proxy_send_timeout {{.ProxyTimeout}}s; proxy_read_timeout {{.ProxyTimeout}}s; +{{- if .StaticCache}} + expires $flatrun_static_expires; +{{- end}} {{- if $.SecurityEnabled}} log_by_lua_block { security.capture_event() @@ -1338,6 +1358,9 @@ server { proxy_connect_timeout 60s; proxy_send_timeout {{.ProxyTimeout}}s; proxy_read_timeout {{.ProxyTimeout}}s; +{{- if .StaticCache}} + expires $flatrun_static_expires; +{{- end}} {{- if $.SecurityEnabled}} log_by_lua_block { security.capture_event() diff --git a/internal/nginx/manager_test.go b/internal/nginx/manager_test.go index ce97f16..aa71e1c 100644 --- a/internal/nginx/manager_test.go +++ b/internal/nginx/manager_test.go @@ -2288,6 +2288,34 @@ func TestRouteOnlyAliasEmittedIntoServerName(t *testing.T) { } } +// Static-asset caching is opt-in per domain: the expires directive appears only +// when the domain enables it, so other domains keep their exact previous output. +func TestStaticCacheEmitsExpiresOnlyWhenEnabled(t *testing.T) { + m := NewManager(&config.NginxConfig{ContainerWebrootPath: "/var/www/html"}, "/deployments", "") + build := func(on bool) string { + dep := &models.Deployment{ + Name: "shop", + Metadata: &models.ServiceMetadata{ + Domains: []models.DomainConfig{ + {ID: "d1", Service: "web", ContainerPort: 80, Domain: "shop.example.com", StaticCache: on}, + }, + }, + } + out, err := m.generateMultiDomainConfig(dep) + if err != nil { + t.Fatalf("generateMultiDomainConfig failed: %v", err) + } + return out + } + + if on := build(true); !strings.Contains(on, "expires $flatrun_static_expires;") { + t.Errorf("expected the expires directive when static cache is on, got:\n%s", on) + } + if off := build(false); strings.Contains(off, "expires") { + t.Errorf("expected no expires directive when static cache is off, got:\n%s", off) + } +} + func newManagerWithDeployment(t *testing.T, domains []models.DomainConfig, compose string) (*Manager, *models.Deployment) { t.Helper() dir := t.TempDir() diff --git a/pkg/models/deployment.go b/pkg/models/deployment.go index f41f467..2fd6049 100644 --- a/pkg/models/deployment.go +++ b/pkg/models/deployment.go @@ -64,6 +64,10 @@ type DomainConfig struct { // when unset; raise it for domains that proxy long-lived WebSocket // connections so idle sockets are not closed mid-connection. ProxyTimeout int `yaml:"proxy_timeout,omitempty" json:"proxy_timeout,omitempty"` + // StaticCache opts this domain into a long browser cache for static assets + // (css, js, images, fonts). It applies only to responses whose path has a + // static extension; dynamic responses keep the app's own cache headers. + StaticCache bool `yaml:"static_cache,omitempty" json:"static_cache,omitempty"` } type DatabaseConfig struct {