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 buildHowever, 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 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.
FROM nginxinc/nginx-unprivileged:1.26.2-alpine AS release
COPY --from=prerelease --chown=nginx:nginx /usr/src/app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf # [!code ++]
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.
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.
# return the default error page of nginx.
location = /404.html {
internal;
}
}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.
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.
server {
# omitted for brevity
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.
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// omitted for brevity
kit: {
adapter: adapter({
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.
server {
# 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.