DEPLOY, WITHOUT THE GUESSWORK

How to Deploy Astro to a VPS (Static + SSR Options)

Deploy Astro sites to a VPS. Static output with Nginx, or SSR mode with Node.js. Both with HTTPS and a clean update workflow.

How to Deploy Astro to a VPS (Static + SSR Options)

You are going to deploy an Astro site to a VPS:

Choose static for blogs, docs, and marketing sites. Choose SSR when you need authentication, personalization, or API routes.

What you will have at the end

Step 1: Choose your output mode

Check your astro.config.mjs:

// Static (default) - generates HTML files
export default defineConfig({
  output: 'static',
})

// SSR - requires a Node.js server
export default defineConfig({
  output: 'server',
  adapter: node({ mode: 'standalone' }),
})

// Hybrid - static by default, opt-in SSR per page
export default defineConfig({
  output: 'hybrid',
  adapter: node({ mode: 'standalone' }),
})

For SSR/hybrid, install the Node adapter:

npx astro add node

Step 2: Create the Dockerfile

Static Mode Dockerfile

FROM node:20-alpine AS build
WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Create nginx.conf:

server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Gzip compression
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml;

    # Cache static assets aggressively
    location /_astro/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # Cache other static files
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 30d;
        add_header Cache-Control "public";
    }

    # SPA fallback for client-side routing
    location / {
        try_files $uri $uri/ $uri.html /index.html;
    }

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
}

SSR Mode Dockerfile

FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321

COPY --from=build /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./

USER node
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]

Step 3: Set up the VPS directory

mkdir -p ~/apps/astro-site
cd ~/apps/astro-site
git clone https://github.com/you/your-astro-site.git .

Step 4: Create docker-compose.yml

Static Mode Compose

services:
  traefik:
    image: traefik:v2.11
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --certificatesresolvers.letsencrypt.acme.tlschallenge=true
      - [email protected]
      - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
    ports:
      - 80:80
      - 443:443
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - letsencrypt:/letsencrypt
    restart: unless-stopped

  site:
    build: .
    restart: unless-stopped
    labels:
      - traefik.enable=true
      - traefik.http.routers.site.rule=Host(`your-domain.com`)
      - traefik.http.routers.site.entrypoints=websecure
      - traefik.http.routers.site.tls=true
      - traefik.http.routers.site.tls.certresolver=letsencrypt
      - traefik.http.services.site.loadbalancer.server.port=80

volumes:
  letsencrypt:

SSR Mode Compose

services:
  traefik:
    image: traefik:v2.11
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --certificatesresolvers.letsencrypt.acme.tlschallenge=true
      - [email protected]
      - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
    ports:
      - 80:80
      - 443:443
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - letsencrypt:/letsencrypt
    restart: unless-stopped

  site:
    build: .
    restart: unless-stopped
    environment:
      - HOST=0.0.0.0
      - PORT=4321
    labels:
      - traefik.enable=true
      - traefik.http.routers.site.rule=Host(`your-domain.com`)
      - traefik.http.routers.site.entrypoints=websecure
      - traefik.http.routers.site.tls=true
      - traefik.http.routers.site.tls.certresolver=letsencrypt
      - traefik.http.services.site.loadbalancer.server.port=4321

volumes:
  letsencrypt:

Step 5: Deploy

docker compose up -d --build

Verify deployment:

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

Check cache headers are working:

curl -I https://your-domain.com/_astro/some-file.css
# Should show: Cache-Control: public, immutable

Step 6: Optimize performance

For static sites, add Traefik compression:

  site:
    build: .
    labels:
      - traefik.enable=true
      - traefik.http.routers.site.rule=Host(`your-domain.com`)
      - traefik.http.routers.site.middlewares=compress
      - traefik.http.middlewares.compress.compress=true
      # ... other labels

For SSR, add health checks:

  site:
    build: .
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:4321/"]
      interval: 30s
      timeout: 10s
      retries: 3

Update workflow:

git pull
docker compose up -d --build

Troubleshooting

Build fails with memory errors

Astro builds can use significant memory. On small VPS instances:

# Add swap if needed
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile

# Or build locally and push the image
docker build -t your-registry/astro-site:latest .
docker push your-registry/astro-site:latest

404 errors on page refresh (static mode)

Your nginx config needs the SPA fallback:

location / {
    try_files $uri $uri/ $uri.html /index.html;
}

For Astro's default routing, $uri.html handles /about -> /about.html.

SSR server crashes on startup

Check your Astro config has the correct adapter:

import node from '@astrojs/node';

export default defineConfig({
  output: 'server',
  adapter: node({ mode: 'standalone' }),
})

And verify the entry point in your Dockerfile:

# Check what files were generated
docker compose exec site ls -la dist/server/

Environment variables not available

For SSR, pass env vars through Docker:

  site:
    build: .
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - API_KEY=${API_KEY}

Or use .env file:

  site:
    build: .
    env_file:
      - .env

Internal links (recommended next reads)


Related in the StoicSoft network

If you regularly stitch together PDF, image, video, or batch-file workflows like the ones above, 1FileTool is the StoicSoft network's purpose-built desktop app — 245+ local-first tools, pay-once, files never leave the device.

From across the StoicSoft network

Hand-curated reads on the same topic from sister sites in the StoicSoft family.