Back

Moving from PM2 and Nginx to Traefik

I use pm2 and Caddy or in complicated apps nginx to manage my personal projects. Simply because I can pretty quickly fix and update things if anything breaks and that's limited to my personal projects. I can't do this when I'm working at my day job since those apps are mid to large sized and it's not a good idea to not add in any kind of fault tolerance to it.

Either way, the personal projects have been doing fine and I don't really need to change to traefik but I've been using traefik for a long time at this point and I thought I could make the deployments a bit more easier.

You can find the final resulting repository of this on barelyhuman/easy-deploy-template

Before the migration

Even though most of the apps I have are already container based and I use docker for most of everything, being able to deploy by simply typing DOCKER_HOST=user@sshmachine.com docker compose up --build -d is a really nice flow to have. The images are built locally and tranferred to the remote host.

The secrets are also transferred securely so you don't have to worry about that either but for people who are doing this for the first time, here's thing you need to verify.

The Plan

I still wish to keep it super simple to deploy locally, so I'm going to be writing bash scripts and Makefile to make it super easy to run the deploy and rollback commands

  1. compose.yml for setting up traefik
  2. compose.yml for the app and it's dependencies
  3. Makefile scripts for running deploys and local builds

Execution

The folder structure of this looks like this

|--| ~
|--| traefik
|--|--| traefik.yml
|--|--| compose.yml
|--| app
|--|--| compose.yml
|--|--| main.go

Traefik

We simply setup traefik to be a closed box waiting for docker services to ask it to deploy stuff.

Let's start with traefik.yaml, which is the config file that we'll be passing it's docker image.

# traefik.yaml
providers:
  docker:
    watch: true
    exposedByDefault: false
    network: 'proxy'
    endpoint: 'unix:///var/run/docker.sock'

api:
  insecure: true

To explain it briefly, we've just asked traefik to use the docker provider and only use the network named proxy to look for services that can use traefik. There's also configuration that disables traefik from picking up all the docker services that are running. You can run this locally if it's too scary to run on a remote machine.

Let's get to the compose.yml for this.

version: '3.8'

services:
  traefik:
    image: traefik:v2.5
    ports:
      - 80:80
      - 8080:8080
    volumes:
      - ./traefik.yml:/etc/traefik/traefik.yml
      - /var/run/docker.sock:/var/run/docker.sock
    networks:
      - proxy
    restart: unless-stopped

networks:
  proxy:
    external: true
    driver: bridge
    name: proxy

Going to keep that simple as well, we've done the following things:

If you are doing this locally, you should now be able to go to http://localhost:8080 and see a dashboard from traefik that shows the currently running services, routers, etc.

App

For the app, I'm going to use a tiny go lang program that just has 2 routes and a simple database migration.

package main

import (
	"fmt"
	"net/http"

	"github.com/barelyhuman/go/env"
	"github.com/joho/godotenv"
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
)

type Product struct {
	gorm.Model
	Code  string
	Price uint
}

func main() {
	godotenv.Load()
	pgDSN := env.Get("DATABASE_URL", "")
	db, err := gorm.Open(postgres.Open(pgDSN), &gorm.Config{})
	if err != nil {
		panic("failed to connect database")
	}

	db.AutoMigrate(&Product{})

	http.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, "hello")
	}))

	http.Handle("/healthz", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	}))

	http.ListenAndServe(":3000", nil)
}

You don't have to understand the app, just know that it runs of the port :3000 and responds to /healthz and / routes. You could use a simple nodejs express /fastify app if you are trying this out right now

We need to define how the app is going to be built, so we'll write a custom Dockerfile for this.

from golang:1.20

WORKDIR /app

COPY . .

RUN go build -o app .

CMD ["./app"]

Pretty simple stuff, we copy the source, build it and run the app at the end.

At this point, you can test if your app works or not locally and now we can just add in the compose.yml file for the app as well.

version: '3.8'

services:
  app:
    # tell docker compose to build this `Dockerfile` that's in this folder
    build:
      context: .

    # set these labels on the container
    # these are what traefik uses to identify configuration
    labels:
      - traefik.enable=true

      # enable the below label when working locally
      #   - traefik.http.routers.app.rule=Host(`127.0.0.1`)
      - traefik.http.routers.app.rule=Host(`goblin.run`)
      # provide information to traefik so it knows what to use to check if the instance is up or not
      # and if up, on what port
      - traefik.http.services.app.loadbalancer.healthcheck.path=/healthz
      - traefik.http.services.app.loadbalancer.server.port=3000

      # if the instance ever goes down, these are the number of attempts
      # before failing and the interval for each needs to be 200ms
      - 'traefik.http.middlewares.test-retry.retry.attempts=5'
      - 'traefik.http.middlewares.test-retry.retry.initialinterval=200ms'

    # Tie the app to the following networks, in this case the internal network is
    # going to be used for any other services that you might not want to expose to the system, a database maybe?
    # on the other hand we need to provide `proxy` so that traefik and the app are on the network, this is what
    # we defined in the traefik configuration
    networks:
      - proxy
      - internal

    env_file:
      - .env

    # The deploy settings are used to define how many internal instances do we wish
    # for docker compose to create for this one image, here it says 3 but you can do
    # just fine with 2.
    deploy:
      mode: replicated
      replicas: 3

      # we also define the update_config, to tell docker how to handle rollback and updates
      # here we specify that it needs to update one container at a time instead of parallely updating them all
      update_config:
        parallelism: 1
        order: start-first
        failure_action: rollback
        delay: 5s

    restart: always

# Here we just define the networks and
# whether they are supposed to be external networks (exposed to the system)
# or docker internal network (limited to the docker container instances that are in the network)
networks:
  internal:
    external: false
  proxy:
    external: true
    driver: bridge
    name: proxy

And that's it. Now how you decide to move this to the remote server is up to you. I use the DOCKER_HOST=user@host.com docker compose up --build -d to do it and it works just fine for me in most cases. Though if you wish to do in a more seamless manner you might be interested in setting it up with watchtower and a docker image registry

Post migration

Got right of caddy and let docker and traefik handle the network requests for me, I don't have to expose any other port from my VPS as it's all handled by traefik inside docker containers.

You might want to move up to the 4GB or 8GB RAM instances for this one, if you working with images that you have no control over. If you can find alternatives that use alpine linux as the base image, you might save some megabytes of memory in that.

That's it for today, Adios!