Skip to main contentSkip to navigation
[email protected]
Client AreaSupport
Hosting Mammoth
HostingMammothYour Data, Our Responsibility
Home
Solutions
Hosting Services
Store
Pricing
About
Blog
API
Contact

Stay Ahead of the Curve

Get the latest insights on cybersecurity, AI innovations, and enterprise data solutions delivered to your inbox.

Hosting Mammoth
HostingMammothEnterprise Solutions

Enterprise-grade data solutions. Hosting, recovery, cybersecurity, and AI-powered services for businesses worldwide.

[email protected]
Sun - Fri, 9:00am - 5:00pm

Services

  • Cloud Hosting
  • Data Recovery
  • Cybersecurity
  • Legal Support
  • MSP Services
  • Web Development
  • AI Services
  • Free Server Migration

Hosting

  • VPS Hosting (NVMe SSD)
  • VDS Hosting (NVMe)
  • Storage VPS (High SSD)
  • GPU Servers
  • Managed Services
  • Cloud Firewall
  • Load Balancer
  • One-Click Apps
  • n8n Hosting
  • Object Storage
  • FAQ

Company

  • Store
  • Pricing
  • About Us
  • Locations
  • Blog
  • Testimonials
  • Contact
  • Affiliate Program
  • White-Label
  • Terms of Service
  • Privacy Policy
  • Browser Cookies
  • SLA

Support

  • Client Area
  • Submit Ticket
  • Knowledge Base
  • Server Status
  • API Documentation

© 2026 Hosting Mammoth. All rights reserved.

Knowledge Base
Getting StartedAccount ManagementVPS HostingGPU ServersStorage VPSCloud FirewallLoad BalancerServer ManagementBilling & PaymentsSupport & TicketsAffiliate ProgramReseller ProgramMarketplace & Appsn8n HostingManaged ServicesServer MigrationAPI & DevelopersSecurityTroubleshootingGlossaryInstall Guides
  1. Home
  2. /
  3. Support
  4. /
  5. Install Guides
  6. /
  7. How To Install Sveltekit Ubuntu
GUIDEInstall Guides

How to Deploy SvelteKit on Ubuntu 24.04 VPS: Production Node.js Adapter, PM2, Nginx

26 min read

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?
  • Prerequisites
  • Step 1: Prepare the Ubuntu 24.04 VPS
  • Step 2: Install Node.js 20 LTS
  • Step 3: Create or Clone Your SvelteKit App
  • Step 4: Configure adapter-node
  • Step 5: Environment Variables with $env/static/private
  • Step 6: Build the Production Bundle
  • Step 7: Run SvelteKit Under PM2 Cluster Mode
  • Step 8: Alternative — systemd Service
  • Step 9: Nginx Reverse Proxy with Immutable Asset Caching
  • Step 10: HTTPS with Let's Encrypt
  • Step 11: hooks.server.ts for Logging and Auth
  • Static Sites: adapter-static
  • Troubleshooting
  • FAQ
  • Next Steps
  • 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-node behind 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-node produces 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.
    For internal tools, SaaS dashboards, content sites that need user-specific rendering, or anything with background workers, self-hosting on a VPS is almost always cheaper and faster.

    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:

    • How to Install Node.js on Ubuntu
    • How to Install Nginx on Ubuntu
    Connect via SSH to begin:

    bash
    ssh root@your-server-ip

    Step 1: Prepare the Ubuntu 24.04 VPS

    Update your package index and install the build essentials SvelteKit and native npm modules need:

    bash
    sudo apt update && sudo apt upgrade -y
    sudo apt install -y build-essential git curl ufw

    Create a non-root user to own the application (never run Node as root in production):

    bash
    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_keys

    Enable the firewall, allowing SSH and both HTTP (for Certbot challenges) and HTTPS:

    bash
    sudo ufw allow OpenSSH
    sudo ufw allow 'Nginx Full'
    sudo ufw --force enable
    sudo ufw status

    Expected output:

    text
    Status: active

    To Action From -- ------ ---- OpenSSH ALLOW Anywhere Nginx Full ALLOW Anywhere

    From here on, work as the deploy user:

    bash
    su - deploy

    Step 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.

    bash
    curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
    sudo apt install -y nodejs

    Verify the install:

    bash
    node --version
    npm --version

    Expected output:

    text
    v20.18.1
    10.8.2

    Enable corepack so pnpm and yarn are available when needed (SvelteKit's create-svelte supports all three):

    bash
    sudo corepack enable

    For 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:

    bash
    cd ~
    git clone https://github.com/yourorg/your-sveltekit-app.git app
    cd app
    npm ci

    If you are starting fresh, scaffold a new project with the official initializer:

    bash
    cd ~
    npm create svelte@latest app

    The interactive prompt will ask for:

    text
    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 testing

    Then install dependencies:

    bash
    cd app
    npm install

    Confirm the dev server works (we will not use it for production, but it is a useful smoke test):

    bash
    npm run dev -- --host 0.0.0.0 --port 5173

    Visit 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:

    bash
    npm install --save-dev @sveltejs/adapter-node

    Edit svelte.config.js to use the Node adapter:

    javascript
    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 is build.
    • precompress: true — Ahead-of-time gzip and Brotli compression for static assets. Nginx serves the .gz and .br variants directly via gzip_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.

    ModuleWhen readWhere visibleUse for
    $env/static/privateBuild timeServer onlySecrets baked in at build (API keys committed to env at deploy time)
    $env/dynamic/privateRuntimeServer onlySecrets that must rotate without rebuilding
    $env/static/publicBuild timeServer + browserBuild-time constants safe to ship to client (feature flags, public keys)
    $env/dynamic/publicRuntimeServer + browserRuntime values readable by client JS
    Create an .env file at the project root (never commit it):

    bash
    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
    EOF

    Use them in a server-only file (src/lib/server/db.ts):

    typescript
    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 (default 0.0.0.0)
    • PORT — port to listen on (default 3000)
    • ORIGIN — the public URL of your app (e.g. https://app.example.com). Required for CSRF protection on form actions and for url.origin to be accurate behind a reverse proxy.
    We will set all three in PM2 next.

    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/:

    bash
    cd ~/app
    npm run build

    Expected output (abbreviated):

    text
    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.42s

    Run npm run preview to preview your production build locally.

    > Using @sveltejs/adapter-node ✔ done

    Inspect the output:

    bash
    ls build/
    text
    client/  index.js  server/  handler.js  env.js  shims.js
    • build/index.js — entry point that starts the HTTP server
    • build/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
    Smoke-test the production build:

    bash
    HOST=0.0.0.0 PORT=3000 ORIGIN=http://your-server-ip:3000 node build

    Hit 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:

    bash
    sudo npm install -g pm2

    Create an ecosystem config at ~/app/ecosystem.config.cjs:

    javascript
    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 for instances > 1 to 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.
    Start the app:

    bash
    cd ~/app
    pm2 start ecosystem.config.cjs

    Expected output:

    text
    [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:

    bash
    pm2 save
    pm2 startup systemd -u deploy --hp /home/deploy

    PM2 will print a sudo ... command — run it to register the systemd unit that boots PM2 on startup.

    Common PM2 commands:

    bash
    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 dashboard

    Deploy a new version:

    bash
    cd ~/app
    git pull
    npm ci
    npm run build
    pm2 reload sveltekit-app --update-env

    pm2 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:

    ini
    [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:

    bash
    sudo systemctl daemon-reload
    sudo systemctl enable --now sveltekit
    sudo systemctl status sveltekit

    Check logs:

    bash
    sudo journalctl -u sveltekit -f

    Run 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):

    bash
    sudo apt install -y nginx

    Create /etc/nginx/sites-available/sveltekit:

    nginx
    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:

    • upstream with keepalive — 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 set precompress: true in the adapter, Nginx serves .gz/.br files straight from disk instead of compressing on every request.
    • proxy_buffering off — required for streaming responses, SSE, and SvelteKit's load function 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-io or custom WS endpoints.
    Enable the site and reload:

    bash
    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 nginx

    The 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:

    bash
    sudo apt install -y certbot python3-certbot-nginx
    sudo certbot --nginx -d app.example.com

    Certbot will:

  • Verify domain ownership via an HTTP-01 challenge
  • Issue a 90-day certificate
  • Rewrite the Nginx config with valid cert paths
  • Install a systemd timer for automatic renewal
  • Test renewal:

    bash
    sudo certbot renew --dry-run

    Your 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:

    typescript
    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:

    typescript
    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:

    bash
    npm install --save-dev @sveltejs/adapter-static

    In svelte.config.js:

    javascript
    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:

    bash
    npm run build

    Configure Nginx to serve build/ directly — no Node upstream needed:

    nginx
    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

    ProblemCauseSolution
    Cross-site POST form submissions are forbiddenORIGIN env var does not match the URL users visitSet ORIGIN=https://app.example.com in PM2 config and reload. Include the exact scheme and host.
    502 Bad Gateway from NginxNode process not running or crashedpm2 status and pm2 logs sveltekit-app. Check for missing env vars or port conflicts.
    EADDRINUSE: address already in use :::3000Another process (old PM2, dev server) holds port 3000sudo lsof -i :3000 then kill or pm2 delete the stale process.
    Build OOM killed on 1 GB VPSVite needs ~1.5 GB for medium appsAdd 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 deployPM2 caches env on start; .env changes not picked uppm2 reload sveltekit-app --update-env forces a reload with fresh env.
    Stale asset served after deployBrowser cached an old hashed file — unlikely, or you did not purge CDNHard refresh. If consistent, verify expires header only set on /_app/immutable/, not on HTML.
    Form actions always return 403Request missing origin or coming through a reverse proxy that strips headersEnsure Nginx passes X-Forwarded-Proto and Host, and ORIGIN matches.
    Streamed load function hangsproxy_buffering is on in NginxSet proxy_buffering off in the location block proxying to Node.

    Viewing Logs

    bash
    # PM2 combined logs
    pm2 logs sveltekit-app --lines 200

    systemd logs (if using systemd instead)

    sudo journalctl -u sveltekit -f

    Nginx access + error

    sudo tail -f /var/log/nginx/access.log /var/log/nginx/error.log

    FAQ

    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.ts is 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-env on every push to main is 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.

    Was this article helpful?

    ← Back to Install GuidesBrowse all categories →

    Still have questions?

    Contact Support →Submit a Ticket