Containerizing SvelteKit Apps
Introduction
Some of the things I've been learning recently are web development and DevOps, so I thought I'd write about some of it in this blog. I started a SvelteKit project recently, and wanted to apply some of the devops concepts I've learned, like automation and CI/CD. It's still a work in progress, and I'm planning to add more features like a continuous delivery pipeline to AWS or automated tests.
I'm starting to use containers more these days. They have all the necessary dependencies for an app, so I don't have to manually install them and bloat my machine. Most of the projects I've done in the past don't use any external libraries (because most of them are C programs), so I had some difficulties when trying to write the Dockerfile for this one.
I wanted to try using containers during the development process. It's something
I thought wasn't doable, but after some reading I realized it was possible. At
first I thought I needed to write 2 Dockerfiles, one for development and one
without the dev dependencies for production. I turns out that just one is enough,
by using multi staged builds. With multi stage builds, you break the image into
individual stages. The first stage could be to setup the base environment, and
after that could be the testing or building stages. When building, you can
target a specific stage, e.g. docker build -t . --target <stage>
.
Build Stages
I split my Dockerfile into 3 stages:
base
: for setting up the base image and installing dependenciesbuild
: for building the application and removing unneeded packagesprod
: the production image with a smaller base image
Project Structure
The directory structure of the project looks like this:
$ tree -aI '.git|.svelte-kit|node_modules' -L 2
.
├── src
│ ├── lib
│ ├── routes
│ ├── app.html
│ ├── global.d.ts
│ ├── hooks.js
│ └── tailwind.css
├── static
│ └── favicon.png
├── docker-compose.yml
├── Dockerfile
├── .dockerignore
├── .env
├── .env.example
├── .eslintrc.cjs
├── .gitignore
├── jsconfig.json
├── LICENSE
├── package.json
├── package-lock.json
├── postcss.config.cjs
├── .prettierrc
├── README.md
├── svelte.config.js
└── tailwind.config.cjs
4 directories, 21 files
Dockerfile
The complete Dockerfile looks like this:
# Dockerfile
# setup base image
FROM mhart/alpine-node:14.17 AS base
WORKDIR /app
COPY package*.json .
RUN npm ci
# building stage
FROM base AS build
COPY . .
RUN npm run build
RUN npm prune --production
# production image
FROM mhart/alpine-node:slim-14.17 AS prod
WORKDIR /app
EXPOSE 3000
COPY --from=build /app/build .
COPY --from=build /app/package.json .
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "index.js"]
The first step copies package.json
and package-lock.json
to the image and
installs the packages. This is the stage that will be used during development,
but you need to set several flags when running the container.
The second step copies the source code and builds it. Then it runs npm prune --production
, which removes all of the dev dependencies. The app is then copied
to the last stage, which uses a smaller base image.
Ignoring Some files
When copying files to the container, there are some files and directories that
are better ignored, especially the node_modules
directory. We can specify these
files in the .dockerignore
file.
# .dockerignore
node_modules
build
.git*
npm-debug.log
Dockerfile*
docker-compose*
.dockerignore
README.md
LICENSE
.husky
Development Setup
To be able to have the local changes take effect directly in the container, you need to mount the files into a volume. You also have to specify the command to run as well as export the ports. To build the image only up to the base stage, run:
$ docker build . -t dev-app --target base
Then run the container using this command:
$ docker run -it \
-v .:/app \
-v /app/node_modules \
-p 3000:3000 \
dev-app \
npm run dev -- --host
Explanation:
-it
: runs the container interactively-v .:/app
: mounts the current directory into the container-v /app/node_modules
: prevents the localnode_modules
from being copied to the container-p 3000:3000
: forwards the containers port 3000 to the local machine's port 3000npm run dev -- --host
: the command to start the development server
Docker Compose
The command to run the container is long. Although you can put it into a shell
script like run.sh
, I prefer to use docker compose for this. It uses yaml so
there's no need to learn a special configuration language. An added benefit to
using docker compose is adding services to the app is simpler. If you want to
add a mongodb server, you can just edit the docker-compose.yml
file.
Anyways, here's the file:
services:
app:
depends_on: mongodb
build:
context: .
target: base
command: npm run dev -- --host
ports:
- 3000:3000
volumes:
- .:/app
- /app/node_modules
Then start the service with docker-compose up
. To stop it, run docker-compose down
.
Conclusion
So that's about it. This took me around 3 days to figure out, but I finally got it working. I might write about this again when I get to deploy the application.