How to Deploy SvelteKit on Ubuntu 24.04 VPS: Production Node.js Adapter, PM2, Nginx
SvelteKit is the full-stack framework built on top of Svelte — it gives you server-side rendering, file-based routing, form actions, and streaming data loading in a package that typically ships 30-40% less JavaScript than a comparable Next.js app. This guide walks through a production deployment of a SvelteKit application on an Ubuntu 24.04 VPS: from git clone to a hardened, HTTPS-enabled service running under PM2 behind Nginx with aggressive asset caching.
By the end you will have a SvelteKit app running as a clustered Node.js process on port 3000, fronted by Nginx with Let's Encrypt TLS, cached immutable assets, and proper environment-variable handling — the same blueprint used by production SvelteKit sites that serve millions of requests per month on a single VPS.
In a hurry? Provision the base OS with our CloudCore Starter VPS (EUR 7.99/month) and follow this guide end to end in about 30 minutes.
Table of Contents
Why Self-Host SvelteKit vs Vercel or Netlify?
Vercel and Netlify both offer first-class SvelteKit support via their respective adapters, and they are genuinely excellent for hobby projects and low-traffic marketing sites. The economics shift quickly once you are running a real product:
- Flat, predictable pricing — A CloudCore Starter VPS at EUR 7.99/month serves unlimited requests and unlimited bandwidth (fair-use capped). Vercel Pro starts at USD 20/user/month with metered function invocations, bandwidth overages, and edge-middleware execution charges that compound fast.
- No cold starts — SvelteKit on
adapter-nodebehind PM2 keeps your Node process warm 24/7. Serverless platforms spin functions down after inactivity, adding 300-1500 ms latency to the first request after idle. - Long-lived connections — Serverless platforms cap function duration (10-60 seconds typically). If you need WebSockets, Server-Sent Events, long polling, streaming AI responses, or background jobs, a VPS is the right shape.
- Control over Node runtime — Pin any Node version, install native modules (
sharp,bcrypt,@node-rs/*) without worrying about AWS Lambda layer limits, and swap Node versions with a single NodeSource command. - Data residency — Choose an EU datacenter and keep all processing (logs, form submissions, database) inside one jurisdiction. Vercel's global edge runtime routes through whichever region is closest to the user.
- No vendor lock-in —
adapter-nodeproduces a plain Node.js server. Move it between VPS providers, container orchestrators, or back to a laptop in minutes. - Better unit economics at scale — A CloudCore Professional (6 vCPU, 12 GB RAM) handles 500-1000+ rps of server-rendered SvelteKit routes for EUR 19.99/month. Matching that throughput on Vercel's pay-per-request pricing easily runs into the hundreds of dollars per month.
Prerequisites
Before starting, make sure you have:
- An Ubuntu 24.04 LTS VPS with root or sudo access
- SSH access (built-in terminal on macOS/Linux, PuTTY or Windows Terminal on Windows)
- A domain name with a DNS A record pointing to your VPS IP (for HTTPS in Step 10)
- At least 1 GB of RAM (2 GB+ recommended for builds; SvelteKit's Vite build can be memory-hungry)
- 2 GB of free disk space for Node.js, dependencies, and your build output
- Basic familiarity with the Linux command line
Recommended Plan: CloudCore Starter>
For a single SvelteKit app with a modest database backend, the CloudCore Starter plan is the sweet spot:>
- 2 vCPU cores
- 4 GB RAM
- 50 GB NVMe SSD
- Unmetered bandwidth
- EUR 7.99/month>
That gives you plenty of headroom for npm run build, two or three clustered Node workers under PM2, and Nginx with room for a small PostgreSQL or Redis instance on the same box. Scale up to CloudCore Professional when traffic justifies it.If you have not yet set up Node.js or Nginx, follow our dedicated guides first:
Connect via SSH to begin:ssh root@your-server-ipStep 1: Prepare the Ubuntu 24.04 VPS
Update your package index and install the build essentials SvelteKit and native npm modules need:
sudo apt update && sudo apt upgrade -y
sudo apt install -y build-essential git curl ufwCreate a non-root user to own the application (never run Node as root in production):
sudo adduser --disabled-password --gecos "" deploy
sudo usermod -aG sudo deploy
sudo mkdir -p /home/deploy/.ssh
sudo cp ~/.ssh/authorized_keys /home/deploy/.ssh/
sudo chown -R deploy:deploy /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keysEnable the firewall, allowing SSH and both HTTP (for Certbot challenges) and HTTPS:
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw --force enable
sudo ufw statusExpected output:
Status: active
To Action From -- ------ ---- OpenSSH ALLOW Anywhere Nginx Full ALLOW Anywhere
From here on, work as the deploy user:
su - deployStep 2: Install Node.js 20 LTS
SvelteKit requires Node.js 18.13+ and officially recommends the latest LTS release. We will install Node.js 20 from NodeSource, which tracks upstream releases and ships with npm, npx, and corepack.
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejsVerify the install:
node --version
npm --versionExpected output:
v20.18.1
10.8.2Enable corepack so pnpm and yarn are available when needed (SvelteKit's create-svelte supports all three):
sudo corepack enableFor a deeper dive into Node.js install options (nvm, fnm, binary builds), see our Node.js on Ubuntu guide.
Step 3: Create or Clone Your SvelteKit App
You have two paths here. If you already have a SvelteKit repo, clone it:
cd ~
git clone https://github.com/yourorg/your-sveltekit-app.git app
cd app
npm ciIf you are starting fresh, scaffold a new project with the official initializer:
cd ~
npm create svelte@latest appThe interactive prompt will ask for:
Which Svelte app template? › SvelteKit demo app
Add type checking with TypeScript? › Yes, using TypeScript syntax
Select additional options: ›
◉ Add ESLint for code linting
◉ Add Prettier for code formatting
◯ Add Playwright for browser testing
◉ Add Vitest for unit testingThen install dependencies:
cd app
npm installConfirm the dev server works (we will not use it for production, but it is a useful smoke test):
npm run dev -- --host 0.0.0.0 --port 5173Visit http://your-server-ip:5173 in your browser. You should see the SvelteKit welcome page. Stop the dev server with Ctrl+C.
Step 4: Configure adapter-node
SvelteKit adapters transform your app into the target environment's expected format. For a VPS, you want @sveltejs/adapter-node, which outputs a standalone Node.js server with its own HTTP handler.
Install it:
npm install --save-dev @sveltejs/adapter-nodeEdit svelte.config.js to use the Node adapter:
import adapter from '@sveltejs/adapter-node'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';/* @type {import('@sveltejs/kit').Config} / const config = { preprocess: vitePreprocess(), kit: { adapter: adapter({ out: 'build', precompress: true, envPrefix: 'APP_' }) } };
export default config;
Each option matters in production:
out: 'build'— Directory where the adapter writes the compiled server. Default isbuild.precompress: true— Ahead-of-time gzip and Brotli compression for static assets. Nginx serves the.gzand.brvariants directly viagzip_static/brotli_static— zero CPU at request time.envPrefix: 'APP_'— Optional prefix for public environment variables exposed to the browser. Only vars starting with this prefix are accessible via$env/static/public.
Step 5: Environment Variables with $env/static/private
SvelteKit has a four-quadrant environment variable model that enforces the build-time vs runtime and public vs private split at the type system level. Understanding it prevents accidentally leaking secrets to the browser bundle.
| Module | When read | Where visible | Use for |
|---|---|---|---|
$env/static/private | Build time | Server only | Secrets baked in at build (API keys committed to env at deploy time) |
$env/dynamic/private | Runtime | Server only | Secrets that must rotate without rebuilding |
$env/static/public | Build time | Server + browser | Build-time constants safe to ship to client (feature flags, public keys) |
$env/dynamic/public | Runtime | Server + browser | Runtime values readable by client JS |
.env file at the project root (never commit it):cat > ~/.env <<'EOF'
DATABASE_URL=postgres://app:secret@localhost:5432/appdb
SESSION_SECRET=change-me-to-64-random-bytes
PUBLIC_STRIPE_KEY=pk_live_xxx
PUBLIC_SITE_URL=https://app.example.com
EOFUse them in a server-only file (src/lib/server/db.ts):
import { DATABASE_URL, SESSION_SECRET } from '$env/static/private'; import { PUBLIC_SITE_URL } from '$env/static/public';import postgres from 'postgres';
export const sql = postgres(DATABASE_URL, { max: 10 }); export const cookieDomain = new URL(PUBLIC_SITE_URL).hostname;
If you import $env/static/private from a file that could end up in the browser bundle, SvelteKit will fail the build with a loud error — this is the point of the static split.
At runtime, three environment variables control adapter-node itself:
HOST— interface to bind (default0.0.0.0)PORT— port to listen on (default3000)ORIGIN— the public URL of your app (e.g.https://app.example.com). Required for CSRF protection on form actions and forurl.originto be accurate behind a reverse proxy.
Step 6: Build the Production Bundle
Run the SvelteKit build. This invokes Vite to bundle your app, runs the adapter, and writes output to build/:
cd ~/app
npm run buildExpected output (abbreviated):
vite v5.4.11 building SSR bundle for production... ✓ 127 modules transformed. .svelte-kit/output/client/_app/immutable/entry/start.DpN7a2Qk.js 42.17 kB │ gzip: 16.88 kB .svelte-kit/output/client/_app/immutable/entry/app.Bq3V9lkz.js 180.11 kB │ gzip: 60.42 kB ✓ built in 8.42sRun npm run preview to preview your production build locally.
> Using @sveltejs/adapter-node ✔ done
Inspect the output:
ls build/client/ index.js server/ handler.js env.js shims.jsbuild/index.js— entry point that starts the HTTP serverbuild/handler.js— express/polka-compatible middleware (useful for mounting SvelteKit inside a custom server)build/client/_app/immutable/— content-hashed static assets (JS, CSS, fonts). Safe to cache forever.build/server/— server-rendered route handlers
HOST=0.0.0.0 PORT=3000 ORIGIN=http://your-server-ip:3000 node buildHit http://your-server-ip:3000 — you should see your app rendered server-side. Stop with Ctrl+C.
Step 7: Run SvelteKit Under PM2 Cluster Mode
PM2 is a Node.js process manager that handles clustering (one worker per CPU core), log rotation, automatic restarts, zero-downtime reloads, and boot-time startup.
Install it globally:
sudo npm install -g pm2Create an ecosystem config at ~/app/ecosystem.config.cjs:
module.exports = {
apps: [
{
name: 'sveltekit-app',
script: './build/index.js',
cwd: '/home/deploy/app',
instances: 'max', // one worker per CPU core
exec_mode: 'cluster', // Node cluster module — shares port 3000
max_memory_restart: '500M',
env: {
NODE_ENV: 'production',
HOST: '127.0.0.1',
PORT: 3000,
ORIGIN: 'https://app.example.com',
BODY_SIZE_LIMIT: '10485760' // 10 MB
},
env_file: '/home/deploy/app/.env',
error_file: '/home/deploy/.pm2/logs/sveltekit-error.log',
out_file: '/home/deploy/.pm2/logs/sveltekit-out.log',
merge_logs: true,
time: true
}
]
};Key settings explained:
instances: 'max'— PM2 spawns one worker per CPU core and load-balances via Node's built-in cluster module. All workers share port 3000 automatically.exec_mode: 'cluster'— required forinstances > 1to enable zero-downtime reloads (pm2 reload).max_memory_restart: '500M'— if a worker exceeds 500 MB (memory leak), PM2 restarts just that worker.HOST: '127.0.0.1'— bind only to localhost; Nginx will proxy external traffic. Never expose port 3000 to the public internet directly.ORIGIN— must match the public HTTPS URL exactly, otherwise SvelteKit's CSRF check will reject form submissions with "Cross-site POST form submissions are forbidden".BODY_SIZE_LIMIT— max request body size in bytes. Default is 512 KB. Raise it for file uploads.
cd ~/app
pm2 start ecosystem.config.cjsExpected output:
[PM2][INFO] Starting sveltekit-app in cluster mode (2 instances)
┌────┬──────────────────┬─────────┬─────────┬──────────┐
│ id │ name │ mode │ status │ memory │
├────┼──────────────────┼─────────┼─────────┼──────────┤
│ 0 │ sveltekit-app │ cluster │ online │ 78.2mb │
│ 1 │ sveltekit-app │ cluster │ online │ 76.8mb │
└────┴──────────────────┴─────────┴─────────┴──────────┘Persist the process list across reboots:
pm2 save
pm2 startup systemd -u deploy --hp /home/deployPM2 will print a sudo ... command — run it to register the systemd unit that boots PM2 on startup.
Common PM2 commands:
pm2 status # list all apps
pm2 logs sveltekit-app # tail combined logs
pm2 reload sveltekit-app --update-env # zero-downtime reload after .env changes
pm2 restart sveltekit-app # hard restart (brief downtime)
pm2 monit # interactive dashboardDeploy a new version:
cd ~/app
git pull
npm ci
npm run build
pm2 reload sveltekit-app --update-envpm2 reload restarts workers one at a time, keeping at least one serving traffic throughout — users see no downtime.
Step 8: Alternative — systemd Service
If you prefer not to install PM2, systemd can run SvelteKit directly. You lose cluster mode (use Nginx as a load balancer to multiple systemd units if you need it) but gain simpler operations.
Create /etc/systemd/system/sveltekit.service:
[Unit] Description=SvelteKit App After=network.target[Service] Type=simple User=deploy WorkingDirectory=/home/deploy/app EnvironmentFile=/home/deploy/app/.env Environment=NODE_ENV=production Environment=HOST=127.0.0.1 Environment=PORT=3000 Environment=ORIGIN=https://app.example.com ExecStart=/usr/bin/node build Restart=always RestartSec=5 StandardOutput=append:/var/log/sveltekit.log StandardError=append:/var/log/sveltekit.err.log
Hardening
NoNewPrivileges=true ProtectSystem=strict ProtectHome=read-only ReadWritePaths=/home/deploy/app PrivateTmp=true
[Install] WantedBy=multi-user.target
Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now sveltekit
sudo systemctl status sveltekitCheck logs:
sudo journalctl -u sveltekit -fRun either PM2 or systemd — do not run both, they will fight over port 3000.
Step 9: Nginx Reverse Proxy with Immutable Asset Caching
Nginx sits in front of Node, terminating TLS, serving static assets directly from disk, and proxying dynamic routes to SvelteKit. The killer feature for SvelteKit specifically is that every file in build/client/_app/immutable/ has a content hash in its filename — you can safely cache them forever.
Install Nginx if you have not already (full walkthrough in our Nginx on Ubuntu guide):
sudo apt install -y nginxCreate /etc/nginx/sites-available/sveltekit:
upstream sveltekit_upstream { server 127.0.0.1:3000; keepalive 64; }server { listen 80; listen [::]:80; server_name app.example.com;
# Redirect to HTTPS (enabled after Certbot) return 301 https://$host$request_uri; }
server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name app.example.com;
# SSL certs populated by Certbot in Step 10 ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off;
# Security headers add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options SAMEORIGIN always; add_header Referrer-Policy strict-origin-when-cross-origin always;
# Body size for uploads (match BODY_SIZE_LIMIT in PM2 env) client_max_body_size 10m;
# Compression gzip on; gzip_vary on; gzip_proxied any; gzip_comp_level 6; gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml application/atom+xml image/svg+xml;
# Serve pre-compressed files produced by adapter-node's precompress option gzip_static on;
# --- Immutable asset cache --- # Every file under /_app/immutable/ has a content hash in its name. # Safe to cache for a year and never revalidate. location /_app/immutable/ { alias /home/deploy/app/build/client/_app/immutable/; access_log off; expires 1y; add_header Cache-Control "public, immutable, max-age=31536000"; add_header X-Cache-Status "immutable"; try_files $uri =404; }
# --- Other static files from /static/ directory --- # Revalidate weekly; filenames are not hashed. location ~ ^/(favicon\.ico|robots\.txt|sitemap\.xml|.*\.(png|jpg|jpeg|gif|webp|svg|woff2?))$ { root /home/deploy/app/build/client; access_log off; expires 7d; add_header Cache-Control "public, max-age=604800"; try_files $uri @sveltekit; }
# --- Proxy everything else to Node --- location / { try_files $uri @sveltekit; }
location @sveltekit { proxy_pass http://sveltekit_upstream; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; 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_set_header X-Forwarded-Host $host;
# SSE and streaming SvelteKit load functions proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_send_timeout 300s; } }
Why these choices matter:
upstreamwithkeepalive— Nginx keeps 64 idle connections to the Node upstream open, saving the TCP + HTTP handshake on every request. Essential for performance on a clustered app.location /_app/immutable/— SvelteKit content-hashes everything in this directory (app.Bq3V9lkz.js). Cache forever; when the file changes, the hash changes, so the URL changes, and browsers fetch the new one naturally.gzip_static on— because we setprecompress: truein the adapter, Nginx serves.gz/.brfiles straight from disk instead of compressing on every request.proxy_buffering off— required for streaming responses, SSE, and SvelteKit'sloadfunction streaming. If you enable buffering, users will see long TTFB on streamed routes.- Upgrade/Connection headers — required for WebSocket support if your app uses libraries like
sveltekit-ioor custom WS endpoints.
sudo ln -s /etc/nginx/sites-available/sveltekit /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxThe familiar Nginx reverse-proxy pattern here is identical to what you would use for Next.js — see our Next.js on Ubuntu guide for a side-by-side comparison.
Step 10: HTTPS with Let's Encrypt
Install Certbot and let it rewrite the Nginx config with production TLS:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d app.example.comCertbot will:
Test renewal:
sudo certbot renew --dry-runYour site is now accessible at https://app.example.com with an A+ rating on SSL Labs (assuming the config from Step 9).
Step 11: hooks.server.ts for Logging and Auth
hooks.server.ts is SvelteKit's middleware layer — it runs before every request, perfect for structured logging, auth, feature flags, and global error reporting.
Create src/hooks.server.ts:
import type { Handle, HandleServerError } from '@sveltejs/kit'; import { sequence } from '@sveltejs/kit/hooks'; import { SESSION_SECRET } from '$env/static/private';const logger: Handle = async ({ event, resolve }) => { const start = Date.now(); const response = await resolve(event); const duration = Date.now() - start;
// Single-line structured log, picked up by PM2/journald console.log(JSON.stringify({ t: new Date().toISOString(), ip: event.getClientAddress(), method: event.request.method, path: event.url.pathname, status: response.status, ms: duration, ua: event.request.headers.get('user-agent') }));
return response; };
const auth: Handle = async ({ event, resolve }) => { const sessionCookie = event.cookies.get('session'); if (sessionCookie) { // Verify and attach user to event.locals event.locals.user = await verifySession(sessionCookie, SESSION_SECRET); } return resolve(event); };
async function verifySession(token: string, secret: string) { // your JWT / database lookup logic here return null; }
export const handle = sequence(logger, auth);
export const handleError: HandleServerError = ({ error, event }) => { const id = crypto.randomUUID(); console.error(JSON.stringify({ t: new Date().toISOString(), errorId: id, path: event.url.pathname, message: (error as Error).message, stack: (error as Error).stack })); return { message: 'Internal error', errorId: id }; };
Because we log JSON to stdout, PM2 captures it in ~/.pm2/logs/sveltekit-out.log. Pipe those logs to Loki, Datadog, or Better Stack with a single log shipper to get production observability.
For TypeScript support of event.locals.user, add to src/app.d.ts:
declare global {
namespace App {
interface Locals {
user: { id: string; email: string } | null;
}
}
}
export {};Static Sites: adapter-static
If your SvelteKit app has no server-rendered routes — pure marketing site, docs, blog with build-time-generated pages — use @sveltejs/adapter-static instead. You will not need Node.js at runtime, PM2, or the upstream block in Nginx.
Install and configure:
npm install --save-dev @sveltejs/adapter-staticIn svelte.config.js:
import adapter from '@sveltejs/adapter-static';
export default { kit: { adapter: adapter({ pages: 'build', assets: 'build', fallback: undefined, // 'index.html' for SPA mode, '404.html' for static 404 precompress: true, strict: true }) } };
Add export const prerender = true; to src/routes/+layout.ts so every route is prerendered at build time.
Build:
npm run buildConfigure Nginx to serve build/ directly — no Node upstream needed:
server { listen 443 ssl http2; server_name site.example.com;root /home/deploy/app/build; index index.html;
gzip_static on;
location /_app/immutable/ { expires 1y; add_header Cache-Control "public, immutable, max-age=31536000"; }
location / { try_files $uri $uri/ $uri.html =404; } }
This is essentially free to run — any VPS with 512 MB RAM serves static SvelteKit sites at tens of thousands of requests per second.
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Cross-site POST form submissions are forbidden | ORIGIN env var does not match the URL users visit | Set ORIGIN=https://app.example.com in PM2 config and reload. Include the exact scheme and host. |
| 502 Bad Gateway from Nginx | Node process not running or crashed | pm2 status and pm2 logs sveltekit-app. Check for missing env vars or port conflicts. |
EADDRINUSE: address already in use :::3000 | Another process (old PM2, dev server) holds port 3000 | sudo lsof -i :3000 then kill or pm2 delete the stale process. |
| Build OOM killed on 1 GB VPS | Vite needs ~1.5 GB for medium apps | Add 2 GB of swap: sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile. Or build locally and rsync the build/ dir. |
| Environment vars undefined after deploy | PM2 caches env on start; .env changes not picked up | pm2 reload sveltekit-app --update-env forces a reload with fresh env. |
| Stale asset served after deploy | Browser cached an old hashed file — unlikely, or you did not purge CDN | Hard refresh. If consistent, verify expires header only set on /_app/immutable/, not on HTML. |
| Form actions always return 403 | Request missing origin or coming through a reverse proxy that strips headers | Ensure Nginx passes X-Forwarded-Proto and Host, and ORIGIN matches. |
Streamed load function hangs | proxy_buffering is on in Nginx | Set proxy_buffering off in the location block proxying to Node. |
Viewing Logs
# PM2 combined logs
pm2 logs sveltekit-app --lines 200systemd logs (if using systemd instead)
sudo journalctl -u sveltekit -fNginx access + error
sudo tail -f /var/log/nginx/access.log /var/log/nginx/error.logFAQ
Which SvelteKit adapter should I use for a VPS?
Use @sveltejs/adapter-node for any app that needs server-side rendering, form actions, API routes, or dynamic data loading. It produces a self-contained Node.js HTTP server that runs comfortably under PM2 or systemd behind Nginx. Only use @sveltejs/adapter-static for fully prerenderable sites like marketing pages, documentation, or personal blogs — those sites skip Node entirely at runtime and are served as plain files by Nginx.
Do I need PM2 or can I use systemd?
Both work and both are production-grade. PM2 is recommended for most SvelteKit deployments because it trivially gives you cluster mode (one worker per CPU core sharing a single port), log rotation, a built-in web dashboard, and zero-downtime reloads with pm2 reload. systemd is preferable when you want zero extra dependencies, run a single worker, or are deploying via immutable infrastructure (Docker, Nixpacks) where a process manager inside the container would be redundant.
How do I pass environment variables to SvelteKit at runtime?
SvelteKit has four env modules: $env/static/private (build-time secrets baked into the bundle), $env/dynamic/private (runtime secrets that must rotate without rebuilding), $env/static/public (build-time values safe for the browser), and $env/dynamic/public (runtime values readable by client JS). For runtime rotation use $env/dynamic/private and set variables in your PM2 ecosystem file or a .env file loaded via env_file. Use pm2 reload --update-env after changing any env var.
Why self-host SvelteKit instead of using Vercel or Netlify?
Self-hosting gives you predictable flat-rate pricing (EUR 7.99/month vs metered function invocations), no cold starts, no function duration limits on WebSockets or streaming AI responses, full control of the Node runtime, data residency guarantees, and no vendor lock-in. Vercel and Netlify are great for hobby projects and traffic spikes; a VPS wins for steady-state SaaS, internal tools, and anything with long-lived connections.
Can I run multiple SvelteKit apps on one VPS?
Yes. Each app gets its own PM2 entry in ecosystem.config.cjs listening on a different port (3000, 3001, 3002, ...), and each gets its own Nginx server block with a different server_name and upstream pointing to the right port. A 4 GB RAM CloudCore Starter comfortably runs three or four small SvelteKit apps plus their Nginx front-end.
How do I deploy zero-downtime?
Use pm2 reload (not restart). Reload spawns new workers before killing old ones, so there is always at least one process serving requests. Combined with git pull && npm ci && npm run build && pm2 reload sveltekit-app --update-env in a deploy script, users experience no dropped connections during rollouts. For truly atomic deploys (rollback in one step), use a symlinked current -> releases/2026-04-16-abc123/ pattern and have PM2 cwd point at the symlink.
Next Steps
With SvelteKit running in production, here are logical follow-ups:
- Add a database — Drop in PostgreSQL 16 or SQLite with Drizzle ORM. Both run happily on the same VPS for small-to-medium workloads; move the database to a separate VPS when read/write pressure justifies it.
- Ship structured logs to Loki — The JSON logger in
hooks.server.tsis ready to ingest. Install Promtail or Vector, point it at~/.pm2/logs/, and query traffic patterns in Grafana. - Add a CDN in front of Nginx — Cloudflare (free tier) caches
/_app/immutable/globally and absorbs DDoS attacks. Set Cloudflare's cache rules to respect origin headers and you are done. - Set up CI/CD — A simple GitHub Actions workflow that SSHes to the VPS and runs
git pull && npm ci && npm run build && pm2 reload sveltekit-app --update-envon every push tomainis 20 lines of YAML and catches 95% of deploy errors. - Read the official SvelteKit docs — For deeper coverage of load functions, form actions, and advanced routing patterns, bookmark the SvelteKit documentation.
- Compare to Next.js — If you are weighing frameworks, read our Next.js on Ubuntu guide for the same deployment blueprint on a different stack.
Start Deploying Today>
Spin up a CloudCore Starter VPS for EUR 7.99/month and follow this guide end to end. 2 vCPU and 4 GB RAM is plenty for a production SvelteKit app with room to spare.>
Launch Your CloudCore Starter VPS and have your SvelteKit app live on HTTPS in under 30 minutes.