Dockerize SvelteKit with adapter-static and Nginx
I have encountered a few unobvious pitfalls when I create the Dockerfile
for this blog, which is a SvelteKit project built with @sveltejs/adapter-static
. For the sake of contributing back to the community, I would like to share those pitfalls and some tips with you on building a Docker image that follows best practises.
Build SvelteKit in Docker
To reduce the size of the Docker image as much as possible, only the output of building SvelteKit should be included in the image. With a multi-stage build, only the final layer will be included in the image, and the rest will be discarded. By doing so, we can discard all dependencies of the building process(e.g. node_modules
), keeping the image slim.
FROM node:22.12.0-slim as base
WORKDIR /usr/src/app
FROM base AS install
RUN mkdir -p /temp/dev
COPY package.json package-lock.json /temp/dev/
RUN cd /temp/dev && npm install --frozen-lockfile
RUN mkdir -p /temp/prod
COPY package.json package-lock.json /temp/prod/
RUN cd /temp/prod && npm install --frozen-lockfile --production
FROM base AS prerelease
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
ENV NODE_ENV=production
RUN npm run build
However, this Docker image would not run and response to request yet. With @sveltejs/adapter-static
, the output of SvelteKit is only a set of static files, and we need to run a file server to serve them. I am going to pick nginx
, a popular and performant file server to serve these files.
As I run my Docker images in Kubernetes, I am relatively sensitive with what privileges I need to give my containers. Most people out there would probably use nginx
in their Docker image, but this image would run nginx
as root, and by default listen to port 80
, which is a priviledged port. Running a file server shouldn’t require root
permission at all. A better option is to use nginxinc/nginx-unprivileged
, which would run nginx
as non-root and listen to port 8080
by default.
# omitted for brevity
FROM base AS prerelease
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
FROM nginxinc/nginx-unprivileged:1.26.2-alpine AS release
COPY --from=prerelease --chown=nginx:nginx /usr/src/app/build /usr/share/nginx/html
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]
Now if you build and run your Docker image, you should be able to serve your SvelteKit’s output.
Handling directories without index.html
Unless you have set trailingSlash = 'always'
in your SvelteKit project, you will notice that there is only one index.html
in your build output. Pages with a nested path, say /post/my-post-title
, would be generated as build/post/my-post-title.html
instead of build/post/my-post-title/index.html
. nginx
will refuse serving these routes with its default configuration. To fix that, we have to provide our own configuration of nginx
, through the Dockerfile
.
First of all, create an nginx.conf
at the same level as your Dockerfile
.
server {
listen 8080;
listen [::]:8080;
root /usr/share/nginx/html;
server_name _;
location / {
try_files $uri $uri.html; # handle the output of SvelteKit correctly, by using a fallback pattern.
}
include /etc/nginx/mime.types; # handle mime.
}
And then copy it and overwrite the default configuration file of nginx
.
# omitted for brevity
FROM nginxinc/nginx-unprivileged:1.26.2-alpine AS release
COPY --from=prerelease --chown=nginx:nginx /usr/src/app/build /usr/share/nginx/html
# overwrite the default configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]
Handling 404 error
Since we are using @sveltejs/adapter-static
, all the HTTP errors are handled during prerendering, with the exception of 404. A simple way to handle this is to update nginx.conf
and use the internal error page of nginx
.
# nginx.conf
server {
# omitted for brevity
location / {
try_files $uri $uri.html $uri/ =404; # if we cannot find a match, route to 404.
}
error_page 404 /404.html; # if 404, route the request to 404.html.
location = /404.html {
internal; # return the default error page of nginx.
}
}
Even though SvelteKit would render +error.svelte
for 404 during development(i.e. vite dev
), it does not generate that page for you automatically in build time. To generate that, you have to specify the fallback in the adapter’s arguments.
// svelte.config.js
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// omitted for brevity
kit: {
adapter: adapter({
fallback: '404.html' // this filename can be whatever you want
}),
},
};
export default config;
Then when you run vite build
, you should be able to find a 404.html
in your build directory. To make nginx
route to this page whenever a 404 is encountered, you simply need to remove the route to the internal
error page.
# nginx.conf
server {
# omitted for brevity
location / {
try_files $uri $uri.html $uri/ =404;
}
error_page 404 /404.html; # if 404, route the request to 404.html, which would be the page generated by SvelteKit as fallback.
# NO LONGER NEEDED
# location = /404.html {
# internal;
# }
}
Serving precompressed assets
To improve performance of your site, you should served your assets with compression, such as with gzip
or brotli
. @sveltejs/adapter-static
supports precompressing, creating compressed assets for you in the build time.
// svelte.config.js
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// omitted for brevity
kit: {
adapter: adapter({
// omitted for brevity,
precompress: true
}),
},
};
export default config;
After precompressing your assets, you have to make nginx
serve these assets. Setting gzip_static
will allow nginx
to serve gzip
files.
# nginx.conf
server {
# omitted for brevity
location / {
# omitted for brevity
gzip_static on;
}
}
If you want to use brotli
instead for better compression, you will need to instsall ngx_brotli
module and set brotli_static
. I didn’t go that far, but I found a Docker image with this module installed.