How to containerize a SvelteKit app
Are you developing applications with SvelteKit? Have you modularized your app in a mono-repo using pnpm? Do you deploy a containerized runtime using Docker?
If so, congratulations - you’ve made great technology choices! But if you’re struggling to put all the pieces together or just want to confirm that your Docker Image is as efficient and optimized as it could be, this may be for you.
Creating a Docker Image of a SvelteKit mono-repo isn’t a case of copying everything in our project folder into it. While it may work, and run, it’s not going to be very efficient and you’ll end up with a Docker image that could easily be Gigabytes larger than it needs to be. The goal is for the image to contain as little as possible - just what is required for runtime execution. A lean minimal container image will not only be quicker to deploy but will also be more secure as it has fewer dependencies installed.
When developing we inevitably use many more packages to compile and build our project than are required to run it. As an example, an out-of-the-box SvelteKit project may contain these devDependencies
(when set to use the node adapter):
None of these need to be installed to execute the built version of the app - they either play a part in building it, or the pieces of the packages we use in our app are included in the built output. As we develop larger apps, the dependencies we use often grow and one of the eternal challenges is keeping control on how many packages we pull in. But it’s not unusual to end up with packages for UI components, charting, date handling and many others.
There are also runtime dependencies
that our app will need. These are packages that the server uses and while we could build some of them into our app code, it’s more efficient and often necessary, to install them on the server. Sometimes they will have their own run-time installed dependencies, such as libraries for image processing or AI, or trying to re-package them into our app is problematic for technical reasons - they simply aren’t designed to be used like that.
We need these packages, defined in dependencies
, to be installed in the docker image, but we don’t want the devDependencies
to be installed.
To achieve this we can use a “multi-stage” Dockerfile. This builds the final Docker Image in stages, and the final stage can use some of the outputs produced in earlier stages, but without including all of the dependencies that were needed to create them.
Here’s the Dockerfile we’re going to use. But before you copy-and-paste this, there are some other pieces needed to make this work, so keep reading …
FROM node:22-bookworm-slim AS build
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
COPY . .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
RUN pnpm run -r build
RUN pnpm deploy --filter=web --prod out
FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
ENV NODE_ENV=production
ENV ORIGIN=http://localhost:8080
COPY --from=build /app/out/ .
EXPOSE 8080
CMD ["server.js"]
We’ll go through what each line does to explain it.
The “build” part of our multi-stage Dockerfile is going to use the latest slim version of the node image. Unless you use something that needs the extra dependencies (e.g. the full C build toolchain) it only uses a couple of hundred Mb vs more than a Gb:
FROM node:22-bookworm-slim AS build
Next, we configure and enable pnpm
. Note that we no longer need to install pnpm using npm - support is built in to recent node versions and activated using corepack enable
:
FROM node:22-bookworm-slim AS build
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
We’ll create a workspace to put our project files and copy them in:
WORKDIR /app
COPY . .
NOTE: this shouldn’t copy everything that is inside our project folder. We don’t want to copy the (hidden) git repository files or the node_modules
folder that may have versions of packages built for a different architecture, so we use a .dockerignore
file to exclude these:
.git
.gitignore
node_modules
At this point, our image will have the basics of a dev environment - a linux distro, with the node runtime, and the project source files. Just as when you first checkout a project from a source repo, we need to install the dependencies. We will use the cache mounts feature to have Docker cache any downloads to speed up repeated runs, otherwise it’s a fairly straightforward pnpm install
making sure to use the dependencies specified in the lockfile:
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
With the dependencies installed, we can build our app! Note that we’re using the -r
(recursive) option that will execute the build
script in all packages inside our mono repo workspace. pnpm
automatically handles the dependencies to build things in the correct order, and it doesn’t matter if we have multiple packages, even multiple SvelteKit instances for apps and component libraries, they are all built:
RUN pnpm run -r build
And now the “magical” part. The pnpm deploy command transforms the app as if it was optimized for publishing to a node package repository. All dev dependecies are pruned, including other packages in our mono-repo, with just the runtime dependencies included in the node_modules
folder. We use the --filter
option to specify the package without our mono-repo (this is the name of the app in its package.json
file), and the --prod
and out
specify the type and location of the output.
RUN pnpm deploy --filter=web --prod out
As before, there is some extra configuration needed to make this work, this time to make sure it includes everything our app needs to execute. SvelteKit, like most frameworks, needs more than the node_modules
runtime dependencies … it needs the built app output, normally output to a build
folder. Unless we configure our app to know which files are needed for runtime, pnpm deploy
won’t know to include them.
We specify the files to include by adding a files
section to the apps package.json
, just as we would if we wanted to publish the outputs to npmjs.com:
{
"name": "web",
"version": "0.0.1",
"files": [
"build",
"server.js"
],
For this example, I’m also including server.js
, which I’ll explain later.
At this point, we have a built output. To get a better idea of exactly what it’s done, you can execute the built and deploy commands in your project folder and examine the out
folder. Note it has no source code, just the build
and node_modules
folders, with the latter containing just the runtime dependencies.
pnpm run -r build
pnpm deploy --filter=web --prod out
At this point we have a Docker Image that we could deploy and execute. But it would be a waste to do this. The Image it was based off has way more dependencies than we really need, which makes it bigger than it needs to be and can represent a larger attack surface. Also, it has all the build tools and devDependencies
that simply aren’t required (please, don’t run vite
in production …)
So instead, we will use a distroless image for the runtime base. A distroless image contains only your application and its runtime dependencies. They do not contain package managers, shells or any other programs you would expect to find in a standard Linux distribution. Fewer dependencies make for both a smaller image and a more secure image, with a reduced attack surface. It’s a win-win no brainer to use.
FROM gcr.io/distroless/nodejs22-debian12
As before, we will create an area for our app to live:
WORKDIR /app
And we’ll be sure to set NODE_ENV=production
so node and any packages know how to behave.
ENV NODE_ENV=production
Because SvelteKit doesn’t know what URL the Docker Image will be serving, we need to configure it. This can be done by setting an ORIGIN
environment variable, or other variables to allow the host to be determined from the request headers. For this example we’ll use the simplest option.
ENV ORIGIN=http://localhost:8080
Finally, we’ll copy just the out
folder from the previous build stage to our app folder. This image now contains the bare minimum to execute our app and for this example comes out at about 200Mb in size.
COPY --from=build /app/out/ .
We can then expose the port and execute the app. The entrypoint of the node distroless image is set to “node”, so it expects the name of a .js file to execute. This is the server.js
file I mentioned earlier.
EXPOSE 8080
CMD ["server.js"]
Using a custom server as the entrypoint to a Node Adapter SvelteKit app allows you greater control. You may need to setup a proxy to support Firebase signInWithRedirect or want to add a healthcheck endpoint or http compression to your server.
Here’s an example of the latter (note, http-compression
is an example of something that would be added to the runtime dependencies
for the app):
import http from 'http'
import compression from 'http-compression'
import { handler } from './build/handler.js'
const compress = compression()
console.log('listening on port 8080 ...')
http
.createServer((req, res) => {
compress(req, res, () => {
handler(req, res, err => {
if (err) {
res.writeHead(500)
res.end(err.toString())
} else {
res.writeHead(404)
res.end()
}
})
})
})
.listen(8080)
I hope you found this useful. It seems more complex than it really is because we’ve gone through it line-by-line but as long as you remember the .gitignore
and files
in package.json
, it’s easy to apply to multiple projects and ensures the Docker Images they output will always be optimized.
There are additional enhancements that can be made such as using environment variables to specify the PORT
to run on, and if you have multiple applications in your mono-repo project that will each need their own Docker Image (e.g. a client facing site, a tenant console, an admin site etc…) then you can add ARG
s to the Dockerfile to allow the same one to be used for all of them.