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
- A server running Ubuntu with a public IP address (rented or at home)
- A domain name pointing to that IP address
- Docker with the Compose plugin installed on the server
- A project built with Vite
- 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.jsonBuilding 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
/datadirectory 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/caddyandfile_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
# 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
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: bridgeThe .env file
Before running Docker Compose, create a .env file in the project root. Compose reads it automatically on startup.
DOMAIN=your-domain.com
EMAIL=you@example.comDon'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:
ssh user@your-server-ipInstall Git if it isn't already installed:
sudo apt update && sudo apt install git -y # Ubuntu/DebianCheck that it works:
git --version # git version 2.50.1Clone the project and go to its root folder:
git clone https://github.com/user/site.git && \
cd siteThen create the .env file described above.
Running the site
In the project root, run:
docker compose up --build -dMake 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:
curl -I https://your-domain.comIf 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.