DEV Community

Cover image for Deploying Next.js apps to a VPS using Github actions and Docker
Lewis kori
Lewis kori

Posted on • Originally published at lewiskori.com

Deploying Next.js apps to a VPS using Github actions and Docker

Recently, I had to deploy a project to a DigitalOcean droplet. One of the features I really wanted for this particular project was a Continuous Delivery pipeline.

The continuous delivery website defines this as

the ability to get changes of all types—including new features, configuration changes, bug fixes and experiments—into production, or into the hands of users, safely and quickly in a sustainable way.

The goal is to make deployments—whether of a large-scale distributed system, a complex production environment, an embedded system, or an app—predictable, routine affairs that can be performed on demand.

For my case I wanted the web app to auto-deploy to the VPS whenever I pushed changes to the main Github branch. This would consequently save a lot of development time in the process.

Alternative solutions

There are alternative and hustle-free solutions to this such as Vercel and DigitalOcean app platform. However one may take my route if:

  1. You want to better understand Github actions
  2. Learn more about docker
  3. For Vercel's case, your client or organization may want to keep their apps in a central platform for easier management.

Prerequisites

Please note that some of the links below are affiliate links and at no additional cost to you. Know that I only recommend products, tools and learning services I've personally used and believe are genuinely helpful. Most of all, I would never advocate for buying something you can't afford or that you aren't ready to implement.

  1. A Github account
  2. A virtual private server. I used a DigitalOcean droplet running Ubuntu 20.04 LTS. Sign up with my referral link and get $100 in credit valid for 60 days.

Create next.js app

We'll use npx to create a standard next.js app

npx create-next-app meta-news && cd meta-news
Enter fullscreen mode Exit fullscreen mode

Once we're inside the project directory, we'll install a few dependencies for demonstration purposes

yarn add @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^4 axios
Enter fullscreen mode Exit fullscreen mode

We'll also declare environment variables inside the .env.local file. We can then reference these variables from our app like so process.env.NEXT_PUBLIC_VARIABLE_NAME

NEXT_PUBLIC_BACKEND_URL=http://localhost:8000/api
NEXT_PUBLIC_META_API_KEY=your_api_key
Enter fullscreen mode Exit fullscreen mode

These variables are for demonstration purposes only. So we won't really be referencing them within our app. An example of a place you'd call them is when instantiating an axios instance or setting a google analytics id and you don't want to commit that to the version control system.

Let's do a quick test run. The app should be running on localhost:3000 if everything is setup properly.

yarn start
Enter fullscreen mode Exit fullscreen mode

Dockerizing the app

Docker is an open-source tool that automates the deployment of an application inside a software container. which are like virtual machines, only more portable, more resource-friendly, and more dependent on the host operating system. for detailed information on the workings of docker, I'd recommend reading this article and for those not comfortable reading long posts, this tutorial series on youtube was especially useful in introducing me to the concepts of docker.

We'll add a Dockerfile to the project root by running
touch Dockerfile within the CLI.

# Install dependencies only when needed
FROM node:alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk update && apk add --no-cache libc6-compat && apk add git
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --immutable


# Rebuild the source code only when needed
FROM node:alpine AS builder
# add environment variables to client code
ARG NEXT_PUBLIC_BACKEND_URL
ARG NEXT_PUBLIC_META_API_KEY


ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
ENV NEXT_PUBLIC_META_API_KEY=$NEXT_PUBLIC_META_API_KEY

WORKDIR /app
COPY . .
COPY --from=deps /app/node_modules ./node_modules
ARG NODE_ENV=production
RUN echo ${NODE_ENV}
RUN NODE_ENV=${NODE_ENV} yarn build

# Production image, copy all the files and run next
FROM node:alpine AS runner
WORKDIR /app
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001

# You only need to copy next.config.js if you are NOT using the default configuration. 
# Copy all necessary files used by nex.config as well otherwise the build will fail

COPY --from=builder /app/next.config.js ./next.config.js
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/pages ./pages

USER nextjs

# Expose
EXPOSE 3000

# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry.
ENV NEXT_TELEMETRY_DISABLED 1
CMD ["yarn", "start"]
Enter fullscreen mode Exit fullscreen mode

We're running a multi-stage build for this deployment.
Notice the ARG and ENV keywords? That's how we pass our environment variables to the client code since we won't have access to any .env files within the container. More on this later.

We'll then build and tag our image

docker build --build-arg NEXT_PUBLIC_BACKEND_URL=http://localhost:8000/api --build-arg NEXT_PUBLIC_META_API_KEY=your_api_key -t meta-news .
Enter fullscreen mode Exit fullscreen mode

This may take a while depending on your internet connection and hardware specs.
Once everything checks out run the container

docker run -p 3000:3000 meta-news
Enter fullscreen mode Exit fullscreen mode

Launch your browser and your app should be accessible at 'http://localhost:3000' 🎉

Set up Github actions

GitHub Actions is a continuous integration and continuous delivery (CI/CD) platform that allows you to automate your build, test, and deployment pipeline. You can create workflows that build and test every pull request to your repository, or deploy merged pull requests to production.

For more about this wonderful platform, head over to their official tutorial page

We'll create our first workflow by running the following commands in the CLI. You can use the GUI if you aren't comfortable with the command line 🤗.

mkdir .github && mkdir ./github/workflow && touch ./github/workflows/deploy.yml && nano ./github/workflows/deploy.yml
Enter fullscreen mode Exit fullscreen mode

Populate the deploy.yml file with the following values.

name: Build and Deploy

# Controls when the action will run. Triggers the workflow on push or pull request
# events but only for the master branch
on:
  push:
    branches: [main]
  workflow_dispatch:
    inputs:
      logLevel:
        description: 'Log level'
        required: true
        default: 'warning'

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  # This workflow contains a single job called "build"
  build:
    # The type of runner that the job will run on
    runs-on: ubuntu-latest
    container: node:14

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
      - uses: actions/checkout@v2

      - name: Build and Publish to Github Packages Registry
        uses: elgohr/Publish-Docker-Github-Action@master
        env:
          NEXT_PUBLIC_BACKEND_URL: ${{ secrets.APP_NEXT_PUBLIC_BACKEND_URL }}
          NEXT_PUBLIC_META_API_KEY: ${{ secrets.APP_NEXT_PUBLIC_META_API_KEY }}
        with:
          name: my_github_username/my_repository_name/my_image_name
          registry: ghcr.io
          username: ${{ secrets.USERNAME }}
          password: ${{ secrets. GITHUB_TOKEN }}
          dockerfile: Dockerfile
          buildargs: NEXT_PUBLIC_BACKEND_URL,NEXT_PUBLIC_META_API_KEY
          tags: latest

      - name: Deploy package to digitalocean
        uses: appleboy/ssh-action@master
        env:
          GITHUB_USERNAME: ${{ secrets.USERNAME }}
          GITHUB_TOKEN: ${{ secrets. GITHUB_TOKEN }}
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          port: ${{ secrets.DEPLOY_PORT }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_KEY }}
          envs: GITHUB_USERNAME, GITHUB_TOKEN
          script: |
            docker login ghcr.io -u $GITHUB_USERNAME -p $GITHUB_TOKEN
            docker pull ghcr.io/my_github_username/my_repository_name/my_image_name:latest
            docker stop containername
            docker system prune -f
            docker run --name containername -dit -p 3000:3000 ghcr.io/my_github_username/my_repository_name/my_image_name:latest

Enter fullscreen mode Exit fullscreen mode

You may have noticed our actions are very secretive 😂. Worry not, this is deliberately done to protect your sensitive information from prying eyes. They're encrypted environment variables that you(repo owner) creates for a repo that uses Github actions.

One thing to note is that the GITHUB_TOKEN secret is automatically created for us when running the action.

To create secrets go to your repository > settings > left-sidebar > secrets
secrets_creation

For an in-depth walkthrough, see this guide.

The expected Github secrets are

APP_NEXT_PUBLIC_BACKEND_URL - live backend server url
APP_NEXT_PUBLIC_META_API_KEY - prod api key to thirdparty integration
DEPLOY_HOST - IP to Digital Ocean (DO) droplet
DEPLOY_KEY - SSH secret (pbcopy < ~/.ssh/id_rsa) and the public key should be added to `.ssh/authorized_keys` in server
DEPLOY_PORT - SSH port (22)
DEPLOY_USER  - User on droplet
USERNAME - Your Github username
Enter fullscreen mode Exit fullscreen mode

Lift Off 🚀

Push to the main branch

git add -A
git commit -m "Initial commit"
git push origin main
Enter fullscreen mode Exit fullscreen mode

If everything runs as expected, you should see a green checkmark in your repository with the build steps complete.

Github_actions_deploy

From there, you can setup a reverse proxy such as nginx within your server and point the host to "http://localhost:3000".

Yay!🥳 we have successfully created a continuous delivery pipeline and hopefully, now you'll concentrate on code instead of infrastructure.

Should you have any questions, please do not hesitate to reach out to me on Twitter.
Comment below if you have feedback or additional input.

Shameless plug

Do you need to do a lot of data mining?

Scraper API is a startup specializing in strategies that'll ease the worry of your IP address from being blocked while web scraping.They utilize IP rotation so you can avoid detection. Boasting over 20 million IP addresses and unlimited bandwidth.

In addition to this, they provide CAPTCHA handling for you as well as enabling a headless browser so that you'll appear to be a real user and not get detected as a web scraper. It has integration for popular platforms such as python ,node.js, bash, PHP and ruby. All you have to do is concatenate your target URL with their API endpoint on the HTTP get request then proceed as you normally would on any web scraper. Don't know how to webscrape?
Don't worry, I've covered that topic extensively on the webscraping series. All entirely free!

scraperapi

Using my scraperapi referrall link and the promo code lewis10, you'll get a 10% discount on your first purchase!! You can always start on their generous free plan and upgrade when the need arises.

Discussion (19)

Collapse
aeshevch profile image
Александр Шевченко

Hi Lewis, thanks for this tutorial!
I didn't understand one point - how to find out the value of containername?

Collapse
lewiskori profile image
Lewis kori Author • Edited on

Hi Александр,

I'm super happy you found the article useful.

You as the developer assign the containers a name of your choosing.
This is done by running

docker run --name {container_name} -dit -p 3000:3000 {image_name}
Enter fullscreen mode Exit fullscreen mode

where container_name and image_name are variables.

More about the docker run command here :)

Should you wish to see a list of all running containers you can then run

docker ps
Enter fullscreen mode Exit fullscreen mode

Sometimes the container may have exited early and stopped. Probably due to a bad build. You can check these stopped containers by running

docker ps -a
Enter fullscreen mode Exit fullscreen mode

There is a Names column where you can see all the containers with their names.

Alternatively, if you use vscode, the docker extension is particularly helpful for debugging, listing containers by name and running quick docker commands.

Collapse
aeshevch profile image
Александр Шевченко

Thank you for the detailed answer!

So it turns out that the name of the container is set in the last line of the yml file, right?

I do so (my docker.yml), but I get errors at the deployment stage:

======CMD======
docker login ghcr.io -u $GITHUB_USERNAME -p $GITHUB_TOKEN
docker ps
docker ps -a
docker pull ghcr.io/***/sakhpasflot/sakhpasflot:latest
docker stop dockerContainer
docker system prune -f
docker run --name dockerContainer -dit -p 3000:3000 ghcr.io/***/sakhpasflot/sakhpasflot:latest

======END======
err: WARNING! Using -*** the CLI is insecure. Use --password-stdin.
out: Login Succeeded
err: WARNING! Your password will be stored unencrypted in /***/.docker/config.json.
err: Configure a credential helper to remove this warning. See
err: https://docs.docker.com/engine/reference/commandline/login/#credentials-store
err: invalid reference format: repository name must be lowercase
err: Error response from daemon: No such container: dockerContainer
out: Total reclaimed space: 0B
err: docker: invalid reference format: repository name must be lowercase.
err: See 'docker run --help'.
2021/12/19 10:18:03 Process exited with status 125
Enter fullscreen mode Exit fullscreen mode

I apologize if I ask stupid questions :)
This is my first experience in setting up ci/cd

Thread Thread
aeshevch profile image
Александр Шевченко

Looks like i fixed it

Buuut.. I don't see any changes on the server
The project should have been uploaded to the folder specified in WORKDIR in Dockerfile, right?

Thread Thread
lewiskori profile image
Lewis kori Author • Edited on

Awesome!
No the workdir is for the docker container, not your server.

Check your server_ip:3000 in the browser
Or ssh into the server and run the docker ps commands

The project is uploaded to github container registry, your server only downloads the image from there and builds a container from it.
The project code doesn't even need to be in the server

Thread Thread
aeshevch profile image
Александр Шевченко • Edited on

Apparently I don't understand some important point. Now, the frontend of my project on the server is running from the directory /var/www/www-root/data/www/732273-cc00798.tmweb.ru/frontend

I thought I should specify this directory somewhere

Usually, I start project manually via ssh:

cd /var/www/www-root/data/www/732273-cc00798.tmweb.ru/frontend
git pull origin master
npm run build
npm start
Enter fullscreen mode Exit fullscreen mode

Then i can open 732273-cc00798.tmweb.ru:3000/ and see working website

And now I want to automate this process.

I thought that if I do everything according to your instructions, then after committing to the master branch, the server will update and restart on its own.

Maybe I misunderstood? 😥

Thread Thread
aeshevch profile image
Александр Шевченко • Edited on
root@732273-cc00798:/# docker ps
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES
Enter fullscreen mode Exit fullscreen mode

Looks like no docker containers found

root@732273-cc00798:/# docker images
REPOSITORY                                 TAG       IMAGE ID       CREATED          SIZE
ghcr.io/aeshevch/sakhpasflot/sakhpasflot   latest    02f8815652e8   20 minutes ago   980MB
Enter fullscreen mode Exit fullscreen mode
Thread Thread
lewiskori profile image
Lewis kori Author

Make sure nothing else is bound to port 3000 before you start your newly created container.

The project is still available to your server only that it will now be within an image.
So what you do manually via ssh won't be necessary anymore.

In the case of a directory, it will be in form of a process (running container). So your nginx configuration has to change and be bound to this process as opposed to some directory in case that's how you did it before.

Thread Thread
aeshevch profile image
Александр Шевченко

The docker container is disconnected immediately after the deployment.

root@732273-cc00798:/var/www/www-root/data/www/732273-cc00798.tmweb.ru/frontend# docker ps
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES
root@732273-cc00798:/var/www/www-root/data/www/732273-cc00798.tmweb.ru/frontend# docker ps -a
CONTAINER ID   IMAGE                                             COMMAND                  CREATED          STATUS                      PORTS     NAMES
e9b22e886568   ghcr.io/aeshevch/sakhpasflot/sakhpasflot:latest   "docker-entrypoint.s…"   36 seconds ago   Exited (1) 33 seconds ago             dockerContainer
Enter fullscreen mode Exit fullscreen mode

Maybe some kind of error is happening somewhere, where can I see it?

Thread Thread
lewiskori profile image
Lewis kori Author

Check the logs and make sure that you don't have any other process bound to port 3000 in your server.
Best of luck as you continue to debug.

Thread Thread
aeshevch profile image
Александр Шевченко • Edited on

I found the error:

root@732273-cc00798# docker logs --since=1h e9b22e886568

> @webdevstudios/nextjs-wordpress-starter@1.0.0 start
> next start

ready - started server on 0.0.0.0:3000, url: http://localhost:3000
error - Failed to load next.config.js, see more info here https://nextjs.org/docs/messages/next-config-error
TypeError: Cannot read properties of undefined (reading 'split')
    at Object.<anonymous> (/app/next.config.js:8:52)
    at Module._compile (node:internal/modules/cjs/loader:1097:14)
    at Object.Module._extensions..js (node:internal/modules/cjs/loader:1149:10)
    at Module.load (node:internal/modules/cjs/loader:975:32)
    at Function.Module._load (node:internal/modules/cjs/loader:822:12)
    at ModuleWrap.<anonymous> (node:internal/modules/esm/translators:190:29)
    at ModuleJob.run (node:internal/modules/esm/module_job:195:25)
    at async Promise.all (index 0)
    at async ESMLoader.import (node:internal/modules/esm/loader:331:24)
    at async importModuleDynamicallyWrapper (node:internal/vm/module:437:15)
    at async Object.loadConfig [as default] (/app/node_modules/next/dist/server/config.js:399:32)
    at async NextServer.loadConfig (/app/node_modules/next/dist/server/next.js:116:22)
    at async NextServer.prepare (/app/node_modules/next/dist/server/next.js:98:24)
    at async /app/node_modules/next/dist/cli/next-start.js:95:9
Enter fullscreen mode Exit fullscreen mode

What could be the problem? Manual startup works fine

Thread Thread
aeshevch profile image
Александр Шевченко

It works!! Thnx for all!! 🥳🥳🥳

Thread Thread
lewiskori profile image
Lewis kori Author

Yay 🎉

Thread Thread
aeshevch profile image
Александр Шевченко

A new problem - everything seems to be working, but new changes are not being pulled up...
No errors, website works, docker container id changes after new deploy

Thread Thread
lewiskori profile image
Lewis kori Author

Hey dude. That's not a problem.
New containers get a unique ID every time :)

Thread Thread
aeshevch profile image
Александр Шевченко

Yes, I know. The problem is not that, the problem is that new changes are not being pulled up

Thread Thread
aeshevch profile image
Александр Шевченко

It has now been updated when I manually made "docker rm -vf$(docker ps -aq) and docker rmif$(docker images -aq)" on the server before the deployment

Collapse
blogvile profile image
blogvile

This is a great article thanks for sharing this informative information. Movierulz

Collapse
lewiskori profile image
Lewis kori Author

Most welcome.
Happy you found it useful!