Skip to content

Serving Static Files - Assets Without Server Logic

Table of Contents


The Static File Problem

Browsers need CSS , JS , images , and fonts. You could embed everything in template strings - but your server.js would balloon to 50K lines and every deploy would re-send the same bytes

Static file serving reads assets from disk and sends them with the right Content-Type and caching headers. Modern browsers cache aggressively - your job is to tell them how long

Manual Static File Server

const http = require('node:http')
const fs = require('node:fs')
const path = require('node:path')

const PUBLIC_DIR = path.resolve('public')
const MIME_TYPES = {
  '.html': 'text/html',
  '.css': 'text/css',
  '.js': 'application/javascript',
  '.json': 'application/json',
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.gif': 'image/gif',
  '.svg': 'image/svg+xml',
  '.ico': 'image/x-icon',
  '.woff': 'font/woff',
  '.woff2': 'font/woff2',
  '.pdf': 'application/pdf',
  '.txt': 'text/plain',
}

async function serveStatic(req, res) {
  // resolve the requested file path
  const urlPath = new URL(req.url, 'http://localhost').pathname
  const filePath = path.join(PUBLIC_DIR, urlPath)

  // path traversal check - resolves first
  const resolved = path.resolve(filePath)
  if (!resolved.startsWith(PUBLIC_DIR)) {
    res.writeHead(403)
    return res.end('Forbidden')
  }

  try {
    const stat = await fs.promises.stat(resolved)

    if (!stat.isFile()) {
      res.writeHead(404)
      return res.end('Not found')
    }

    const ext = path.extname(resolved) || '.html'
    const contentType = MIME_TYPES[ext] || 'application/octet-stream'

    res.writeHead(200, {
      'Content-Type': contentType,
      'Content-Length': stat.size,
      'Cache-Control': 'public, max-age=3600'
    })

    // stream the file - avoids loading entire file into memory
    const readStream = fs.createReadStream(resolved)
    readStream.pipe(res)
    readStream.on('error', () => {
      res.writeHead(500)
      res.end()
    })
  } catch (err) {
    if (err.code === 'ENOENT') {
      res.writeHead(404)
      return res.end('Not found')
    }
    res.writeHead(500)
    res.end('Server error')
  }
}

// integration into a server
const server = http.createServer((req, res) => {
  if (req.url.startsWith('/api/')) {
    // API routes handled separately
    res.writeHead(200, { 'Content-Type': 'application/json' })
    res.end(JSON.stringify({ status: 'api' }))
  } else {
    serveStatic(req, res)
  }
})

server.listen(3000)

express.static in one line (if you use Express):

app.use(express.static('public', {
  maxAge: '1d',
  etag: true,
  dotfiles: 'ignore'
}))

Caching Headers : Cache-Control , ETag , Last-Modified

The browser cache saves you bandwidth. These headers tell the browser how long to cache and when to revalidate

Cache-Control - the king of caching:

// aggressive caching for versioned assets
// (e.g. /js/app.v123.js)
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')

// moderate caching for regular content
res.setHeader('Cache-Control', 'public, max-age=86400')

// no caching for dynamic content
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
Directive Meaning
public Any cache can store (CDN , browser)
private Only browser cache (not CDN)
max-age=N Cache for N seconds
immutable Content never changes between refreshes
no-cache Must revalidate with server before using cached copy
no-store Never cache anything
must-revalidate Strict revalidation required

ETag - content-based fingerprint:

const crypto = require('node:crypto')

function generateETag(content) {
  return crypto.createHash('md5').update(content).digest('hex')
}

async function serveWithETag(req, res, filePath) {
  const content = await fs.promises.readFile(filePath)
  const etag = generateETag(content)

  // client sends If-None-Match with previous ETag
  if (req.headers['if-none-match'] === etag) {
    res.writeHead(304) // Not Modified - no body sent
    return res.end()
  }

  res.setHeader('ETag', etag)
  res.setHeader('Cache-Control', 'public, max-age=86400')
  res.setHeader('Content-Type', MIME_TYPES[path.extname(filePath)] || 'text/plain')
  res.end(content)
}

Last-Modified - timestamp-based revalidation:

async function serveWithLastModified(req, res, filePath) {
  const stat = await fs.promises.stat(filePath)
  const lastModified = stat.mtime.toUTCString()

  if (req.headers['if-modified-since'] === lastModified) {
    res.writeHead(304)
    return res.end()
  }

  res.setHeader('Last-Modified', lastModified)
  res.setHeader('Cache-Control', 'public, max-age=3600')
  // serve file...
}

ETag vs Last-Modified: Use both. ETag handles byte-for-byte comparison , Last-Modified provides fallback. Most CDNs and browsers handle them correctly

Directory Listing Risks

NEVER serve directory listings in production:

// DANGEROUS - this lists everything
const entries = await fs.promises.readdir(resolved)
res.end(entries.map(e => `<a href="${e}">${e}</a>`).join('<br>'))
// attacker now sees the full file tree

A directory listing exposes: * File names that reveal application structure (config.js, routes/, db/) * Backup files (config.js.bak, app.old.js) * Hidden files (.env, .git/config, .npmrc) * Upload directories with user files

Always reject directory requests:

async function serveStatic(req, res) {
  const filePath = path.resolve(path.join(PUBLIC_DIR, urlPath))
  const stat = await fs.promises.stat(filePath)

  if (stat.isDirectory()) {
    res.writeHead(403)
    return res.end('Directory listing denied')
  }

  // serve file...
}

Better - serve a default file (index.html) for directory paths:

if (stat.isDirectory()) {
  const indexPath = path.join(filePath, 'index.html')
  try {
    await fs.promises.access(indexPath)
    // redirect to /directory/
    res.writeHead(302, { 'Location': ensureTrailingSlash(urlPath) + 'index.html' })
    return res.end()
  } catch {
    res.writeHead(403)
    res.end('Forbidden')
  }
}

Security : Path Traversal and Config File Exposure

Path traversal in static servers is the classic web vulnerability:

// VULNERABLE - direct string concatenation
const filePath = '/var/www/' + req.url
// /var/www/../../../etc/passwd -> reads password file

The correct protection - resolve and verify:

const PUBLIC_DIR = path.resolve('/var/www/public')

function safePath(userPath) {
  // normalize and resolve
  const resolved = path.resolve(PUBLIC_DIR, '.' + userPath)

  // ensure it stays inside PUBLIC_DIR
  if (!resolved.startsWith(PUBLIC_DIR)) {
    return null
  }

  return resolved
}

// usage
const requestedPath = safePath(req.url)
if (!requestedPath) {
  res.writeHead(403)
  return res.end('Nice try , but no')
}

Blocking sensitive files:

const BLOCKED_PATTERNS = [
  /\.env$/,
  /\.git\//,
  /\.npmrc$/,
  /\.htaccess$/,
  /config\.(json|js|yml|yaml)$/,
  /composer\.json$/,
  /package\.json$/,
  /node_modules\//,
  /Dockerfile/,
  /docker-compose/
]

function isBlocked(filePath) {
  return BLOCKED_PATTERNS.some(pattern => pattern.test(filePath))
}

// in your server:
if (isBlocked(resolved)) {
  res.writeHead(403)
  return res.end('Access denied')
}

Symbolic link attacks - even if the path resolves inside PUBLIC_DIR , a symlink inside PUBLIC_DIR could point elsewhere:

async function checkSymlink(filePath) {
  const realPath = await fs.promises.realpath(filePath)
  return realPath.startsWith(path.resolve(PUBLIC_DIR))
}

// in your handler:
if (!(await checkSymlink(resolved))) {
  res.writeHead(403)
  return res.end('Symlink abuse detected')
}

dotfiles protection - files starting with . should be invisible:

function isDotFile(filePath) {
  const basename = path.basename(filePath)
  return basename.startsWith('.')
}

if (isDotFile(resolved)) {
  res.writeHead(404) // 404 , not 403 - dont reveal existence
  return res.end()
}

Complete secure static file handler:

async function secureStatic(req, res, publicDir = 'public') {
  const root = path.resolve(publicDir)
  const urlPath = new URL(req.url, 'http://localhost').pathname

  // resolve symlink-safe path
  let resolved
  try {
    resolved = await fs.promises.realpath(path.join(root, urlPath))
  } catch {
    res.writeHead(404)
    return res.end()
  }

  // containment check
  if (!resolved.startsWith(root)) {
    res.writeHead(403)
    return res.end()
  }

  // block hidden files
  if (path.basename(resolved).startsWith('.')) {
    res.writeHead(404)
    return res.end()
  }

  // reject directories
  const stat = await fs.promises.stat(resolved)
  if (!stat.isFile()) {
    res.writeHead(404)
    return res.end()
  }

  // serve
  const ext = path.extname(resolved)
  res.writeHead(200, {
    'Content-Type': MIME_TYPES[ext] || 'application/octet-stream',
    'Content-Length': stat.size,
    'Cache-Control': 'public, max-age=86400',
    'Last-Modified': stat.mtime.toUTCString()
  })

  fs.createReadStream(resolved).pipe(res)
}

prerequisites

web_06_templating.md - Template rendering , includes for hybrid static/dynamic pages

next => web_08_restful.md