Serving files and static assets

Serve single files, directory trees, embedded filesystems, downloads, and static sites safely.

Celeris ships everything you need to serve files: single-file responses with Range support, traversal-safe directory serving, embedded filesystems (embed.FS), download/inline disposition helpers, a one-line Static router helper, and a full-featured static middleware for SPAs, directory listings, and pre-compressed assets.

This page is organised from the lowest-level primitive (Context.File) up to the highest-level convenience (the static middleware). When in doubt, jump to Choosing an approach.

Security up front. Context.File and Context.FileFromFS open the path you give them directly — they do not sanitise input. Whenever the path comes from the request (a route param, query string, header, or body), use Context.FileFromDir, the Static helper, or the static middleware — all of which clean the path, resolve symlinks, and re-check the directory boundary before opening anything.

Single-file serving — Context.File

Context.File(filePath string) error serves one file from the OS filesystem.

s.GET("/logo", func(c *celeris.Context) error {
    return c.File("./assets/logo.png")
})

What it does for you:

BehaviourDetail
Content-TypeDetected from the file extension via mime.TypeByExtension. Falls back to application/octet-stream when the extension is unknown.
Range requestsSets Accept-Ranges: bytes. A valid Range header produces a 206 Partial Content response with the correct Content-Range.
Zero-copyOn native engines that support it, the body is sent via sendfile(2) without copying through user space. Falls back to a buffered read otherwise (see note below).
Size capThe whole file is read into memory, capped at 100 MB. A larger file returns an HTTPError with status 413.

The sendfile(2) fast path is used transparently when the active engine’s response writer supports it. It is not used when response buffering or body capture is active for the request, or on engines/protocols that don’t implement it (in those cases Celeris falls back to a buffered read and write — the response is identical, just not zero-copy). You don’t configure any of this; it is chosen per request. See Engines for which engine you’re running.

Security: File opens the path verbatim. Never pass request-derived input to File. This is unsafe:

// ❌ DON'T: a request for /download/../../etc/passwd escapes your directory.
s.GET("/download/*name", func(c *celeris.Context) error {
    return c.File("./files/" + c.Param("name"))
})

Use FileFromDir for untrusted paths.

Files larger than 100 MB

File rejects files over 100 MB with a 413. To serve very large files (or generate a large body on the fly), stream it instead with Context.StreamWriter:

s.GET("/big.iso", func(c *celeris.Context) error {
    f, err := os.Open("/srv/images/big.iso")
    if err != nil {
        return celeris.NewHTTPError(404, "not found")
    }
    defer f.Close()

    c.SetHeader("content-type", "application/octet-stream")
    w := c.StreamWriter()
    if w == nil {
        return celeris.NewHTTPError(500, "streaming not supported")
    }
    _, err = io.Copy(w, f)
    return err
})

See Streaming, SSE & WebSocket for the full StreamWriter API.

Safe directory serving — Context.FileFromDir

Context.FileFromDir(baseDir, userPath string) error is the safe way to serve a file whose path comes from the request. It:

  1. Cleans userPath and joins it under baseDir.
  2. Rejects the request with 400 if the result escapes baseDir (e.g. via .. segments).
  3. Resolves symlinks and re-checks the prefix, so a symlink inside baseDir that points outside it can’t be used to escape.
  4. Returns 400 if the resolved path is a directory.
  5. Delegates to File for the actual response (so you keep Content-Type detection, Range/206, sendfile, and the 100 MB cap).
// Safe: traversal attempts get a 400 instead of leaking files.
s.GET("/files/*name", func(c *celeris.Context) error {
    return c.FileFromDir("./public", c.Param("name"))
})

A request for /files/../../etc/passwd returns 400 invalid file path rather than reading outside ./public. A request that resolves to a directory also returns 400FileFromDir serves files only, not listings (use the static middleware with Browse for listings).

Embedded filesystems — Context.FileFromFS

Context.FileFromFS(name string, fsys fs.FS) error serves a file from any fs.FS — most commonly an embed.FS baked into your binary, or os.DirFS.

import "embed"

//go:embed assets/*
var assets embed.FS

s.GET("/static/logo.png", func(c *celeris.Context) error {
    return c.FileFromFS("assets/logo.png", assets)
})

Behaviour mirrors File: Content-Type from the extension, the 100 MB cap (→ 413), and a 400 if name resolves to a directory.

DetailNote
Zero-copyOnly available when the fs.FS entry is backed by a real *os.File (e.g. os.DirFS). embed.FS and in-memory filesystems are read into memory, then written — no sendfile.
Path syntaxfs.FS paths use forward slashes, no leading /, and are relative to the embed root.

Security: FileFromFS does not sanitise name. It passes name straight to fsys.Open. For untrusted input over an fs.FS, prefer the static middleware with the FS field, which strips the prefix and cleans the path for you. (FileFromDir works on the OS filesystem, not on an arbitrary fs.FS.)

Downloads vs inline display

Two helpers set the Content-Disposition header to control how the browser treats the response. Call them before you write the body.

Context.Attachment(filename string) prompts a download:

s.GET("/reports/:id", func(c *celeris.Context) error {
    c.Attachment("report-2026.pdf")        // Content-Disposition: attachment; filename="report-2026.pdf"
    return c.FileFromDir("./reports", c.Param("id")+".pdf")
})

Context.Inline(filename string) suggests in-browser display:

c.Inline("invoice.pdf")                    // Content-Disposition: inline; filename="invoice.pdf"
return c.File("./invoices/0042.pdf")
HelperHeader setEffect
Attachment(name)Content-Disposition: attachment; filename="name"Browser downloads (Save As).
Attachment("")Content-Disposition: attachmentDownload, no suggested name.
Inline(name)Content-Disposition: inline; filename="name"Browser displays if it can.
Inline("")Content-Disposition: inlineInline, no suggested name.

Both helpers escape the filename for the quoted-string header value, so quotes and backslashes in the name are handled. If you build a Content-Disposition header yourself, use the same escaping with celeris.EscapeQuotedString(s string) string, which escapes \ and " per RFC 7230:

name := celeris.EscapeQuotedString(userName) // safe to embed in filename="..."

One-line static serving — Static

Server.Static(prefix, root string) *Route (and the identical RouteGroup.Static) registers a GET route at prefix + "/*filepath" that serves files from root via FileFromDir — so traversal protection is built in.

s := celeris.New(celeris.Config{Addr: ":8080"})
s.Static("/assets", "./public")
// GET /assets/css/app.css  →  ./public/css/app.css   (traversal-safe)

On a group, the group’s prefix is prepended as usual:

admin := s.Group("/admin")
admin.Static("/static", "./admin-ui")
// GET /admin/static/app.js  →  ./admin-ui/app.js

Static returns a *Route, so you can name it for reverse URLs (see Routing):

s.Static("/assets", "./public").Name("assets")
url, _ := s.URL("assets", "css/app.css") // "/assets/css/app.css"

Static is deliberately minimal: it serves existing files and nothing else. No directory index, no SPA fallback, no Cache-Control, no pre-compressed variants, no directory listing. Reach for the static middleware when you need any of those.

The static middleware

For richer static serving, use middleware/static. Unlike Static (a single route), the middleware runs in the chain: it serves a matching file or calls c.Next() to fall through to your routes when nothing matches. It handles GET and HEAD; all other methods fall through.

import (
    "time"

    "github.com/goceleris/celeris"
    "github.com/goceleris/celeris/middleware/static"
)

s := celeris.New(celeris.Config{Addr: ":8080"})
s.Use(static.New(static.Config{
    Root:   "./public",
    Prefix: "/",
    MaxAge: 24 * time.Hour,
}))

Serving an embedded filesystem instead of the OS filesystem:

//go:embed dist/*
var dist embed.FS

s.Use(static.New(static.Config{
    FS:     dist,
    Prefix: "/",
}))

static.Config reference

FieldTypeDefaultDescription
RootstringDirectory on the OS filesystem to serve from. Mutually exclusive with FS. One of Root/FS is required — the middleware panics at construction if neither is set.
FSfs.FSFilesystem to serve from (e.g. embed.FS). If both Root and FS are set, FS takes precedence.
Prefixstring"/"URL prefix stripped before file lookup. Prefix: "/static" serves /static/app.css from app.css. Matched at segment boundaries — /static does not match /static-docs. Must start with /.
Indexstring"index.html"File served when the request resolves to a directory.
BrowseboolfalseRender an HTML directory listing when a directory has no index file.
SPAboolfalseSingle-page-app mode: requests for non-existent files serve Index instead of falling through.
MaxAgetime.Duration0Sets Cache-Control: public, max-age=N. Zero means no Cache-Control header.
CompressboolfalseServe pre-compressed .br/.gz siblings when the client’s Accept-Encoding allows. Brotli is preferred over gzip. Works with both Root and FS.
Skipfunc(c *celeris.Context) boolnilSkip the middleware for requests where this returns true.
SkipPaths[]stringnilSkip the middleware for these exact request paths.

Caching and conditional requests

The middleware sets Last-Modified and a weak ETag (derived from mtime and size) on every file response, and honours If-None-Match / If-Modified-Since, returning 304 Not Modified when the client’s copy is fresh. Set MaxAge to add Cache-Control: public, max-age=N. See Configuration and the etag middleware in Middleware for related options.

Single-page apps

// dist/ contains index.html plus hashed JS/CSS bundles.
s.Use(static.New(static.Config{
    Root: "./dist",
    SPA:  true,  // unknown paths → index.html (client routing)
}))

With SPA: true, a request like /users/42 that has no matching file serves index.html so your client-side router can take over — instead of falling through to a 404.

MaxAge is applied uniformly to every file, including index.html. Setting a year-long MaxAge on a single middleware instance means index.html (the app shell) gets the same Cache-Control: public, max-age=31536000 as your hashed bundles. Once a browser or CDN caches the shell for a year, users won’t see new deploys until that TTL expires.

The typical pattern is to serve hashed assets (e.g. /assets/app-abc123.js) with a long max-age and index.html with no-cache or a short TTL. To do that, register two middleware instances — one for the assets subtree with a long MaxAge, and one for everything else with no MaxAge (or no-cache via a separate SetHeader route):

// Hashed assets under /assets — safe to cache for a year.
s.Use(static.New(static.Config{
    Root:       "./dist",
    Prefix:     "/assets",
    MaxAge:     365 * 24 * time.Hour,
}))

// App shell and everything else — always revalidate.
s.Use(static.New(static.Config{
    Root:   "./dist",
    SPA:    true,
    MaxAge: 0,  // no Cache-Control; browser will revalidate via ETag/Last-Modified
}))

Pre-compressed assets

Build app.js.br and/or app.js.gz next to app.js. With Compress: true, when a client sends Accept-Encoding: br, gzip the middleware serves the .br variant (preferring Brotli), sets Content-Encoding and adds Vary: Accept-Encoding. If no acceptable variant exists it serves the plain file. This works with both Root and FS.

Directory listings

s.Use(static.New(static.Config{
    Root:   "./downloads",
    Prefix: "/downloads",
    Browse: true,   // directories without an index render an HTML listing
}))

Listings HTML-escape names and URL-encode hrefs. As with file serving, symlinks are resolved and re-checked so a listing can’t escape the root.

Choosing an approach

You want to…Use
Serve one known fileContext.File
Serve a request-controlled path from a directoryContext.FileFromDir or Static
Serve a file from embed.FS at a fixed nameContext.FileFromFS
Mount a directory at a URL prefix, nothing fancyStatic
Index files, SPA fallback, caching, compression, or listingsstatic middleware
Force a download / control dispositionAttachment / Inline
Serve a file larger than 100 MBStreamWriter

Static helper vs static middleware

Static(prefix, root)static.New(Config{…})
FormA single named routeMiddleware in the chain
SourceOS directory onlyOS directory (Root) or fs.FS (FS)
Index fileNoYes (Index)
SPA fallbackNoYes (SPA)
Cache-Control / ETag / 304ETag/304 via File; no Cache-ControlFull (MaxAge, ETag, Last-Modified, 304)
Pre-compressed .br/.gzNoYes (Compress)
Directory listingNoYes (Browse)
Traversal-safeYes (FileFromDir)Yes

Common pitfalls

  • Passing request input to File or FileFromFS. Both open the path directly. Use FileFromDir, Static, or the middleware for anything the client controls.
  • The 100 MB cap. File, FileFromDir, FileFromFS, and the middleware all reject bodies over 100 MB with 413. Stream larger files with StreamWriter.
  • Setting disposition after writing. Call Attachment/Inline (and any SetHeader) before File/FileFromDir/FileFromFS writes the response.
  • Root and FS both set. The middleware uses FS and ignores Root. Set exactly one.
  • Prefix that isn’t a clean segment. Prefix must start with /, and it matches at segment boundaries: Prefix: "/static" serves /static/... but not /static-assets/....
  • Expecting FileFromDir to list directories. It returns 400 for a directory. Use the middleware with Browse: true for listings, or Index for an index file.
  • Missing zero-copy on embed.FS. sendfile(2) only applies to real *os.Files. embed.FS content is served from memory — correct, just not zero-copy.

FAQ

How is the Content-Type chosen? From the file extension via mime.TypeByExtension. If the extension is unknown, File/FileFromFS fall back to application/octet-stream; the middleware additionally sniffs the first bytes with http.DetectContentType.

Do range requests / resumable downloads work? Yes. File (and the helpers built on it) set Accept-Ranges: bytes and answer a valid Range header with 206 Partial Content and a Content-Range. The static middleware supports ranges too.

Can I serve a single-binary app with no files on disk? Yes — go:embed your assets and serve them with FileFromFS (fixed paths) or the static middleware’s FS field (a whole tree, with SPA/caching/compression).

Does serving a file set caching headers? File itself does not set Cache-Control/ETag. The static middleware sets ETag, Last-Modified, optional Cache-Control (via MaxAge), and answers conditional requests with 304. See Configuration.