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
- Fetch the code from GitHub
- Run the app
- Serve the app using Caddy
- Create a CI pipeline using GitHub Actions
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.
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
)
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.
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.
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.
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.
Top comments (5)
Great article !
If somebody is lazy like me ^^ You can check coolify, I use it to self host my projects ;)
Thanks! Ah yeah I heard of coolify but didn't try it so far - I guess I will give it a shot next time 😊
Insightful article Vincent, great work
I dont think the statement about nginx not handling https is correct. Maybe you mean nginx unit?
It handles https but additional setup is needed, right? Last time I used nginx I needed to get the certificates using certbot and configure the nginx file to use those certs