Deploy Astro to VPS with Docker and Caddy
This is the exact setup that runs brewcode.app. One VPS, multiple projects, a shared reverse proxy, and automated deploys from GitHub. No Kubernetes, no cloud-specific tooling, just Docker Compose and Caddy.
VPS Setup
Start with a clean Debian 13 (or Ubuntu 24) server. Install Docker and create an external network that all projects share:
curl -fsSL https://get.docker.com | sh
docker network create vps-proxy
The vps-proxy network lets containers from different Compose projects communicate with each other and with Caddy. Each project gets its own directory, its own docker-compose.yml, and its own Caddy site config.
Astro Dockerfile
Use a multi-stage build. The first stage installs dependencies and builds the site. The second stage copies the built output into a minimal Nginx image for serving.
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine AS runtime
COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080
Create a minimal nginx.conf alongside the Dockerfile:
worker_processes 1;
events { worker_connections 512; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
gzip on;
gzip_types text/css application/javascript text/html;
server {
listen 8080;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/index.html =404;
}
error_page 404 /404.html;
}
}
Docker Compose
Each project has its own Compose file. The key is connecting to the shared vps-proxy network:
services:
brewcode-site:
image: ghcr.io/your-org/your-site:${SITE_TAG:-latest}
container_name: brewcode-site
restart: unless-stopped
networks:
- vps-proxy
mem_limit: 128m
cpus: 0.25
networks:
vps-proxy:
external: true
The SITE_TAG variable comes from a .env file next to the Compose file. Update the tag, pull, and recreate to deploy a new version.
Caddy Reverse Proxy
Caddy handles TLS automatically via Let’s Encrypt. Install it as a Docker container with a shared config directory:
services:
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- ./sites:/etc/caddy/sites
- caddy_data:/data
- caddy_config:/config
networks:
- vps-proxy
mem_limit: 128m
cpus: 0.25
volumes:
caddy_data:
caddy_config:
networks:
vps-proxy:
external: true
The main Caddyfile imports per-site configs:
{
email your@email.com
}
import /etc/caddy/sites/*.caddy
Each project adds a .caddy file to the sites directory:
brewcode.app {
reverse_proxy brewcode-site:8080
}
www.brewcode.app {
redir https://brewcode.app{uri} permanent
}
Caddy resolves brewcode-site via the shared Docker network. No exposed ports needed on the project containers.
GitHub Actions: Build and Push
Create .github/workflows/site.yml to build and push the Docker image to GHCR on every tag:
name: Build Site
on:
push:
tags: ['site-v*']
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: ./site/main
push: true
tags: |
ghcr.io/${{ github.repository }}-site:${{ github.ref_name }}
ghcr.io/${{ github.repository }}-site:latest
GitHub Actions: Deploy
Create .github/workflows/deploy-site.yml to deploy to the VPS after a successful build:
name: Deploy Site
on:
workflow_run:
workflows: [Build Site]
types: [completed]
concurrency:
group: deploy-site
cancel-in-progress: false
jobs:
deploy:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to VPS
env:
VPS_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_HOST: ${{ secrets.VPS_HOST }}
run: |
echo "$VPS_KEY" > key && chmod 600 key
TAG="${{ github.event.workflow_run.head_branch }}"
ssh -i key -o StrictHostKeyChecking=no \
deploy@$VPS_HOST \
"cd /opt/brewcode-site \
&& echo SITE_TAG=$TAG > .env \
&& docker compose pull \
&& docker compose up -d \
&& docker exec caddy caddy reload \
--config /etc/caddy/Caddyfile"
rm key
Deploy Flow
The complete flow from code change to live site:
- Push a tag like
site-v1.2.3to GitHub - The build workflow builds the Docker image and pushes it to GHCR
- The deploy workflow SSHs into the VPS, updates
.env, pulls the new image, and recreates the container - Caddy reloads its config (handles new containers on the network)
- Zero downtime — Docker Compose recreates the container while the old one still serves traffic
The entire process takes about 2 minutes from tag push to live update. No manual SSH, no downtime, no coordination needed.