Skip to content

Static Site with VitePress, Caddy, and Docker: A Step-by-Step Guide

This article walks through the full deployment process for a static site built with Vite. We'll write a multi-stage Dockerfile that builds an image containing the site's static files, and run it in a container with the Caddy web server, which automatically obtains and renews Let's Encrypt certificates.

What you'll need

  1. A server running Ubuntu with a public IP address (rented or at home)
  2. A domain name pointing to that IP address
  3. Docker with the Compose plugin installed on the server
  4. A project built with Vite
  5. A terminal

Project structure

We'll assume you already have a project that uses Vite. As an example, we'll use a simple documentation site built with VitePress. The Docker and Caddy configuration files live in the project root.

root
├── Caddyfile
├── Dockerfile
├── docker-compose.yaml
├── docs
│   ├── index.md
│   └── .vitepress
├── package-lock.json
└── package.json

Building the project

Running npm run docs:build generates the site in docs/.vitepress/dist (a plain Vite project uses npm run build and outputs to dist). The output contains index.html, the other HTML pages, and an assets folder with .js and .css files. No Node.js server is involved: the result is just static files.

This matters for Docker: you don't need Node.js in production, only a web server that can serve static files. In this example, we'll use Caddy, a modern web server written in Go that ships as a single, statically compiled binary with no external dependencies.

Why Caddy

  • Automatic HTTPS. Caddy obtains and renews Let's Encrypt certificates via the ACME protocol out of the box, with no need to set up Certbot. You just put your domain in the Caddyfile, and Caddy takes care of encryption. The only thing to remember is to keep Caddy's /data directory in a persistent volume, so certificates survive container restarts.
  • Simple configuration. The Caddyfile syntax is short and easy to read. Serving static files takes just two directives: root * /usr/share/caddy and file_server. The same setup in Nginx usually needs a few more lines.

On the downside, Caddy uses a bit more memory than Nginx, but for serving a static site the difference won't be noticeable.

Caddy configuration

Here's an example configuration with a domain, TLS, and caching:

# The domain comes from the .env file
{$DOMAIN} {
    # Email address used for Let's Encrypt registration
    tls {$EMAIL}

    # Where the site files are located
    # `*` means the rule applies to all requests
    root * /usr/share/caddy

    # Serve files from the root directory
    # MIME types are detected automatically
    file_server

    # Compress responses with gzip
    encode gzip

    # Cache static assets for one year
    header /assets/* Cache-Control "public, max-age=31536000, immutable"
    header /*.css Cache-Control "public, max-age=31536000, immutable"
    header /*.js Cache-Control "public, max-age=31536000, immutable"

    # Try the file, then the directory, and fall back to index.html
    try_files {path} {path}/ /index.html

    # Security headers
    header {
        # Require HTTPS for 2 years (63,072,000 seconds)
        Strict-Transport-Security "max-age=63072000"
        # Don't let the browser guess MIME types (prevents MIME sniffing attacks)
        X-Content-Type-Options "nosniff"
        # Legacy XSS filter header, ignored by modern browsers
        X-XSS-Protection "1; mode=block"
        # Don't allow the site to be embedded in frames (prevents clickjacking)
        X-Frame-Options "DENY"
    }
}

Dockerfile

Dockerfile
# Stage 1: build the site with Node.js
FROM node:24-slim AS builder

WORKDIR /app

# Install dependencies (this layer is cached until package*.json changes)
COPY package*.json ./
RUN npm ci

# Copy the sources and build the site
COPY docs ./docs
RUN npm run docs:build # Output: /app/docs/.vitepress/dist

# Stage 2: the final image with Alpine and Caddy
FROM alpine:3.23.5

# Install the Caddy web server
RUN apk add --no-cache caddy

# Copy the built site from the first stage into Caddy's default directory
COPY --from=builder /app/docs/.vitepress/dist /usr/share/caddy

# Copy the Caddy config
COPY Caddyfile /etc/caddy/Caddyfile

# Expose the HTTP and HTTPS ports
EXPOSE 80 443

# Start Caddy
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]

Only the final stage ends up in the image, so it contains Caddy and the static files, but no Node.js or node_modules.

docker-compose.yaml

yaml
services:
  # Service name (can be anything)
  caddy:
    build:
      # Build from the current directory
      context: .
      # Use the file named Dockerfile
      dockerfile: Dockerfile

    container_name: vitepress-site

    # Restart the container automatically if it crashes or the server reboots,
    # unless it was stopped manually
    restart: unless-stopped

    ports:
      # HTTP
      - "80:80"
      # HTTPS
      - "443:443"

    volumes:
      # Caddy data (certificates and other state)
      - caddy_data:/data
      # Caddy configuration state
      - caddy_config:/config

    environment:
      # Pass DOMAIN from the .env file
      - DOMAIN=${DOMAIN}
      # Pass EMAIL from the .env file
      - EMAIL=${EMAIL}

    networks:
      - web

volumes:
  caddy_data:
  caddy_config:

networks:
  # A bridge network (Docker's default network type)
  web:
    driver: bridge

The .env file

Before running Docker Compose, create a .env file in the project root. Compose reads it automatically on startup.

properties
DOMAIN=your-domain.com
EMAIL=you@example.com

Don't commit this file to Git.

Getting the code onto the server

The easiest way to get your project onto the server is through a Git hosting service like GitHub, GitLab, or Bitbucket.

Connect to the server over SSH:

bash
ssh user@your-server-ip

Install Git if it isn't already installed:

bash
sudo apt update && sudo apt install git -y # Ubuntu/Debian

Check that it works:

bash
git --version # git version 2.50.1

Clone the project and go to its root folder:

bash
git clone https://github.com/user/site.git && \
  cd site

Then create the .env file described above.

Running the site

In the project root, run:

bash
docker compose up --build -d

Make sure ports 80 and 443 are open in your firewall: Caddy needs them to obtain certificates and serve the site.

Checking that it works

From your own computer, send a request with curl:

bash
curl -I https://your-domain.com

If you get a 200 response, the site is up and HTTPS is working.

Summary

We've built a complete Docker-based setup for deploying a static site built with Vite (VitePress in our example). It includes a multi-stage build with Node.js and Caddy, automatic TLS certificates, and proper caching headers. I hope you found this article helpful.