Dockerize SvelteKit with adapter-static and Nginx — Hugo Sum

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.

Hugo Sum

A Hongkonger living in the UK. The only thing I know is there is so much I don't know.

Archive