Skip to content

Instantly share code, notes, and snippets.

@aradalvand
Last active November 5, 2024 04:20
Show Gist options
  • Save aradalvand/04b2cad14b00e5ffe8ec96a3afbb34fb to your computer and use it in GitHub Desktop.
Save aradalvand/04b2cad14b00e5ffe8ec96a3afbb34fb to your computer and use it in GitHub Desktop.
Dockerfile and .dockerignore for SvelteKit:

*This Dockerfile is intended for SvelteKit applications that use adapter-node. So, the Dockerfile below assumes that you have already installed and configured the adapter.

Dockerfile:

FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --production

FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/build build/
COPY --from=builder /app/node_modules node_modules/
COPY package.json .
EXPOSE 3000
ENV NODE_ENV=production
CMD [ "node", "build" ]

.dockerignore:

Dockerfile
.dockerignore
.git
.gitignore
.gitattributes
README.md
.npmrc
.prettierrc
.eslintrc.cjs
.graphqlrc
.editorconfig
.svelte-kit
.vscode
node_modules
build
package
**/.env

How it works:

Let's break down this Dockerfile and explain its various parts:

Build Stage:

Note that we're doing what's called a multistage build, hence the two FROM statements and the AS builder part in the first one. This is highly recommended for applications that have a build step (such as SvelteKit apps), as it drastically reduces the size of the final image.

To learn more about multistage builds, see official Docker documentation on multistage builds.

FROM node:18-alpine AS builder

We are setting node:18-alpine as the base image of our first stage, but you can of course change this, or use another version, if you see fit.

The AS builder part is simply assigning a custom name to this first stage, so that we can reference it later in the second stage more easily.

The alpine variant is based on Alpine Linux, which is a minimal Linux distro used mainly in containers because of its small size. And even though the size of the base image you use in the first stage ultimately makes no difference to the final image size — since the second stage is all that remains in the end — it's normally a good idea to use the same base image in the build stage as the final stage (or a derivative of it), so that you can be sure that the build artifacts that are created in the first stage, will be compatible with the base image used in final stage.

Note that there's some tradeoffs that come with choosing Alpine that you should probably be aware of. Alpine uses musl and BusyBox instead of the de-facto standard glibc and GNU Core Utilities — if you don't know what these are, that's fine, this just means that some programs might not run or might run differently on Alpine than they do on bigger distributions like Ubuntu and Debian, but Node.js applications should be fine for the most part. For an overview of the differences between Docker images for various distros (e.g. Alpine, Debian, Ubuntu, etc.), check out this article.

If you don't want to use Alpine, you can remove the -alpine suffix from the image name, leaving only node:18, which defaults to the Debian-based image. If you decide to do this, remember to do the same for the next stage as well.


WORKDIR /app

This instruction is pretty self-explanatory. We're simply setting the working directory to /app, so that we don't have to repeat it for each subsequent COPY instruction.


COPY package*.json .

We copy the package.json + package-lock.json files into the working directory. The wildcard character * here is for convenience, it's to avoid explicitly naming both files, like COPY package.json package-lock.json ..


RUN npm ci

We run the npm ci command to install all the dependencies, based on the newly copied package-*.json file(s).

As you can see, we're using npm ci as opposed to the regular npm install, since the former is more fit for productions environments. For more information, see npm ci in official NPM documentations.

Note that we can't do npm ci --production — which doesn't install the dev dependencies — because the SvelteKit package itself is a dev dependency and we need it for building the app.


COPY . .

This instruction copies the rest of the source files into the working directory.

The reason we copied the pakcage-*.json file(s) and installed the dependencies first, is because we want to take advantage of Docker's layer caching mechanism, which we couldn't do if we copied everything in one go. See this.

This is a very common practice when writing Dockerfiles for Node applications and you'll see it pretty much everywhere.


RUN npm run build
RUN npm prune --production

We run the npm run build command so that SvelteKit generates the build directory, containing a standalone Node server that serves our application — assuming, of course, that you're using adapter-node.

We subsequently run the npm prune --production command to delete all the dev dependencies from the "node_modules" folder, since we no longer need any of them.


Final Stage:

Now that we have our build directory and its contents ready, we can begin the final stage, which is responsible for running our application.

FROM node:18-alpine

Here, we're using the same Alpine-based image we used in the first (build) stage. If want to use Debian-based images, you can also use node:18-slim (which is actually bigger than node:18-alpine, but smaller than node:18) to have a smaller image size. The slim variant doesn't contain things like npm, for instance, it only includes node itself. So, if you do need npm in your final image, stick to the non-slim variants.


WORKDIR /app

Same as the one in the first stage. Sets the working directory to /app.


COPY --from=builder /app/build build/
COPY --from=builder /app/node_modules node_modules/
COPY package.json .

The adapter-node documentation states the following:

You will need the output directory (build by default), the project's package.json, and the production dependencies in node_modules to run the application.

And so here, we are copying the node_modules directory (stripped of all the dev dependencies, since we did npm prune --production in the previous stage), the package.json file, and of course the build directory, from the previous stage into the working directory.

Note that some Dockerfiles (the one in this article, for instance) make the mistake of copying everything from the first stage over to the second one, including the source files, this is completely redundant, unnecessarily increases the size of the image, and basically defeats the whole point of using multistage builds. Only the artifacts that are strictly necessary for running the application should be copied over to the final stage, everything else should be left out.


EXPOSE 3000

According to the adapter-node documentation, the default port that the application will run on is 3000, assuming this, we are exposing the port 3000 via this instruction.

You can also change the port by assigning a different number to the PORT environment variable, but there's no need to do that in this case.


ENV NODE_ENV=production

Here we set the NODE-ENV variable to production, to let Node.js and other code that we're about to run know that this is a production environment.


CMD [ "node", "build" ]

We finally run the node build command — equivalent to node build/index.js — to start the server.


Also, here's a great YouTube video on this subject, building the same Dockerfile step-by-step: Containerize SvelteKit NodeJs with Docker

@maietta
Copy link

maietta commented Jan 6, 2023

THANK YOU.

@aradalvand
Copy link
Author

@maietta Happy to help!

@knulpi
Copy link

knulpi commented May 23, 2023

@aradalvand the mhart/alpine-node image is archived since January and the author suggests to use the official Node.js alpine image. Would you mind changing that in your example?

@aradalvand
Copy link
Author

@knulpi Ah, thanks for pointing that out. Done.

@NetoSimoes
Copy link

 > [stage-1 3/5] COPY --from=builder /app/build build/:
------
failed to solve: failed to compute cache key: "/app/build" not found: not found

Help?

@aradalvand
Copy link
Author

aradalvand commented Jun 12, 2023

@NetoSimoes Make sure you aren't modifying the default output folder for build artifacts in your svelte.config.js — see this.

@NetoSimoes
Copy link

To fix it I needed to install @sveltejs/adapter-node
After, changed svelte.config.js

- import adapter from '@sveltejs/adapter-auto';
+ import adapter from '@sveltejs/adapter-node';

and

adapter: adapter({out: 'build',	precompress: false, envPrefix: '', polyfill: true})

All worked correctly after this

Thank you for your answer, the article was a lifesaver!

@maietta
Copy link

maietta commented Jun 13, 2023

This is what I use, for whatever that's worth.

FROM node:19-alpine
ENV NODE_ENV production
WORKDIR /app
COPY ./build package*.json ./
RUN npm ci --omit dev
EXPOSE 80
ENTRYPOINT ["node", "index.js"]

For the project, run npm i -D @sveltejs/adapter-node

Change reference in svelte.config.js from @sveltejs/adapter-auto to the @sveltejs/adapter-node.

You can now remove the old adapter from modules using npm remove @sveltejs/adapter-auto.

@aradalvand
Copy link
Author

@NetoSimoes Oh yeah, you need to install and set up adapter-node first, as you found out.
Happy to help!

@aradalvand
Copy link
Author

aradalvand commented Jun 13, 2023

@maietta Is that your entire Dockerfile? Is there a particular reason you're not using two stages, like the Dockerfile in this gist? Correct me if I'm wrong but your current Dockerfile would require you to run npm run build before you can run docker build to build the image.

@madupuis90
Copy link

I had to change COPY package*.json . to COPY package*.json ./
after getting When using COPY with more than one source file, the destination must be a directory and end with a / error

@maietta
Copy link

maietta commented Jul 27, 2023

@maietta Is that your entire Dockerfile? Is there a particular reason you're not using two stages, like the Dockerfile in this gist? Correct me if I'm wrong but your current Dockerfile would require you to run npm run build before you can run docker build to build the image.

You are correct. You do need a process to build first. However I don't ever want to build inside a container unless it's part of a build toolchain. In my case, I have a publish.sh script that runs an npm run build then packages up the build files and sends them off to my deployment server. The deployment server will then run the small Dockerfile I include.

This is the reason I do things this way:

I build locally in WSL on my machine. This isn't the same environment as my server. The separation of build and and dependency cleanup ensures that the apps will always get deployed.

Now what I could be doing is building with CI/CD tooling, including the dev dependency cleanup, then creating a docker image and pushing it to a registry, where the image could get get deployed as a container. But that requires dependency on outside toolchain, i.e., GitHub Actions. I don't that if I'm deploy directly from my local machine as a one man team.

I hope that helps. In a nutshell, you are correct. You need to build prior to the dev dependency cleanup. And of course, you can do both in the Dockerfile as separate build steps. My needs different from most.

--- Another option I have --

I could also just build the image locally and push to my container registry, but doing so ads file size overhead to my pushes and I would still be required to trigger a deployment, perhaps with a webhook to my CapRover server. Given how iffy my internet connection can be sometimes and never knowing if I might be using my phone's hotspot, the smallest possible file size for deployment is best for me. It also ensures that the least amount of work is required of the CapRover server to deploy under the scenario.

--- Further ---

I am building a whole new native Docker Swarm deployment tool called Capitano that will address everything, but that's a topic for another place and time, not here or now.

@youfun
Copy link

youfun commented Oct 7, 2023

not work in my project . Is there any solution to solve this problem?

`
#8 [builder 4/7] RUN npm ci
#8 3.132 npm ERR! code EUSAGE
#8 3.142 npm ERR! [--install-strategy <hoisted|nested|shallow|linked>] [--legacy-bundling]
#8 3.142 npm ERR! [--global-style] [--omit <dev|optional|peer> [--omit <dev|optional|peer> ...]]
#8 3.142 npm ERR! [--strict-peer-deps] [--foreground-scripts] [--ignore-scripts] [--no-audit]
#8 3.142 npm ERR! [--no-bin-links] [--no-fund] [--dry-run]
#8 3.142 npm ERR! [-w|--workspace [-w|--workspace ...]]
#8 3.142 npm ERR! [-ws|--workspaces] [--include-workspace-root] [--install-links]
#8 3.142 npm ERR!
#8 3.142 npm ERR! aliases: clean-install, ic, install-clean, isntall-clean
#8 3.142 npm ERR!
#8 3.142 npm ERR! Run "npm help ci" for more info
#8 3.145
#8 3.145 npm ERR! A complete log of this run can be found in: /root/.npm/_logs/2023-10-07T15_25_01_782Z-debug-0.log
#8 ERROR: process "/bin/sh -c npm ci" did not complete successfully: exit code: 1

[builder 4/7] RUN npm ci:
3.142 npm ERR! [--strict-peer-deps] [--foreground-scripts] [--ignore-scripts] [--no-audit]
3.142 npm ERR! [--no-bin-links] [--no-fund] [--dry-run]
3.142 npm ERR! [-w|--workspace [-w|--workspace ...]]
3.142 npm ERR! [-ws|--workspaces] [--include-workspace-root] [--install-links]
3.142 npm ERR!
3.142 npm ERR! aliases: clean-install, ic, install-clean, isntall-clean
3.142 npm ERR!
3.142 npm ERR! Run "npm help ci" for more info
3.145
3.145 npm ERR! A complete log of this run can be found in: /root/.npm/_logs/2023-10-07T15_25_01_782Z-debug-0.log


Dockerfile:4

2 | WORKDIR /app
3 | COPY package*.json .
4 | >>> RUN npm ci
5 | COPY . .
6 | RUN npm run build

error: failed to solve: process "/bin/sh -c npm ci" did not complete successfully: exit code: 1
exit status`

@nhlong1512
Copy link

adapter: adapter({out: 'build', precompress: false, envPrefix: '', polyfill: true})

Trying to use RUN yarn install or RUN npm install instead of RUN npm ci. It works well for me.

@kamalkech
Copy link

@aradalvand in https://kit.svelte.dev/docs/adapter-node#options
not working npm run still compile files in folder .svelte-kit

@maietta
Copy link

maietta commented Jan 25, 2024

Hi @aradalvand.

I run npm run build locally before I package up my tar file which includes my Dockerfile. I then push it to CapRover using the CLI tool.

In my Dockerfile I use the npm ci --omit dev.

The only files & directories I send in my tar file are:

Dockerfile
build/
packages*.json

Remember, I'm building ahead of deployment. I just happen to build locally but you can also build with a CI/CD system such as Jenkins, Github Actions in a Workflow, or some other pipeline.

When you build the project, you'll get the build/ directory by default with the SvelteKit Node Adaptor.

NOTE:

While I can run build the project locally, the npm ci --omit dev is handled in my Dockerfile and performed on the CapRover side of things during deployment. It's probably not idea. Ideally, I would build a docker image first, then deploy that directly. By doing it the way I am doing it, I don't have to worry about what architecture the server is running vs how I build my project.

@stalkerg
Copy link

You can skip node_modules copy because vite do copy everything as needed and basically bundled it for prod. (I use my project without node_modules)

@MoinJulian
Copy link

@aradalvand
I get an error when running the container:

`[+] Building 10.0s (8/14) docker:desktop-linux
=> [internal] load build definition from dockerfile 0.0s
=> => transferring dockerfile: 372B 0.0s
=> [internal] load metadata for docker.io/library/node:18-alpine 2.9s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 220B 0.0s
=> [internal] load build context 0.1s
=> => transferring context: 751.96kB 0.1s
=> [builder 1/7] FROM docker.io/library/node:18-alpine@sha256:0085670310d2879621f96a4216c893f92e2ded827e9e6ef8437672e1bd72f437 5.4s
=> => resolve docker.io/library/node:18-alpine@sha256:0085670310d2879621f96a4216c893f92e2ded827e9e6ef8437672e1bd72f437 0.0s
=> => sha256:eb6c7c29ba4d368f2428cacd291f7821b750fac3b1fb65b937ef855c573cdf97 40.24MB / 40.24MB 1.8s
=> => sha256:3d4a65156edf0208c8421995310d9e662e7ee63e2bcae660efb02f6c4ddef6a9 2.34MB / 2.34MB 0.4s
=> => sha256:0085670310d2879621f96a4216c893f92e2ded827e9e6ef8437672e1bd72f437 1.43kB / 1.43kB 0.0s
=> => sha256:aacbcec05180c1dd8c33dba8a9c42b75dbfdd659aa57617497f1ce2c5d83d889 1.16kB / 1.16kB 0.0s
=> => sha256:c8eb770fbfacf54104162cc9035c478ddb7d8dc15dca5298af028257f1dbdb3f 7.14kB / 7.14kB 0.0s
=> => sha256:4abcf20661432fb2d719aaf90656f55c287f8ca915dc1c92ec14ff61e67fbaf8 3.41MB / 3.41MB 0.3s
=> => extracting sha256:4abcf20661432fb2d719aaf90656f55c287f8ca915dc1c92ec14ff61e67fbaf8 0.2s
=> => sha256:5bdb6c27eb32087b71a9dde411c1f1eeb87563c0445f89db4eb7639d2cf50f45 450B / 450B 0.5s
=> => extracting sha256:eb6c7c29ba4d368f2428cacd291f7821b750fac3b1fb65b937ef855c573cdf97 3.2s
=> => extracting sha256:3d4a65156edf0208c8421995310d9e662e7ee63e2bcae660efb02f6c4ddef6a9 0.1s
=> => extracting sha256:5bdb6c27eb32087b71a9dde411c1f1eeb87563c0445f89db4eb7639d2cf50f45 0.0s
=> [builder 2/7] WORKDIR /app 0.2s
=> [builder 3/7] COPY package*.json . 0.1s
=> ERROR [builder 4/7] RUN npm ci 1.3s

[builder 4/7] RUN npm ci:
1.219 npm ERR! code EUSAGE
1.233 npm ERR!
1.233 npm ERR! The npm ci command can only install with an existing package-lock.json or
1.234 npm ERR! npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or
1.234 npm ERR! later to generate a package-lock.json file, then try again.
1.235 npm ERR!
1.235 npm ERR! Clean install a project
1.236 npm ERR!
1.236 npm ERR! Usage:
1.237 npm ERR! npm ci
1.237 npm ERR!
1.237 npm ERR! Options:
1.238 npm ERR! [--install-strategy <hoisted|nested|shallow|linked>] [--legacy-bundling]
1.238 npm ERR! [--global-style] [--omit <dev|optional|peer> [--omit <dev|optional|peer> ...]]
1.238 npm ERR! [--include <prod|dev|optional|peer> [--include <prod|dev|optional|peer> ...]]
1.238 npm ERR! [--strict-peer-deps] [--foreground-scripts] [--ignore-scripts] [--no-audit]
1.238 npm ERR! [--no-bin-links] [--no-fund] [--dry-run]
1.238 npm ERR! [-w|--workspace [-w|--workspace ...]]
1.238 npm ERR! [-ws|--workspaces] [--include-workspace-root] [--install-links]
1.238 npm ERR!
1.238 npm ERR! aliases: clean-install, ic, install-clean, isntall-clean
1.238 npm ERR!
1.238 npm ERR! Run "npm help ci" for more info
1.242
1.243 npm ERR! A complete log of this run can be found in: /root/.npm/_logs/2024-02-15T12_00_02_116Z-debug-0.log


dockerfile:4

2 | WORKDIR /app
3 | COPY package*.json .
4 | >>> RUN npm ci
5 | COPY . .
6 | RUN npm run build

ERROR: failed to solve: process "/bin/sh -c npm ci" did not complete successfully: exit code: 1`

Can you help me?

@madupuis90
Copy link

@MoinJulian have you read the error message? Is says you do not have a package-lock.json. It is usually best practice to commit the package-lock.json in your project to always pull the same dependencies. Run npm i in your project to generate the package-lock.json

@MoinJulian
Copy link

@madupuis90 I have read read the error message, but I didn't understand it, but I managed to get it working, I'm using the pnpm so it is infact a pnpm-lock.yaml, but I got it working at the end. Thank you

@rjalexa
Copy link

rjalexa commented May 13, 2024

This is a great guide. The only observation I have is that it might be better to run as a non privileged user.
I have also been suggested to use pnpm as a better performing alternative to npm (you need to install it)
After quite a few experiences I personally eschew using Alpine and prefer the lts-slim versions.
With these ideas I tried to merge my Dockerfile with yours and generated this:

The Node image already has a non privileged user "node" with UID = 1000

FROM node:lts-slim AS deps
WORKDIR /app
RUN npm install -g pnpm
COPY package*.json pnpm-lock.yaml ./
RUN pnpm i --frozen-lockfile

FROM node:lts-slim AS builder
WORKDIR /app
RUN npm install -g pnpm
COPY . .
COPY --from=deps /app/node_modules ./node_modules
RUN pnpm run build
RUN pnpm prune --production

Final stage to run the application

FROM node:lts-slim AS runner
RUN npm install -g pnpm
WORKDIR /app
ENV NODE_ENV production
ENV MODELS_HOST=localhost
COPY --from=builder --chown=node:node /app/build build/
COPY --from=builder --chown=node:node /app/node_modules node_modules/

USER node:node
EXPOSE 3000 5173

CMD ["pnpm", "run", "start"]

@omeryousaf
Copy link

omeryousaf commented Jun 13, 2024

Do we need to do the following ? Why ?

COPY --from=builder --chown=node:node /app/build build/

@rjalexa
Copy link

rjalexa commented Jun 16, 2024

Good catch. No we do not need to make writable a directory that doesn't need to. I have got in the repetitive habit of chowning the directories I copy without much thought since I've been bitten a few times from not being able to write in them.

@hvuhsg
Copy link

hvuhsg commented Jul 7, 2024

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment