Introduction
In this article, I'll guide you through the process on how to containerize a SvelteKit application.
I wont go into the reasons why containerization is a good thing and why you would want it. Since you are reading this, I assume you're already convinced that in todays cloud-drive area, its a baseline infrastructure discipline 😄
I build scalable cloud platforms & SaaS solutions, modernize critical software, and help organizations make high-stakes technology decisions with confidence.
👉🏼 Let's discuss your architure!
Prerequisites
Before we'll get to the actual Dockerfile there are couple of things we need to carry out and understand beforehand.
Reconfigure your Svelte application to use the Node adapter
Since we will use the node:22 container image as a base image your application needs to be able to run on that platform. The setup is fairly simple, instructions can be found here.
Understand how SvelteKit handles environment variables
SvelteKit provides the following modules to access environment variables. These are:
- $env/dynamic/private
- $env/dynamic/public
- $env/static/private
- $env/static/public
Since the proper usage of these modules impacts how we need to build the container, it also has an impact on the security of your build artifact (container image). Let's have a closer look.
$env/dynamic/private
- Provides access to runtime environment variables as defined by the platform (
process.env) - Won-t be statically injected to your code at build time
- Only includes variables NOT beginning with
PUBLIC_ - Cannot be imported into client-side code
👉🏼 So this is the candidate to use for injection secret API keys, etc. at runtime.
$env/dynamic/public
- Only includes variables beginning with
PUBLIC_ - Can safely be exposed to client-side code
- Must all be sent from server to client, causing slightly larger network requests
👉🏼 This module is a good fit to inject variables like for example version strings.
$env/static/private
- Values are statically injected into the bundle at build time
- Cannot be impoted into client-side code
- All environment variables referenced in the code should be declared in
.envfile, even if they don't have a value until the app is deployed
👉🏼 The usage of this module should scare you and I recommend not using it, since this would bake potential secrets into the code and therefor will end up in the container image.
$env/static/private, since it will bake potential secrets into the bundle, that will end up in the container image. If you later plan to publish your container image 💥 boom💥 you have leaked a credential into the public.
$env/static/public
- Only includes variables beginning with
PUBLIC_ - Can safely be exposed to client-side code
- Values are are replaced statically at build time
- Variables will end up in the bundle
👉🏼 These type of variables need to be passed at build time, which implies a change of this variable requires a rebuild of the container.
Running on the node adapter without Docker
Let's first understand how the application needs to run on the adapter-node before we'll get to the Dockerfile and building the actual image.
- Build your application with
npm run build. This will output the build artifacts to the folderbuildif using the default options foradapter-node. - Copy
build/,package.jsonandpackage-lock.jsonto target folder (e.g./app) - Run
npm ci --omit devfrom/appto generate production dependencies - Start the application with
node build
Example SvelteKit code for demo
The following instructions are based on the following simple SvelteKit example.
# Can only be used with $env/static/public or dynamic/public.
# Needs to be passed as --build-args when using static/public
PUBLIC_SITE_NAME="blog-article-demo"
PUBLIC_SITE_VERSION="v1.0.0"
# Can only be used with $env/dynamic/private (avoid static/private)
API_KEY="41519720-7728-4f1e-869d-d6ab9b037b93".env.local
This runs server-side, why it's safe to dynamically read private environment variables.
import { env } from "$env/dynamic/private";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async () => {
if (env.API_KEY) {
console.log("API_KEY is set");
} else {
console.error("API_KEY is not set");
}
return {};
};
+page.server.ts
Since +page.svelte runs client-side, we are safe to use $env/dynamic/public and $env/static/public. Btw. if we would have tried to use $env/dynamic/private here, the build process (or even earlier the HRM) would have failed.
<script lang="ts">
// This will be fetched at runtime
import { env } from "$env/dynamic/public";
// This will be baked into the bundle
import { PUBLIC_SITE_NAME } from "$env/static/public";
</script>
<h1>Environment Variables</h1>
<h2>Dynamically imported</h2>
{#if env.PUBLIC_SITE_VERSION}
<pre>{env.PUBLIC_SITE_VERSION}</pre>
{:else}
<pre>PUBLIC_SITE_VERSION is not set</pre>
{/if}
<h2>Statically imported</h2>
<pre>{PUBLIC_SITE_NAME}</pre>
+page.svelte
The actual Dockerfile
This is what you came here for, a minimal, non-root, multi-stage Dockerfile using node:22-bookworm-slim.
# First builder stage
FROM node:22-bookworm-slim AS builder
# needs to be defined after FROM and before npm run build
ARG PUBLIC_SITE_NAME
ENV PUBLIC_SITE_NAME=$PUBLIC_SITE_NAME
# Set the workding directory for any RUN, CMD, ENTRYPOINT, COPY and ADD
# instructions that follow. It will create the specified directory if it
# doesn't exist
WORKDIR /temp
# Copy source code from host to builder stage
COPY . ./
# npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828).
RUN npm ci
# Actually build the code
RUN npm run build
# Second runner stage
FROM node:22-bookworm-slim AS runner
WORKDIR /app
# Setting NODE_ENV to anything but production is considered an antipattern.
# https://nodejs.org/en/learn/getting-started/nodejs-the-difference-between-development-and-production
ENV NODE_ENV=production
# Copy build artefacts into runner stage
COPY --from=builder --chown=node:node /temp/build build/
COPY --from=builder --chown=node:node /temp/package.json /temp/package-lock.json ./
# Generate production dependencies
RUN npm ci --omit dev
# Drop privileges
USER node
# We are using the node adapter
EXPOSE 3000
CMD ["node", "/app/build"]Dockerfile
... and its companian the .dockerignore file
.env*
node_modules
.git
.vscodeAs you can see, I am defining the build argument PUBLIC_SITE_NAME since it needs to be passed at build time.
.env files to your container image as this could potentially leak sensitive information. Also, if your application requires secrets at build time, don't pass them as
build-args to Docker. Instead use the secure RUN --mount=type=secretOf course, the Dockerfile can be further optimized, e.g. by using an additional deps stage for better caching. But I think it is a good starting point.
Building the image now boils down to...
docker build -t blog-article:demo --build-arg PUBLIC_SITE_NAME=foobar .And for executing, we can either pass variable by variable, using -e or point docker to an .env file using the --env-file parameter.
docker run -p 3000:3000 -e PUBLIC_SITE_VERSION="v2.0.0" blog-article:demo
docker run -p 3000:3000 --env-file=.env blog-article:demoConclusion
- Use
adapter-nodein your application for preparation - Use
ARGin yourDockerfileand pass build time arguements with the--build-argsparameter - Don't pass secrets with
build-argsbut useRUN --mount=type=secretinstead - Avoid the module
$env/static/privatewhen containerizing SvelteKit apps - Remember rebuilding your application when any of your
$env/static/publicvariables are changing
Further reading




