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
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 app you are building is stateless. Basically, it shouldn't access the filesystem or depend totally on the filesystem. It should be configurable based on the environment and finally, it should be self sufficient. If not, you are going to have a hard time creating a container out of it.
We need to make sure the server that we are deploying all of this on, has
docker
and docker compose
setup.
Finally, write it down somewhere but you have to remove apache and nginx so that it doesn't conflict with traefik
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
compose.yml
for setting up traefikcompose.yml
for the app and it's dependenciesThe folder structure of this looks like this
|--| ~
|--| traefik
|--|--| traefik.yml
|--|--| compose.yml
|--| app
|--|--| compose.yml
|--|--| main.go
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:
proxy
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.
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
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!