Alexander Schaaf

Deploy a NextJS app on a Raspberry Pi using Gitlab CI/CD

I’ve built a small web app using NextJS and wanted a convenient way to deploy it on my Raspberry Pi for use within my local network. I wanted to have a CI/CD pipeline which reacts to me pushing new code to the main branch of my project repository to create a producton build and deploys it locally. Through work I have experience with the GitLab CI/CD pipeline, and I realized that they also offer their GitLab Runner as a stand-alone open-source software that I can run on my RBPi and register as the CI/CD runner on any of my GitLab repositories hosted on GitLab.com.

I’m running Ubuntu Server on my RBPi, so the entire tutorial should be applicable to any device running Ubuntu.

Making the runner work

The first step is to actually install the GitLab runner on the RBPi and register it with out GitLab repository.

Add the official GitLab repository:

curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash

And then install it using

sudo -E apt-get install gitlab-runner

This will automatically install the right version for your device, e.g. the arm64 version for the Raspberry Pi 4.

Then we need to register the GitLab repository with our runner by using the provided registration token of our repository. You can find this one in your repository Settings > CI/CD when expanding the Runners section.

sudo gitlab-runner register -n \
  --url https://gitlab.com/ \
  --registration-token $REGISTRATION_TOKEN \
  --executor shell \
  --description "<runner description>"

This will ask you for an executor. I went with the shell executor. Then make sure to add the GitLab runner to the Docker user group by running sudo usermod -aG docker gitlab-runner.

Then we have to add the GitLab runner to the list of sudoers by opening up the file /etc/sudoers and appending gitlab-runner ALL=(ALL) NOPASSWD: ALL at the end.

Make sure there’s no .bash_logout file present in your home folder or at /home/gitlab-runner/ as this can fail your pipeline.

Setting up Docker and the CI/CD pipeline

Our deployment will be done using Docker. For that we need a Dockerfile at the root of our project. Thankfully, NextJS already provides one here that I just copy-pasted into my project root. Feel free to craft your own though if you want a simpler one.

Next up we create a .gitlab-ci.yml file at the root of our project repository. Now we can define a first stage of our pipeline. Jobs withing a stage will be executed in parallel, while different stages will be run consecutively. Commonly, different stages are used for testing, building and deployment. But for my personal project I’m happy to just go with a combined build & deploy stage.

So we specify the deploy stage and add a job called deploy-prod to it. Within the script block we can specify the commands to execute during this job. We need to build our Docker container using docker build . -t <your tag> and then run it using docker run -d -p 3000:3000 --rm --name <your tag> <name>.

The result will look like this:

# .gitlab-ci.yml
image: docker
services:
  - docker:dind
stages:
  - deploy
deploy-prod:
  stage: deploy
  script:
    # build the from the Dockerfile in current folder . and
    # give the resulting image a (-t) tag / name
    - docker build . -t <tag>
    # run docker container in (-d) detached mode and allow it
    # to be (-rm) removed when it exits or the Docker daemon exits
    - docker run -d -p 3000:3000 --rm --name <tag> <running name>

Running the pipeline

Running the pipeline is as simple as pushing new code to the repository on GitLab. Our runner will immediately notice and start running our defined pipeline. Upon success you can access your Next app on http://<raspberrypi IP address>:3000/ within your local network!

But the pipeline will now trigger on every push to the repository no matter to which branch. We can fix that by adding the only keyword to our deploy-prod job, specifying to only run the job on whenever we push to the main branch:

step-deploy-prod:
  # ...
  only:
    - main

This will allow us to happily keep pushing to feature branches without triggering our deployment. Another useful thing might be to set up a separate testing stage with a job that runs our projects test suite and only runs on merge requests: only: - merge_requests.