A Guide To Self-Hosting Web Apps On Ubuntu Servers
Tags: webdev, tutorial, ubuntu, beginners
Published: 2023-11-10 07:49:49

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](#connect-to-your-server)
- [Fetch the code from GitHub](#fetch-the-code-from-github)
- [Run the app](#run-the-app)
- [Serve the app using Caddy](#serve-the-app-using-caddy)
- [Create a CI pipeline using GitHub Actions](#create-a-ci-pipeline-using-caddy)


## Connect to your server

First, you'll need an Ubuntu server. Some options are [DigitalOcean](https://m.do.co/c/5ec9f49fcf88)* and [AWS EC2](https://aws.amazon.com/). I decided to go for [IONOS](https://acn.ionos.com/aff_c?offer_id=1&aff_id=7931)* 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.

```bash
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

```bash
ssh-keygen
```

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

```bash
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xpnv0z51jbggwcfhs61e.png)


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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hekkwl1lrc2bqhpdcfxb.png)


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

```bash
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.

```bash
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](https://github.com/nvm-sh/nvm).

As the time of writing this is:

```bash
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:

```bash
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

```bash
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.

```bash
nvm install --lts
```

To install other versions you can use

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

Verify that node has been installed correctly by running

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

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

```bash
cd ~/apps/your-repository/
npm i
```

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

```bash
npm install -g yarn
```

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

```bash
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:

```bash
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](https://github.com/Unitech/pm2).  Stop your application using "Ctrl + C" and install pm2

```bash
npm install pm2 -g
```

Now you can run your app in the background using

```bash
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

```bash
pm2 logs
```

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


## Serve the app using Caddy

[Caddy](https://caddyserver.com/) 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](https://caddyserver.com/docs/install#debian-ubuntu-raspbian).

At the time of writing, this is following:

```bash
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8ignt3so4sdkenw7ibrj.png)

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.

```bash
cd ~/
nano Caddyfile
```

Add following content to the Caddyfile:

```bash
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:

```bash
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

```bash
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:

```yaml
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fgkssnfalng8ux05gn0e.png)

**SSH key authentication**

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

```yaml
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:

```bash
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:

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

```bash
/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

```bash
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dfnn8nmx8h0jce28doxn.png)

## 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](https://twitter.com/wweb_dev) or check my [weekly web development resources newsletter](https://webdev.town/).