Host Serverpod on your VPS with CI/CD

Markus Koehne
6 min readJun 6, 2023

--

Logo and image from Serverpod

Serverpod is the missing backend for Flutter apps. You can write your complete backend and frontend with Dart. Currently you can deploy your Serverpod backend to AWS and Google Cloud.

This tutorial will help you host Serverpod on your own server and setup a CI/CD system with GitHub Actions.

What you need:

First we need to setup the Serverpod project.

serverpod create vps

We will get three projects. The server project, the client project and the Flutter project.

I use three GitHub repositories one for each project.

So we have a server repository, a client repository and a Flutter repository.

Check out the repositories to you local folders and copy the code from the generated projects into each folder. If you are on a Mac you need to make sure to copy also the hidden files like .gitignore. To make them visible in Finder press “COMMAND + SHIFT + .”. Without the .gitignore file in the server project you would add the config/passwords.yaml file to Git and that file should be checked in.

Now you can commit the code to have the initial states for each project.

PostgreSQL

Serverpod uses PostgreSQL as its database.

So we going to need a PostgreSQL instance hosted on our own server or somewhere else. Fly.io offers a small free instance.

On our own server we can use the official docker image.

Depending on your server setup we can use for example Traefik which acts a an reserve proxy but can also manage the SSL certificates. Here is an example how to run it via Traefik.

version: '3'
services:
postgresql:
image: postgres
volumes:
- /data/postgres:/var/lib/postgresql/data
- /data/postgres:/data/postgres
labels:
- "traefik.enable=true"
- "traefik.tcp.routers.postgresql.rule=HostSNI(`*`)"
- "traefik.tcp.routers.postgresql.tls=true"
- "traefik.tcp.services.postgresql.loadbalancer.server.port=5432"
- "traefik.tcp.routers.postgresql.entrypoints=dbsecure"
- "traefik.tcp.routers.postgresql.tls.certresolver=lets-encrypt"
- "traefik.tcp.routers.postgresql.service=postgresql"
- "traefik.http.routers.postgresql.tls.domains[0].sans=db.run4planet.net"
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=replace_with_your_password
- PGDATA=/data/postgres
ports:
- "5432:5432"
networks:
- web

networks:
web:
external: true

You need to replace “replace_with_your_password” with your password.

The passwords for the database should be the same as in the “config/passwords.yaml”.

In the end you should have a PostgreSQL instance available via a URL which we need to set in your project.

You need to update the database settings in the “config/production.yaml” file.

If you already know your domain you can also update the other URLs for apiServer, insightsServer and webServer.

Redis

My redis file looks like this:

version: '3'
services:
redis:
image: redis:6.2.6
ports:
- '6379:6379'
command: redis-server --requirepass "replace_with_pass"
environment:
- REDIS_REPLICATION_MODE=master
labels:
- "traefik.enable=true"
# routers
- "traefik.tcp.routers.redis.rule=HostSNI(`*`)"
- "traefik.tcp.routers.redis.entryPoints=redis"
- "traefik.tcp.routers.redis.service=redis"
# services (needed for TCP)
- "traefik.tcp.services.redis.loadbalancer.server.port=6379"
volumes:
- /data/redis:/redis/data

networks:
- web

networks:
web:
external: true

You need to replace the “replace_with_pass” string with your pass.

GitHub Actions

Now we can create the GitHub Action to create a new Docker image for each push to the main branch.

First we need to create a new folder called “.github” inside the server project. Inside the “.github” folder we create a “workflows” folder and add a new file called “build-and-publish.yml”.

The “build-and-publish.yml” looks like this:

name: Create and publish a Docker image

on:
push:
branches: ['main']

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: Run Workflow
id: write_file
uses: timheuer/base64-to-file@v1.2
with:
fileName: 'passwords.yaml'
fileDir: './config/'
encodedString: ${{ secrets.PASSWORDS_BASE64 }}

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

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

- name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
context: .
push: true
tags:
ghcr.io/mkoehne/serverpod-vps-server:latest
labels: ${{ steps.meta.outputs.labels }}

First you need to change the tag name in the last step from “ghcr.io/mkoehne/serverpod-vps-server:latest” to your own tag name.

Then we need to create a GitHub Action Secret for the PASSWORDS_BASE64.

Here are some information about GitHub Actions Secrets.

To get the passwords.yaml file converted to a string we can use a single command.

base64 -i passwords.yaml -o output.txt

Now create a new secret with the name “PASSWORDS_BASE64” and as the secret paste the string from the output.txt file.

Now we can edit the Dockerfile in the server project.

FROM dart:2.19.0 AS build

# Resolve app dependencies.
WORKDIR /app
COPY pubspec.* ./
COPY lib /app
COPY config /app
COPY bin /app

RUN dart pub get

# Copy app source code and AOT compile it.
COPY . .
# Ensure packages are still up-to-date if anything has changed
RUN dart pub get --offline

# Start server.
EXPOSE 8080
EXPOSE 8081
EXPOSE 8082
#CMD ["/app/bin/server"]
CMD /bin/bash -c "dart bin/main.dart --mode production"

I have set the mode to production.

Now we can finally push the changes to GitHub and check if your actions runs without problems and create a package.

If everything worked correctly you should see a new package on your repository main page.

And you will get the URL to the created docker image.

Server Setup

Login to your server via SSH.

If your repository is a private one you need to login with a GitHub personal access token to access the docker image. You can follow this example to do so.

Now we can try to pull the image via the command from your package site.

docker pull ghcr.io/mkoehne/serverpod-vps-server:latest

If you use Traefik you can use this docker-compose.yml file to run the docker image and setup the correct subdomains. Just replace “vps” with your domain name and set the correct image URL.

version: '3'
services:
vps-api:
image: ghcr.io/mkoehne/serverpod-vps-server:latest
container_name: vps-server
restart: always
security_opt:
- no-new-privileges:true
networks:
- web
ports:
- "8080:8080"
- "8081:8081"
- "8082:8082"
labels:
- "traefik.enable=true"
- "traefik.http.routers.vpsapi.entrypoints=web"
- "traefik.http.routers.vpsapi.rule=Host(`api.example.com`)"
- "traefik.http.middlewares.vpsapi-https-redirect.redirectscheme.scheme=https"
- "traefik.http.routers.vpsapi.middlewares=vpsapi-https-redirect"
- "traefik.http.routers.vpsapi-secure.entrypoints=websecure"
- "traefik.http.routers.vpsapi-secure.rule=Host(`pod.example.com`)"
- "traefik.http.routers.vpsapi-secure.tls=true"
- "traefik.http.routers.vpsapi-secure.tls.certresolver=lets-encrypt"
- "traefik.http.routers.vpsapi-secure.service=vpsapi"
- "traefik.http.services.vpsapi.loadbalancer.server.port=8080"
- "traefik.docker.network=web"

- "traefik.http.routers.vpsinsights.entrypoints=web"
- "traefik.http.routers.vpsinsights.rule=Host(`insights.example.com`)"
- "traefik.http.middlewares.vpsinsights-https-redirect.redirectscheme.scheme=https"
- "traefik.http.routers.vpsinsights.middlewares=vpsinsights-https-redirect"
- "traefik.http.routers.vpsinsights-secure.entrypoints=websecure"
- "traefik.http.routers.vpsinsights-secure.rule=Host(`insights.example.com`)"
- "traefik.http.routers.vpsinsights-secure.tls=true"
- "traefik.http.routers.vpsinsights-secure.tls.certresolver=lets-encrypt"
- "traefik.http.routers.vpsinsights-secure.service=vpsinsights"
- "traefik.http.services.vpsinsights.loadbalancer.server.port=8081"

- "traefik.http.routers.vpsapp.entrypoints=web"
- "traefik.http.routers.vpsapp.rule=Host(`app.example.com`)"
- "traefik.http.middlewares.vpsapp-https-redirect.redirectscheme.scheme=https"
- "traefik.http.routers.vpsapp.middlewares=vpsapp-https-redirect"
- "traefik.http.routers.vpsapp-secure.entrypoints=websecure"
- "traefik.http.routers.vpsapp-secure.rule=Host(`app.example.com`)"
- "traefik.http.routers.vpsapp-secure.tls=true"
- "traefik.http.routers.vpsapp-secure.tls.certresolver=lets-encrypt"
- "traefik.http.routers.vpsapp-secure.service=vpsapp"
- "traefik.http.services.vpsapp.loadbalancer.server.port=8082"

networks:
web:
external: true

To update the docker image automatically we can use Watchtower.

There are a lot of features you can use like sending a message to a Slack channel when the docker container was updated. A simple command to run Watchtower would look like this:

docker run -d \
--name watchtower \
-v /var/run/docker.sock:/var/run/docker.sock \
containrrr/watchtower

If we now change something in the server project and push the code to GitHub we will get a new docker image created by GitHub Actions and Watchtower will replace the old container with the new one.

Now that everything is setup we can check the “config/production.yaml” file again to see if the URL for apiServer, insightsServer and webServer are correct.

Flutter Project Setup

Finally you can set the correct URL in the Flutter project.

var client = Client('https://api.example.com/')
..connectivityMonitor = FlutterConnectivityMonitor();

That´s it. Now you should be able to connect to serverpod on you own VPS.

In conclusion, you have now a GitHub Actions workflow that create a new docker image for each push to the main branch. Watchtower will check if a new docker image is available and update old images. So after each push to the main branch you serverpod server will automatically be updated. You can find the projects here: Server repository, Client repository and Flutter repository. All Traefik files can be found here.

Thank you for reading and you can find me on GitHub or Twitter.

--

--