Deploying SvelteKit with Docker: lessons from production
Moving a SvelteKit application from development to production often means wrestling with containerization. We've containerized SvelteKit apps across multiple teams—some smoothly, others with friction—and the difference comes down to a handful of critical decisions made before you push that first image to your registry. Deploying SvelteKit with Docker requires choosing the right adapter, configuring environment variables correctly, and handling file permissions thoughtfully; get these wrong and you'll face mysterious form submission failures, permission errors at runtime, or images that bloat unnecessarily.
Build with adapter-node
When you run npm run build in SvelteKit, the framework needs to know how to produce output for your target environment. The adapter you choose determines whether your app becomes a static site, a serverless function, or a Node.js application. For Docker deployments, adapter-node is the right choice in most cases.
Why adapter-node? It produces a standalone Node.js server that can run directly in a container. Unlike adapter-static (which requires a separate web server) or adapter-vercel (which is platform-specific), adapter-node gives you a self-contained executable. You install SvelteKit, choose adapter-node in your svelte.config.js, and you get a portable artifact.
Here's what your configuration looks like:
import adapter from '@sveltejs/adapter-node';
export default {
kit: {
adapter: adapter({
out: 'build',
precompress: false,
envPrefix: 'PUBLIC_'
})
}
};The out directory is where SvelteKit writes the compiled server and client assets. The precompress option tells the adapter whether to pre-gzip static files; in Docker, we usually disable this because gzip on-the-fly is negligible overhead and keeps images leaner. The envPrefix setting ensures only environment variables prefixed with PUBLIC_ are bundled into client code—anything else stays server-only, which is crucial for security.
After npm run build completes, you'll have a build directory containing a Node.js app ready to run. This is what goes into your Docker image.
Run node build in the container
Your Dockerfile needs two stages: one to build the SvelteKit app, and one to run it. A multi-stage build keeps your final image small by discarding npm dependencies and build artifacts.
Here's a production-tested pattern:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["node", "build"]The first stage installs dependencies and runs the SvelteKit build. The second stage copies only the compiled output and production dependencies into a fresh image. This technique reduces final image size significantly—often from 500 MB down to 200 MB or less.
A few details matter here. We use npm ci (clean install) rather than npm install` in CI environments because it respects your lock file exactly and fails if versions don't match. We copy the entire project directory before building so that SvelteKit has access to your routes, components, and config. And crucially, we expose port 3000, which is the default port that adapter-node listens on.
When you run this image locally with docker run -p 3000:3000 your-image:tag, the Node.js process inside the container starts immediately and listens on 3000. The container's port 3000 maps to your host's port 3000, and the app is accessible at localhost:3000.
Set ORIGIN for form actions
One of the most common gotchas we encounter happens when you deploy a Docker image behind a reverse proxy (nginx, ALB, CloudFront, etc.). Your form actions mysteriously fail with CORS errors or redirect loops, even though everything worked locally.
The issue: SvelteKit's form actions use the Origin header to validate requests and prevent cross-site request forgery. When your container runs behind a proxy, the request arrives with an origin like https://example.com, but inside the container, SvelteKit only sees the internal connection and doesn't know what the external URL is. This causes form submission validation to fail.
The solution is to explicitly set the ORIGIN environment variable when you run the container. This tells SvelteKit what the external URL is:
docker run -p 3000:3000 \
-e ORIGIN=https://example.com \
your-image:tagIn Kubernetes or Docker Compose, you'd set it in your deployment manifest or compose file:
version: '3.8'
services:
app:
image: your-image:tag
ports:
- "3000:3000"
environment:
ORIGIN: https://example.com
NODE_ENV: productionIf you're deploying to multiple environments or using dynamic DNS, consider deriving this from your ingress configuration. In AWS ECS, for example, you might construct it from the load balancer's DNS name. The key is that SvelteKit receives the correct external URL so form actions validate properly.
Without setting ORIGIN, you'll see errors like "Invalid form submission origin" in logs, or form submissions will silently fail. It's worth double-checking this value during any deployment.
Use a non-root user
Running your Node.js process as the root user inside a container is a security risk. If a vulnerability in SvelteKit, a dependency, or your code is exploited, an attacker gains root privileges inside the container and potentially the host. Production deployments should drop to a non-root user.
Update your Dockerfile to create and use an unprivileged user:
FROM node:20-alpine
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
RUN chown -R nodejs:nodejs /app
USER nodejs
EXPOSE 3000
CMD ["node", "build"]This creates a new user and group called nodejs with a fixed UID and GID, then switches to that user before running the command. Alpine Linux keeps this lightweight—the final image barely grows in size.
The chown command ensures the non-root user owns the application files. Without this, Node.js can still read the files but cannot write to the working directory if needed (though for a SvelteKit app, this is rare).
Security scanning tools and container orchestration platforms often flag images running as root, and most will refuse to run them without explicit override. Setting this up once in your Dockerfile saves you from friction later.
Common mistakes
Forgetting to set NODE_ENV to production: SvelteKit and most Node.js packages optimize heavily when NODE_ENV=production. Without it, you'll see degraded performance and unnecessary logging. Always set this environment variable in your container.
Using npm install instead of npm ci: In CI pipelines, npm install can update versions unexpectedly if your lock file isn't respected. Use npm ci for reproducible builds.
Copying node_modules from builder without checking for native modules: If your dependencies include C++ bindings (like native SQLite or bcrypt), they must be built on the same OS and architecture as your target. If your builder stage runs on macOS and your production stage runs on Alpine, you'll get runtime errors. Consider using a production stage with the same base image as builder, or multi-architecture builds.
Not handling graceful shutdown: When Kubernetes or Docker terminates your container, it sends a SIGTERM signal. If your Node.js process doesn't handle it, requests in-flight get dropped. SvelteKit's adapter-node handles this, but if you wrap it with a proxy or custom logic, ensure you implement signal handlers.
Exposing sensitive environment variables in the image: Never COPY a .env file into your image. Pass environment variables at runtime only. This prevents accidental secrets leaking into registries.
Key takeaways
- Use
@sveltejs/adapter-nodefor Docker deployments; it produces a self-contained, portable Node.js server. - Build with a multi-stage Dockerfile to keep your final image small and focused on runtime only.
- Set the
ORIGINenvironment variable to match your external URL, or form actions will fail behind a reverse proxy. - Create and run as a non-root user to minimize security risk in production.
- Always set
NODE_ENV=productionand usenpm ciin your build stage for consistent, optimized builds. - Test your image locally with the same environment variables and networking setup you'll use in production.






Comments
Loading comments…