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.
Outline 🧭
- Why this guide and what you’ll build
- Architecture overview (apps, Nginx, SSL, assets) 🔧
- Cloudflare SSL/TLS Full mode with Origin Certificates 🔐
- Server preparation (Ubuntu/Debian) 🖥️
- Nginx config that correctly serves public assets and proxies dynamic routes ⚡
- Building Svelte apps with Bun and Docker (two-stage) 🧱
- GitHub Actions matrix workflow for selective parallel builds 🚀
- Deployment flows: GitHub artifacts by ID and SSH push 📦
- Compose sketch and volumes for static assets 🗂️
- 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:
crmandsite(SvelteKit), each exposing:3000inside 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
publicvolume 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):
- In Cloudflare dashboard, create an Origin Certificate (PEM) and private key for your domain(s).
- Save them on your server as:
statusvnp/ssl/cf.crtstatusvnp/ssl/cf.key
- 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 /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 /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 returnCache-Control: public, immutable, max-age=31536000.
Troubleshooting (top hits):
- SSL mode wrong at Cloudflare (must be “Full”).
- Missing
publicvolume mounts → Nginx serves nothing. - Incorrect root paths in Nginx
location /→ use/var/www/<app>. - Forgetting to copy client assets into
dist/publicduring 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
- SvelteKit Documentation
- Docker Best Practices
- Nginx Configuration Guide
- Cloudflare SSL Documentation
Remember: Deployment is an ongoing process. Keep monitoring, updating, and improving your infrastructure as your application grows.
Happy deploying! 🚀