Back to Blog
Deploy Astro to VPS with Docker and Caddy

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:

  1. Push a tag like site-v1.2.3 to GitHub
  2. The build workflow builds the Docker image and pushes it to GHCR
  3. The deploy workflow SSHs into the VPS, updates .env, pulls the new image, and recreates the container
  4. Caddy reloads its config (handles new containers on the network)
  5. 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.