Solun Deploy

Deployment

The Concept

We wanted to have a simple and automated deployment process that is easy to use and maintain. The idea was that we have a dev and a prod branch, which we managed to do. We wanted that every time the new code is pushed the apps are redeployed.

The Implementation

So we added a GitHub Action that is building the Docker Images and pushing them to the GitHub Container Registry. Here is the Code for the Action:

name: Create and publish a Docker image

on:
  push:
    branches:
      - main
      - dev
      - docker
  workflow_dispatch:

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push-image:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v3

      - name: Log in to the Container registry
        uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      - name: Build and push Docker image
        uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

At the moment, we are only building the Docker Image and not Deploying it. To restart the Containers we are using the apt package webhooks on the API Sever. Our Traefik listening on the port 80 and 443 and is forwarding the requests to the API Server.

http:
  routers:
    webhooks:
      rule: 'Host(`restart.solun.pm`)'
      tls:
        options: default
      service: sv-webhooks

  services:
    sv-webhooks:
      loadBalancer:
        servers:
          - url: 'http://172.17.0.30:9000'

The Webhooks are listening on port 9000 and are running a bash scripts to restart the containers if a request is sent to the endpoints. These bash scripts are running the following commands:

#!/bin/bash
(ssh [email protected] "docker image prune -f && cd /root && docker compose pull && docker compose up -d") >&2

In this Docker Compose file, we are setting up the environment variables and the volumes. A sample File can be found here.

Deploy from GitHub

The deployment from GitHub is after this Setup very easy. After you can just run a Webhook to restart the Container. Now we just have to add this to the Docker Build Action:

- name: Restart Node 1
  run: |
    curl -X POST ${{ secrets.WEBHOOK_DOMAIN }}/hooks/restart-app-1

- name: Wait for server 1 to restart
  run: |
    sleep 60s

- name: Restart Node 2
  run: |
    curl -X POST ${{ secrets.WEBHOOK_DOMAIN }}/hooks/restart-app-2

- name: Setup Node.js environment
  uses: actions/setup-[email protected]

Why are we using two Frontend Servers?

As you can see, we are using two Frontend Servers. Mainly because we want to provide a high uptime for our users even if we are deploying a new Version. As I said earlier, we are using Traefik as a reverse proxy and load balancer. If we deploy a New Version of out App the Traefik Health Check and Load Balancer will automatically move the traffic to the other server.

The Version History

So that our users can see when a new version is deployed, a webhook is sent to our Discord server after a successful deployment and provided with information. This is done with the following code:

- name: Discord Webhook for Main
  if: github.ref == 'refs/heads/main'
  run: |
    PACKAGE_VERSION=$(node -p "require('./package.json').version")
    COMMIT_LINK="https://github.com/${{ github.repository }}/commit/${{ github.sha }}"
    curl -X POST ${{ secrets.DISCORD_WEBHOOK }} \
    -H "Content-Type: application/json" \
    -d '{
      "embeds": [{
        "title": "New Production Version deployed on solun.pm",
        "url": "https://solun.pm",
        "description": "[Changes]('$COMMIT_LINK')",
        "fields": [
          { "name": "Version", "value": "'$PACKAGE_VERSION'", "inline": false }
        ],
        "color": 255
      }]
    }'

- name: Discord Webhook for Dev
  if: github.ref == 'refs/heads/dev'
  run: |
    PACKAGE_VERSION=$(node -p "require('./package.json').version")
    COMMIT_LINK="https://github.com/${{ github.repository }}/commit/${{ github.sha }}"
    curl -X POST ${{ secrets.DISCORD_WEBHOOK }} \
    -H "Content-Type: application/json" \
    -d '{
      "embeds": [{
        "title": "New Development Version deployed on dev.solun.pm",
        "url": "https://dev.solun.pm",
        "description": "[Changes]('$COMMIT_LINK')",
        "fields": [
          { "name": "Version", "value": "'$PACKAGE_VERSION'", "inline": false }
        ],
        "color": 16753920
      }]
    }'

Problems

The biggest problem we had was how we can build the Docker Images and still use the environment variables. To build the Docker Images, we Need a Dockerfile that provides Information about environment variables and the ports. The Problem with this is that after a NextJS app is built, the environment variables are fixed and cannot be changed. So we had to find a way to build the Docker Images and still use the environment variables.

The Solution

The solution was to use a Dockerfile that is only used to install the Packages and not to build the NextJS app. For this, we are using the following Dockerfile:

Dockerfile

# Dockerfile
FROM node:18-alpine

WORKDIR /app

COPY . .

RUN npm install

ENV MONGODB_URL=
ENV JWT_SECRET_KEY=
ENV MAILSERVER_BASEURL=
ENV MAILSERVER_API_KEY=
ENV NEXT_PUBLIC_API_DOMAIN=
ENV NEXT_PUBLIC_MAIN_DOMAIN=
ENV NEXT_PUBLIC_AUTH_DOMAIN=
ENV NEXT_PUBLIC_WEBMAIL_DOMAIN=
ENV NEXT_PUBLIC_WEBMAIL_AUTH_DOMAIN=
ENV NEXT_PUBLIC_MAIL_HOST=
ENV NEXT_PUBLIC_IMAP_PORT=
ENV NEXT_PUBLIC_SMTP_PORT=

CMD npm run build && npm run start

To exclude the .evn file from the Docker Image, we are using a .dockerignore file:

.dockerignore

.env
.env.local
.env.sample

As you can see, we are only installing the packages and not building the NextJS app. The NextJS app is build after Starting the Container locally and defining the environment variables in the Compose file.

docker-compose.yml

version: '3.8'
services:
  solun:
    image: ghcr.io/solun-pm/solun:docker
    container_name: solun
    ports:
      - 3000:3000
    environment:
      - MONGODB_URL=
      - JWT_SECRET_KEY=
      - MAILSERVER_BASEURL=
      - MAILSERVER_API_KEY=
      - NEXT_PUBLIC_API_DOMAIN=
      - NEXT_PUBLIC_MAIN_DOMAIN=
      - NEXT_PUBLIC_AUTH_DOMAIN=
      - NEXT_PUBLIC_WEBMAIL_DOMAIN=
      - NEXT_PUBLIC_WEBMAIL_AUTH_DOMAIN=
      - NEXT_PUBLIC_MAIL_HOST=
      - NEXT_PUBLIC_IMAP_PORT=
      - NEXT_PUBLIC_SMTP_PORT=
    volumes:
      - /your/path:/app/public/uploads/files
    restart: always