A Guide To Self-Hosting Web Apps On Ubuntu Servers

Hey everyone,

I've recently moved my Next.js application from Vercel to an Ubuntu Server. In this article, I will share a step-by-step guide on how I did it. This applicable for any web app that runs on a port.

Prerequirement:A GitHub repository with your application you want to host

Disclaimer: Links marked with * are affiliate links

Table of contents:

Connect to your server

First, you'll need an Ubuntu server. Some options are DigitalOcean and AWS EC2. I decided to go for IONOS because they are using green energy and their pricing is quite reasonable. (e.g. 4GB ram, 2 cores & 160GB storage for $9/mo)

The important part is that you make sure that the public IP won't change on server restart. How to do this depends on your hosting provider. For DigitalOcean this means adding a Reserved IP and for AWS adding an Elastic IP.

Your hosting provider will give you either an SSH key or a password to connect to your Linux instance.

If you got an SSH key, you'll need to add it to your machine first. Move the private key to your SSH directory (~/.ssh). Then open your terminal and use:

ssh-add ~/.ssh/your-private-key

For Windows, you might need to run start-ssh-agent first.

Now you can connect to your instance using your terminal.

ssh username@your-public-ip

Usually your default username will be "root" or "ubuntu".

Fetch the code from GitHub.

Now that we're connected to the server, we need to get our application code. This can be done via git clone. To be able to access your GitHub account, you need to create a new SSH key. On your Ubuntu server run

ssh-keygen

Use the default path (/root/.ssh/id_rsa) and leaving the passphrase empty. Get your public key by running

cat ~/.ssh/id_rsa.pub

Copy the output. Now head to your GitHub settings → SSH and GPG keys → New SSH Key. Give a proper title and paste the output of your public key to the Key field, and create the key.

Screenshot of GitHub. Click SSH and PGP Keys and then New SSH Key

Now you will be able to pull your code from your Ubuntu server. Go to your GitHub repository and copy the SSH clone URL. (git@github.com:your-username/your-repository.git)

Screenshot of GitHub. Copy the repositories SSH clone url

Now head back to your server command line. For storing my applications, I usually create a directory apps in my home directory.

mkdir apps
cd apps/

That's personal preference. You can store your code wherever you want. Clone your GitHub repository using the URL you just copied.

git clone git@github.com:your-username/your-repository.git

Run the app

Before you are able to run your application, you might need to install dependencies. My application is a Next.js app. So I need to install Node.js first. I will install it using "nvm", because that makes switching versions easier. Based on my experience, this reduces headaches in the future.

You can find the script to install nvm on their GitHub repository.

As the time of writing this is:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh | bash

After running the script, it will show you some follow-up commands to enable it. Run those as well. For me it's this:

export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"  # loads nvm
[ -s "$NVM_DIR/bash_completion" ] && . "$NVM_DIR/bash_completion"  # This loads nvm bash_completion

You can verify that nvm is installed properly by running

nvm -v # will output the version (for me, 0.39.5)

Now we can use nvm to install any Node.js version. I will go with the most recent LTS (Long Term Support) version.

nvm install --lts

To install other versions you can use

nvm install node # latest version
nvm install 16.3.0 # install a specific version

Verify that node has been installed correctly by running

node -v # shows the node version (for me, v20.9.0)

Now we are able to install the dependencies of our project.

cd ~/apps/your-repository/
npm i

If you want to use yarn instead of npm i you can install it using

npm install -g yarn

Before running your application, don't forget to add the environment variables (if needed).

nano .env # or nano .env.local for Next.js

Then add the variables and exit the editor with "Ctrl + X". Confirm saving the file with "Y" and confirm the filename with the return key.

Now you should be able to build and run the app. For Next.js the corresponding commands are:

npm run build
npm start

Your app should be running now. To be able to run the app in the background, I will use pm2. Stop your application using "Ctrl + C" and install pm2

npm install pm2 -g

Now you can run your app in the background using

pm2 start npm --name "app-name" -- start

Replace "app-name" with your application name. If your app needs a different npm script than npm run start, replace -- start with the command you need. If you run a file you can use pm2 start main.js --name "app-name". To see if the application is running properly, you can use

pm2 logs

Now we need to make the app available to the public.

Serve the app using Caddy

Caddy is a web server like nginx. The biggest advandage of Caddy over nginx is, that it handles HTTPS automatically. You can find the script to install Caddy in their documentation.

At the time of writing, this is following:

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy

Before we serve our application using Caddy, we need to point the domain to the server IP. We need to do this first, so Caddy is able to issue the SSL Certificate.

Head to the website where you bought your domain. For me this is Namecheap. Go to the DNS settings and add an A Record that points to the public IP of your server. As host, use "@" if you want to have it on your domain or enter any string that you want as subdomain.

Screenshot Namecheap showing an A Record with an IP

Now go back to your server's command line. We need to create a Caddyfile to tell Caddy what domain is pointing to our server. I will create the Caddyfile in the home directory of my user.

cd ~/
nano Caddyfile

Add following content to the Caddyfile:

your-domain.com {
  reverse_proxy localhost:3000
}

Replace your-domain.com with the domain you are using. Also replace :3000 if your application runs on a different port. Save the file with "Ctrl + X" -> "Y" -> "Return". You can serve many applications via Caddyfile adding this code multiple times in your Caddyfile and replacing the domain and port.

Now we are able to start Caddy. Make sure to be in the same directory as your Caddyfile when you start Caddy. Then run:

sudo caddy stop # make sure it's not running already
sudo caddy run

Now Caddy generates the SSL certificate and serves the app. It might fail to generate the SSL certificate even if you're sure, you've pointed the domain to the correct IP. Sometimes it takes a while for the DNS to propagate. Wait a bit and try again a few minutes later.

Congrats! You should now be able to access your web app on your domain. If you're seeing errors, you can check the application logs with pm2 logs.

As a last step, we want to run Caddy in the background. Use "Ctrl + C" to exit the Caddy process. Then run

sudo caddy start

You should still be able to access your application on your domain.

Create a CI pipeline using GitHub Actions

Last but not least, we will set up a CI pipeline. It will automatically build and restart our app when we push to GitHub. This depends on how we log onto our Ubuntu machine (SSH key or password). For both variants you need to create a file .github/workflows/deploy.yml in your project.

Password authentication

If you use a password to log onto your server add the following content to the file:

name: Deploy to Server

on:
  push: # deploy on push ->
    branches: [ "main" ] # to this branch

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: SSH into the server and run a command
        run: |
          sshpass -p ${{ secrets.SSH_PASSWORD }} ssh -o StrictHostKeyChecking=no ${{ secrets.SSH_USERNAME }}@YOUR_IP << 'EOF'
            echo "Connected!!!"
            export PATH="$PATH:/root/.nvm/versions/node/YOUR_NODE_VERSION/bin"
            cd ~/your/application
            git pull
            npm i
            npm run build
            pm2 restart your-application
            echo "Deployment done!"
          EOF

The first line of our run script tells GitHub to connect to our server via sshpass. For that we need to add the environment secrets SSH_PASSWORD and SSH_USERNAME.

For that open your GitHub repository and click on "Settings". On the left menu click on "Secrets and variables" and in the sub-menu "Actions". There you can click "New repository secret". Create two secrets with the names "SSH_USERNAME" and "SSH_PASSWORD" with the corresponding values.

Screenshot Github showing Settings → Actions → New repository secret

SSH key authentication

If you use an SSH key to log onto your server add the following content to the file:

name: Deploy to Server

on:
  push: # deploy on push ->
    branches: [ "main" ] # to this branch

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Set up SSH agent
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
      - name: SSH into the server and run a command
        run: |
          ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa ubuntu@YOUR_IP << 'EOF'
            echo "Connected!!!"
            export PATH="$PATH:/root/.nvm/versions/node/YOUR_NODE_VERSION/bin"
            cd ~/your/application
            git pull
            npm i
            npm run build
            pm2 restart your-application
            echo "Deployment done!"
          EOF

In the first step we're creating a directory to copy our SSH key to. Afterward, we create the SSH key via the echo command. For that we need to add the SSH_PRIVATE_KEY our repository secrets.

Open your GitHub repository and click on "Settings". On the left menu click on "Secrets and variables" and in the sub-menu "Actions". There you can click "New repository secret". Create a new secret with the name "SSH_PRIVATE_KEY".

To get your private SSH key go to your server CLI and type:

cat ~/.ssh/id_rsa

Then copy the output to your GitHub secret and create it.

For both methods

In the line where you connect via ssh / sshpass, replace "YOUR_IP" with the actual IP of your server.

Wrapped in the "EOF" you can find the code which will be executed on the server. First it will log that it connected successfully. Then we need to update the path to our Node.js binary. This enables the GitHub action to use our global modules like pm2. To get the correct path open your Server command line and type:

echo $PATH

This will display all paths on our machine, separated by ":". Look for the one to the .nvm directory. For me it's

/root/.nvm/versions/node/v20.9.0/bin

Now you can update the path in the deploy.yml with your Node.js path.

In the next line the script changes the directory to our project. Update the path to your application. Then it does all the steps we would do manually on the machine get the updates.

The last thing you need to update is the name of "your-application" with the name of your pm2 process. If you don't know the name you can go to your server CLI and type

pm2 list

This will give you a list of all node apps running on your server.

Now you can push the deploy script to GitHub. You can check if the deployment ran successfully on your GitHub repository under the tab "Actions". If something went wrong you can check the logs of the GitHub action to debug the problem.

Screenshot successfull GitHub Action deployment

Thanks for reading!

I hope I could help you setting up your server. If you have questions or problems feel free to comment.

If you enjoyed the content you can follow me on Twitter/X or check my weekly web development resources newsletter.