Blog

Complete VPS Setup for Svelte Apps: Cloudflare, Bun, Nginx

Production-ready guide for deploying Svelte/SvelteKit apps on a VPS with Docker, Nginx, Bun, and Cloudflare SSL Full mode using Origin Certificates. Includes selective parallel builds via GitHub Actions matrix and correct public asset serving.

Complete VPS Setup for Svelte Apps: Cloudflare, Bun, Nginx

Outline 🧭

  1. Why this guide and what you’ll build
  2. Architecture overview (apps, Nginx, SSL, assets) 🔧
  3. Cloudflare SSL/TLS Full mode with Origin Certificates 🔐
  4. Server preparation (Ubuntu/Debian) 🖥️
  5. Nginx config that correctly serves public assets and proxies dynamic routes ⚡
  6. Building Svelte apps with Bun and Docker (two-stage) 🧱
  7. GitHub Actions matrix workflow for selective parallel builds 🚀
  8. Deployment flows: GitHub artifacts by ID and SSH push 📦
  9. Compose sketch and volumes for static assets 🗂️
  10. Verification, troubleshooting, and handy commands ✅

1) Why this guide and what you’ll build

You want a smooth, production-grade path for deploying Svelte/SvelteKit apps to a VPS — with strong TLS, fast builds, and correct cache behavior for public assets. This guide delivers exactly that, and yes, we’ll keep it human-friendly 🙂.

What we’ll set up:

  • Two SvelteKit apps (example roles: CRM and Site) running on port 3000 each
  • Nginx as a reverse proxy and static asset server
  • Docker images built with Bun (no npm), two-stage Dockerfiles
  • Cloudflare SSL/TLS in Full mode using Origin Certificates (no Let’s Encrypt) 🔐
  • A GitHub Actions matrix workflow that only builds what changed (fast!)

You can absolutely run only one app — the pattern is the same.

2) Architecture overview 🔧

  • Applications: crm and site (SvelteKit), each exposing :3000 inside the container.
  • Reverse proxy: Nginx terminates TLS and serves static files from mounted volumes; proxies dynamic routes to the apps.
  • Storage: optional PostgreSQL shared DB; not required for the core setup.
  • Build & Ship: Docker images built by Bun + Vite builder; pushed as GitHub artifacts for controlled deployment (or streamed via SSH).
  • Static assets: client assets are copied into a dedicated public volume per app and served by Nginx with aggressive caching for immutable assets.

A simple mental model: Nginx handles TLS + assets; apps handle dynamic routes.

3) Cloudflare SSL/TLS: Full mode with Origin Certificates 🔐

We won’t use Let’s Encrypt here. Instead, set Cloudflare SSL/TLS → Full with Origin Certificates issued by Cloudflare. This gives end-to-end encryption: visitor → Cloudflare → your Nginx.

References: Cloudflare Origin Server docs (https://developers.cloudflare.com/ssl/origin-configuration/).

Steps (summary):

  1. In Cloudflare dashboard, create an Origin Certificate (PEM) and private key for your domain(s).
  2. Save them on your server as:
    • statusvnp/ssl/cf.crt
    • statusvnp/ssl/cf.key
  3. In Cloudflare, set SSL/TLS → “Full” (not “Flexible”, not “Full (strict)” unless you also configure strict chain correctly).

That’s it — Cloudflare will present its edge cert to the client, and connect to your Nginx over TLS using your origin cert.

4) Server preparation (Ubuntu/Debian) 🖥️

Run these once on a fresh VPS (summarized, copy-paste friendly):

sudo apt update && sudo apt -y upgrade && sudo apt -y autoremove

# Optional: reboot
# sudo reboot

# Basic firewall (UFW)
sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw enable

# New user, sudo, passwordless sudo (adjust <username>)
sudo adduser <username>
sudo usermod -aG sudo <username>
echo "<username> ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/<username>

# SSH hardening
sudo sed -i 's/^#?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sudo systemctl restart sshd

# Timezone
sudo timedatectl set-timezone Europe/Podgorica
sudo timedatectl set-ntp true

# Docker Engine + Compose plugin
sudo apt-get install -y curl ca-certificates
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo systemctl enable docker.service
sudo systemctl enable containerd.service
sudo usermod -aG docker $USER

# ufw-docker helper (optional but handy)
sudo wget -O /usr/local/bin/ufw-docker https://github.com/chaifeng/ufw-docker/raw/master/ufw-docker
sudo chmod +x /usr/local/bin/ufw-docker
sudo ufw-docker install --docker-subnets
sudo ufw reload

# Allow HTTPS to Nginx container path
sudo ufw route allow proto tcp from any to any port 443

# Folders
mkdir -p ~/statusvnp/{ssl,db-data,logs}
touch ~/statusvnp/{nginx.conf,site.env,crm.env}
touch ~/statusvnp/ssl/{cf.key,cf.crt}

Note: put your Cloudflare Origin cert/key contents into those cf.crt/cf.key files.

5) Nginx config: correct public assets + dynamic proxy ⚡

Key idea: serve client assets from Nginx volumes with proper caching (immutable where safe), and proxy dynamic routes to the app. Here’s a compact, production-friendly layout:

# Static assets with long-term caching
location /_app/immutable/ {
    add_header X-Robots-Tag "noindex" always;
    add_header Cache-Control "public, immutable, max-age=31536000" always;
    gzip_static on;
    try_files $uri =404;
}

# Application assets with no caching (safe by default)
location /_app/ {
    add_header X-Robots-Tag "noindex" always;
    add_header Cache-Control "no-cache" always;
    gzip_static on;
    try_files $uri =404;
}

# SvelteKit remote functions
location /_app/remote/ {
    try_files _ @backend;
}

# Main routes (example for CRM app)
location / {
    root /var/www/crm;  # or /var/www/site
    gzip_static on;
    try_files /client$uri /prerendered$uri.html @backend;
}

location @backend {
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_connect_timeout 3s;
    proxy_intercept_errors on;
    proxy_pass http://crm:3000;  # or http://site:3000

    # Maintenance fallback
    error_page 502 503 504 = @maintenance;
}

location @maintenance {
    root /usr/share/nginx/html;
    try_files /index.html =200;
    add_header Cache-Control "no-cache, no-store, must-revalidate";
    add_header Pragma "no-cache";
    add_header Expires "0";
}

Why this matters: immutable client chunks should be cached forever, while the rest stays flexible. And yes — gzip_static on; will pick up precompressed files when present.

6) Building Svelte apps with Bun and Docker 🧱

We’ll build with Bun, bundle the server to ESM, and copy client assets to a public folder that gets mounted into Nginx.

Example build script (CRM variant):

import { $, build, write } from 'bun';
import { version } from '../package.json';
import { createBuilder } from 'vite';

console.info('> Building...');
await $`rm -fr ./_dist ./dist`;
process.env.npm_package_version = version;

// Vite builder (SvelteKit)
const builder = await createBuilder({}, null);
await builder.buildApp();

console.info('> Bundling server...');
await build({
	entrypoints: ['./_dist/index.js'],
	outdir: './dist/',
	root: './_dist',
	target: 'bun',
	minify: true,
	format: 'esm',
	sourcemap: 'linked',
	packages: 'bundle'
});

// Copy client assets to public
await $`mkdir -p ./dist/public && cp -r ./_dist/client ./dist/public`;
await $`find ./dist/public -name '*.br' -type f -delete`;

// Entrypoint copies public into volume at container start
const entrypoint = `#!/bin/sh
set -e
rm -rf /home/bun/public/* /home/bun/public/.[!.]* /home/bun/public/..?*
cp -r /home/bun/app/public /home/bun/
echo ">> Updated public volume <<"
exec "$@"`;
await write('./dist/entrypoint.sh', entrypoint, { mode: 0o755 });
await $`rm -fr ./_dist`;

Dockerfile (two-stage, Bun only):

# syntax=docker/dockerfile:1
ARG BUN_VERSION=1.3.1

FROM oven/bun:${BUN_VERSION}-alpine AS builder
WORKDIR /home/bun/app
COPY package.json bun.lock ./
COPY packages ./packages/
COPY apps/crm/package.json ./apps/crm/package.json
RUN bun install
COPY apps/crm/ ./apps/crm/
RUN bun --filter 'crm' build

FROM oven/bun:${BUN_VERSION}-alpine AS production
ENV TZ=Europe/Podgorica
RUN apk add --no-cache tzdata
WORKDIR /home/bun/app
COPY --from=builder /home/bun/app/apps/crm/dist ./
RUN mkdir -p /home/bun/public && chown -R bun:bun /home/bun/public
USER bun
ENV PORT=3000
EXPOSE 3000
ENTRYPOINT ["sh", "./entrypoint.sh"]
CMD ["bun", "./index.js"]

Repeat for the Site app; copy ./_dist/prerendered into ./dist/public too if you prerender.

Maintenance-only Nginx image (for fallback):

# syntax=docker/dockerfile:1
ARG BUN_VERSION=1.3.1
ARG NGINX_VERSION=1.29.1

FROM oven/bun:${BUN_VERSION}-alpine AS builder
WORKDIR /html
COPY docker/nginx/maintenance.html maintenance.html
RUN mkdir -p ./out && bunx html-minifier-terser maintenance.html 
  --collapse-whitespace --remove-comments --minify-css --minify-js 
  -o ./out/maintenance.html

FROM nginx:${NGINX_VERSION}-alpine-slim
ENV TZ=Europe/Podgorica
RUN apk add --no-cache tzdata
COPY --from=builder /html/out/maintenance.html /usr/share/nginx/html/index.html

7) GitHub Actions: selective parallel builds (matrix) 🚀

The pipeline discovers which apps changed, then builds only those in parallel using docker/build-push-action with GHA cache. It uploads each image as a short-lived artifact and creates an “outcome” artifact per job for the notifier.

name: Monorepo Docker Build

on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: docker-${{ github.ref }}
  cancel-in-progress: true

jobs:
  discover:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
      has_targets: ${{ steps.set-matrix.outputs.has_targets }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 2 }
      - name: Get changed files
        id: changed
        run: |
          git diff --name-only HEAD~1..HEAD > changed.txt || true
          echo "changed<<EOF" >> $GITHUB_OUTPUT
          cat changed.txt >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT
      - name: Build matrix
        id: set-matrix
        shell: python
        run: |
          import json, os
          changed = [l for l in os.environ.get('CHANGED','').splitlines() if l.strip()]
          def touched(prefixes):
              if not changed: return False
              return any(any(c.startswith(p) for p in prefixes) for c in changed)
          targets = []
          if touched(["package.json","bun.lock","packages/","apps/crm/"]):
              targets.append({"name":"crm","dockerfile":"docker/Dockerfile.crm","tag":"statusvnp/crm:latest"})
          if touched(["package.json","bun.lock","packages/","apps/site/"]):
              targets.append({"name":"site","dockerfile":"docker/Dockerfile.site","tag":"statusvnp/site:latest"})
          if touched(["docker/Dockerfile.nginx","docker/nginx/maintenance.html"]):
              targets.append({"name":"nginx","dockerfile":"docker/Dockerfile.nginx","tag":"statusvnp/nginx:latest"})
          if os.environ.get('GITHUB_EVENT_NAME') == 'workflow_dispatch':
              targets = [
                {"name":"crm","dockerfile":"docker/Dockerfile.crm","tag":"statusvnp/crm:latest"},
                {"name":"site","dockerfile":"docker/Dockerfile.site","tag":"statusvnp/site:latest"},
                {"name":"nginx","dockerfile":"docker/Dockerfile.nginx","tag":"statusvnp/nginx:latest"}
              ]
          matrix = {"include": targets}
          with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
              f.write(f"matrix={json.dumps(matrix)}\n")
              f.write(f"has_targets={'true' if len(targets) > 0 else 'false'}\n")

  build:
    needs: discover
    if: ${{ needs.discover.outputs.has_targets == 'true' }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      max-parallel: 4
      matrix: ${{ fromJson(needs.discover.outputs.matrix) }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: docker/setup-buildx-action@v3
      - name: Build image ${{ matrix.name }}
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ${{ matrix.dockerfile }}
          platforms: linux/amd64
          tags: ${{ matrix.tag }}
          push: false
          load: false
          pull: true
          cache-from: type=gha,scope=${{ matrix.name }}
          cache-to: type=gha,mode=max,scope=${{ matrix.name }}
          outputs: type=docker,dest=${{ runner.temp }}/${{ matrix.name }}.tar
          build-args: |
            BUILDKIT_INLINE_CACHE=1
      - name: Upload ${{ matrix.name }} artifact
        uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.name }}
          path: ${{ runner.temp }}/${{ matrix.name }}.tar
          retention-days: 1
          compression-level: 9
          if-no-files-found: error
      - name: Create outcome
        if: ${{ !cancelled() }}
        run: |
          jq -cn \
            --arg name "${{ matrix.name }}" \
            --arg tag "${{ matrix.tag }}" \
            --arg status "${{ job.status }}" \
            '{"name":$name,"tag":$tag,"status":$status}' > ${{ runner.temp }}/outcome.json
      - name: Upload outcome
        uses: actions/upload-artifact@v4
        with:
          name: outcome-${{ matrix.name }}
          path: ${{ runner.temp }}/outcome.json

Use a follow-up notification job to gather these small “outcome” artifacts and post to Telegram.

8) Deployment flows 📦

Two supported flows:

  • Artifact ID deployment (from CI) — run a deploy script on the server to pull the image from GitHub artifacts by its ID and recreate the container:
ssh status "/home/g25/.bun/bin/bun /home/g25/deploy <artifact_id> crm"
ssh status "/home/g25/.bun/bin/bun /home/g25/deploy <artifact_id> site"
ssh status "/home/g25/.bun/bin/bun /home/g25/deploy <artifact_id> nginx"
  • SSH push deployment (from local) — stream a gzipped tar directly into the server script via stdin:
gzip -9c dist/crm.tar | ssh status "/home/g25/.bun/bin/bun /home/g25/deploy - crm"
gzip -9c dist/site.tar | ssh status "/home/g25/.bun/bin/bun /home/g25/deploy - site"
gzip -9c dist/nginx.tar | ssh status "/home/g25/.bun/bin/bun /home/g25/deploy - nginx"

Under the hood, the script either downloads the artifact by ID or reads from stdin (-), loads into Docker, and force-recreates the container. Cleanup removes unused images.

9) Compose sketch and volumes 🗂️

Keep it simple — mount app public folders into Nginx so static assets are served directly.

volumes:
  crm-static:
    driver: local
  site-static:
    driver: local

services:
  crm:
    image: statusvnp/crm:latest
    environment:
      - PORT=3000
    volumes:
      - crm-static:/home/bun/public
  site:
    image: statusvnp/site:latest
    environment:
      - PORT=3000
    volumes:
      - site-static:/home/bun/public
  nginx:
    image: statusvnp/nginx:latest
    ports:
      - '443:443'
    volumes:
      - crm-static:/var/www/crm:ro
      - site-static:/var/www/site:ro
      - ./statusvnp/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./statusvnp/ssl/cf.crt:/etc/ssl/certs/cf.crt:ro
      - ./statusvnp/ssl/cf.key:/etc/ssl/private/cf.key:ro

Ensure your Nginx config references those mount points and listens with the Cloudflare Origin cert (ssl_certificate and ssl_certificate_key).

10) Verification, troubleshooting, and handy commands ✅

Verification:

  • docker compose ps — are services up?
  • Hit https://your-domain — CF should show a valid cert; app should respond.
  • Check caching: files under /_app/immutable/ should return Cache-Control: public, immutable, max-age=31536000.

Troubleshooting (top hits):

  • SSL mode wrong at Cloudflare (must be “Full”).
  • Missing public volume mounts → Nginx serves nothing.
  • Incorrect root paths in Nginx location / → use /var/www/<app>.
  • Forgetting to copy client assets into dist/public during build.

Handy commands:

# Build images locally (linux/amd64)
docker buildx build --platform linux/amd64 --output type=docker,dest=dist/crm.tar -f docker/Dockerfile.crm -t statusvnp/crm:latest .
docker buildx build --platform linux/amd64 --output type=docker,dest=dist/site.tar -f docker/Dockerfile.site -t statusvnp/site:latest .

# After deploy
docker compose -f compose.yml ps
docker compose logs -f nginx

📚 Additional Resources

Remember: Deployment is an ongoing process. Keep monitoring, updating, and improving your infrastructure as your application grows.

Happy deploying! 🚀