Self-Hosting Node.js Apps:
The Nicer Way

I'm always interested in how other people solve problems or technical challenges they face. Since I want to be a more active part of the ones sharing their solutions your are more than welcome to read how I do self host (some) of my Node.js applications with Docker, GitLab and Caddy.

The basis is a VPS with an Ubuntu (20.04 LTS) installation and some common security messures enabled to secure the server.
What is nice about this setup: it get's you started for about 5$ per month. All the other tools/software mentioned in this article is open source and can be used (in the beginning) on a free plan.

Components

Docker & Docker Compose

That should not be a surprise to anyone at this point: far better than having to install the runtime environment directly on the server and therefore running into potential issues later on (e.g. that different applications require different runtime versions). So let's have everything running in an isolated Docker container.

For a small Node.js application this is a simplified template for a Dockerfile to get you started:

FROM node:20-alpine
WORKDIR /app

COPY package.json package-lock.json ./
COPY src ./src
RUN npm install
RUN npm run build

EXPOSE 3000
CMD ["node", "dist/index.js"]

Note: this is a simplified Dockerfile. Please read why you should not be using it like this in production.

Since I usually run more complex applications with multiple services (e.g. the frontend and an API providing the backend) I use Docker Compose to manage and run them.

version: '3' 
  services: 
    web:
      image: '${IMAGE}' 
      restart: unless-stopped
      ports: 
        - '${PORT}:3000'

The code sample above is a basic docker-compose.yml file for running a single service/application.
I basically just define the Docker image that should be used to run the service and the port of the host machine through which the service should be accessible.
Continue reading and check the .docker-deploy template for a reference to the given variables.

GitLab - Docker Registry & CD Pipeline

While speaking about Docker – GitLab has its own Docker registry. Which fits perferctly in the stack since we are using the GitLab CI/CD pipeline for the deployment of the application.

.docker-build:
  image: docker:latest
  services:
    - docker:dind
  script:
    - echo $CI_JOB_TOKEN | docker login -u gitlab-ci-token --password-stdin registry.gitlab.com
    - docker build -t $DOCKER_IMAGE_NAME .
    - docker push $DOCKER_IMAGE_NAME
  only:
    - tags

This is the job template I am using to build docker images of tags being pushed to the GitLab repository.
The support is already "baked in" since we can use the $CI_JOB_TOKEN variable to authenticate against the registry and push the built Docker image from the GitLab runner.

After the image was built the GitLab runner does SSH into the host machine, pulls the updated docker image and restarts the Docker container.

.docker-deploy:
  image: alpine:latest
  script:
    - |
      ssh -o StrictHostKeyChecking=no $USER@$HOST "
        export IMAGE=${DOCKER_IMAGE_NAME}
        export PORT=${PORT}
        echo $CI_JOB_TOKEN | docker login -u gitlab-ci-token --password-stdin registry.gitlab.com
        docker pull ${DOCKER_IMAGE_NAME}
        cd $PROJECT_DIR
        git fetch
        git reset --hard $GIT_REF
        docker compose up -d
      "
  only:
    - tags

Caddy

What we are left with is the need for a proxy server to make our application available to the outside world.

A task I did use Nginx for in the past. But this came with the need to handle TLS certificates via Let's Encrypt etc.
When I learned about Caddy this was a task I was more that happy to get rid of. It is an open source web server written in Go that does a lot of nice things for you by default (e.g. TLS certificate handling).

So in our example it just takes three lines of configuration to map our domain to the port where our app is accessible on:

nilsw.io {
  reverse_proxy 127.0.0.1:3000
}

That's it! 🙏
With this setup I get a push to deploy time of around 2-3 minutes. Absolutely fine for what I am doing with it right now.

Ansible

To make the whole VPS setup less tedious I use Ansible (an automation software for IT infrastructure). This takes care of the basic server setup (e.g. security measures, configuring the firewall, installing Docker, etc.). This way I am able to duplicte the VPS setup quickly in case I want to switch the VPS provider or scale the application.
Because Caddy could as well be your load balancer for multiple instances of your application.

Outlook

The current setup is bound to tags beings pushed. What would be nice is to have a per branch preview sometimes. This is where GitLab review apps could come into play.
But these are tasks for future Nils 🔮