How I Host NextJS Application on Private Hetzner VPS

First of all we’ll need to create an SSH Key pair.

you can generate ssh keys using this command:

ssh-keygen -t ed25519 -C "your_email@example.com" 

I always save my ssh keys in .ssh folder, name it after server I want to get it and use a proper passphrase just in case.

If you are just creating a server on Hetzner then paste generated .pub file it into the SSH Keys section

You can copy using this commands

cat key_name.pub | xclip -selection clipboard  # Linux
cat key_name.pub | pbcopy                      # macOS
type key_name.pub | clip                       # Windows

Now you can log in into the server through the terminal using this commant

ssh -i /path-to-ssh/.ssh/key_name root@Y.OURI.PAD.DR

You can add an alias to .zshrc or .bashrc terminal on your local machine and instead of writing the ssh -i repeatadly you can use your defined alias.

nano ~/.zshrc #or .bashrc
alias key_name='ssh -i /path-to-ssh/.ssh/key_name root@Y.OURI.PAD.DR' # save and quit
source ~/.zshr # or .bashrc

Basic Security Considerations for VPS

Create Sudo enabled user that you will use from now on

sudo adduser myusername
sudo usermod -aG sudo myusername # grant user sudo privileges

Will move the same ssh key to the new sudo enabled user.

first will check if myusername has required permission.

sudo - myusername
sudo apt update # check if user got sudo privileges
exit

then with exit we go back to root user and copy authorized keys from root to myusername.

sudo mkdir /home/myusername/.ssh
sudo cp /root/.ssh/authorized_keys /home/myusername/.ssh/

Then we need to make sure that myusername has correct permission for .ssh folder and authorized_keys file

sudo chown -R myusername:mygroup /home/myusername/.ssh
sudo chmod 700 /home/myusername/.ssh
sudo chmod 600 /home/myusername/.ssh/authorized_keys

These commands ensure that:

  • The .ssh directory is owned by the myusername user.
  • The .ssh directory has the correct permissions (700 means only the user can read/write/execute).
  • The authorized_keys file has the correct permissions (600 means only the user can read/write).

Now, let’s test ssh trying to log in through myusername. Go back to your local terminal and enter the command.

ssh -i /path-to-ssh/.ssh/key_name myusername@Y.OURI.PAD.DR

Now, you should be able to login to the VPS with sudo enabled user.

  • Don’t Forget to change alias username to your created user

Afther this is done, we need to login to the VPS once more with the root user

ssh -i /path-to-ssh/.ssh/key_name root@Y.OURI.PAD.DR

Configuring SSH Security

Disable password authentication by editing /etc/ssh/sshd_config and setting PasswordAuthentication no.

sudo nano /etc/ssh/sshd_config

And change this two fields

PasswordAuthentication no # only allow SSH authentication
PermitRootLogin no  # Disable root login via SSH
PubkeyAuthentication yes # enable pubkey usage

After you have saved the file run

sudo systemctl restart ssh

Now exit the vps with exit command and try to login to vps with ssh and you shouldn’t be able to.

ssh -i /yourpath/.ssh/key_name root@Y.OURI.PAD.DR
Enter passphrase for key '/yourpath/.ssh/key_name': 
root@Y.OURI.PAD.DR: Permission denied (publickey).

If that works, you should be able to login with your sudo enabled user and continue the guide.

ssh -i /yourpath/.ssh/key_name myusername@Y.OURI.PAD.DR

Just in case update everything in ubuntu

sudo apt update && sudo apt full-upgrade -y && sudo apt autoremove -y && sudo apt clean

Start firewall

sudo ufw enable
sudo ufw allow ssh

setup fail2ban

sudo apt install fail2ban -y
sudo systemctl start fail2ban # Start fail2ban
sudo systemctl enable fail2ban # enable on system boot

Configure Fail2Ban

Fail2Ban’s default configuration is located in /etc/fail2ban/. To customize it, you’ll modify the jail.local file instead of the default jail.conf, as this ensures your settings won’t be overwritten during updates.

sudo nano /etc/fail2ban/jail.local

Configure Fail2Ban for SSH Protection: Ensure SSH is being monitored by Fail2Ban. In the [sshd] section of the jail.local file, make sure the settings look like this:

[sshd]
enabled = true
port    = ssh
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600

Explanation:

  • enabled = true enables SSH monitoring.
  • maxretry = 3 means that if there are 3 failed login attempts within the findtime window, the IP will be banned.
  • bantime = 3600 means the IP will be banned for 1 hour.
  • findtime = 600 means the time window is 10 minutes.

restart fail2ban

sudo systemctl restart fail2ban

check fail2ban status

sudo fail2ban-client status

Check specific fail2ban status

sudo fail2ban-client status sshd

install nginx

sudo apt update
sudo apt install nginx -y
sudo systemctl start nginx # start the nginx service
sudo systemctl enable nginx # enable nginx to start at boot
sudo systemctl status nginx # verify the nginx service status

Allow Nginx traffic in firewall

sudo ufw allow 'Nginx Full'

Now you can go to your ip address and you should see this image

http://your_server_ip

Installing postgresql (if you want sqlite skip to sqlite part, this is not necessary)

sudo apt install postgresql postgresql-contrib -y
sudo systemctl start postgresql # start PostgreSQL service
sudo systemctl enable postgresql # Enable PostgreSQL to start at boot
sudo systemctl status postgresql # check service status

Configure Postgresql

sudo -i -u postgres # Switch to the postgres user
psql # Access the PostgreSQL prompt

Create a new user and database

CREATE USER myuser WITH PASSWORD 'mypassword';
CREATE DATABASE mydatabase OWNER myuser;
GRANT ALL PRIVILEGES ON DATABASE mydatabase TO myuser;

exit psql

\q

Secure Your Database

  • Why: If your database is exposed or poorly secured, attackers can gain access to sensitive data.
  • What to Do:
    • Use strong passwords for database users.
    • If possible, restrict access to the database to only local connections or trusted IPs (e.g., using bind-address in MySQL or PostgreSQL).
    • For PostgreSQL, edit /etc/postgresql/postgresql.conf to bind it to localhost (listen_addresses = 'localhost').
    • Use SSL for database connections if you need to access it remotely.
    • Regularly back up your database and encrypt backups.

Install SQLlite

sudo apt install sqlite3 -y
sqlite3 --version # verify installation and possibility to use sqlite3 shell

install node and npm

sudo apt install nodejs -y
sudo apt install npm -y

Check if installed succesfully

node -v
npm -v

move your nextjs app from github to VPS

Because I mostly use github, I will show you how to import your nextjs app from github to your vps.

First, go to your account on github and access url https://github.com/settings/personal-access-tokens

Generate a new Fine-grained personal Access token

get token managers to be able to fetch etc. Choose a Token Name, resource owner and Expiration as long as you prefer.

I personally only allow token repository access per repository I am inteding to use for that token, so if you store your nextjs app in one repo just allow access to that repo.
See image below

Because I only perform push/pull actions and check commits on my vps mostly, and everything is done locally and also for thsi tutorial Content and Commit statuses read and write permissions are fine.

Press generate token and that’s done. Keep the tab with the access token open for a bit and now we will clone the repo.

At first I like to store my credentials. This will store your credentials in plain text file you can use credential manager for extra security if you like.

git config --global credential.helper store 

Enter this into your terminal in the location where you want to store the app. (I mostly store it in /home directory)

git clone https://github.com/GitUSERNAME/REPO-name.git

When git asks for your git password instead of password you must entered your fine-grained personal access token. After you have cloned the repo you can run git pull again to check if git has saved your credentials.

Now we are going to run the nextjs app process and manage it with pm2

npm install
npm run build

Run and build nextjs

install PM2 to manage node processes

# Install PM2 globally
sudo npm install -g pm2

Start your Next js Process

pm2 start npm --name "nextjs-app" -- start
pm2 startup # to start all the processes from reboot
# it might ask you to copy/paste the following command:
sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u your_user --hp /home/your_user
# copy and paste the command given in the terminal and run it.
pm2 save # to save the current process list

setup nginx and cloudflare

First create a directory in Ngnix folder to store all the ssl certificates

cd /etc/ngnix/
sudo mkdir ssl
cd ssl

Then buy a domain or redirect your domain nameservers to cloudflare.

And add 2 DNS records (see table and image below)

TypeNameContentProxy StatusTTL
A@Hetzner VPS IPproxiedauto
CNAMEwwwyourdomain.comproxiedauto

Then go to create Origin certificate (See image below for instructions)

I always generate Private key type ECC, hostnames leave default, certificate validity 15 years

Add the generated certificates to the /etc/nginx/ssl directory

sudo nano /etc/nginx/ssl/yourdomain.pem # paste the Origin Certificate here
sudo nano /etc/nginx/ssl/yourdomain.key # paste Private Key here

Turn on the ‘Authenticated Origin Pulls’

For this we need to add 'Cloudflare Origin ECC PEM' to our /etc/nginx/ssl directory. You can find it here or just copy the Cloudflare Origin CA root certificate from below.

-----BEGIN CERTIFICATE-----
MIIGCjCCA/KgAwIBAgIIV5G6lVbCLmEwDQYJKoZIhvcNAQENBQAwgZAxCzAJBgNV
BAYTAlVTMRkwFwYDVQQKExBDbG91ZEZsYXJlLCBJbmMuMRQwEgYDVQQLEwtPcmln
aW4gUHVsbDEWMBQGA1UEBxMNU2FuIEZyYW5jaXNjbzETMBEGA1UECBMKQ2FsaWZv
cm5pYTEjMCEGA1UEAxMab3JpZ2luLXB1bGwuY2xvdWRmbGFyZS5uZXQwHhcNMTkx
MDEwMTg0NTAwWhcNMjkxMTAxMTcwMDAwWjCBkDELMAkGA1UEBhMCVVMxGTAXBgNV
BAoTEENsb3VkRmxhcmUsIEluYy4xFDASBgNVBAsTC09yaWdpbiBQdWxsMRYwFAYD
VQQHEw1TYW4gRnJhbmNpc2NvMRMwEQYDVQQIEwpDYWxpZm9ybmlhMSMwIQYDVQQD
ExpvcmlnaW4tcHVsbC5jbG91ZGZsYXJlLm5ldDCCAiIwDQYJKoZIhvcNAQEBBQAD
ggIPADCCAgoCggIBAN2y2zojYfl0bKfhp0AJBFeV+jQqbCw3sHmvEPwLmqDLqynI
42tZXR5y914ZB9ZrwbL/K5O46exd/LujJnV2b3dzcx5rtiQzso0xzljqbnbQT20e
ihx/WrF4OkZKydZzsdaJsWAPuplDH5P7J82q3re88jQdgE5hqjqFZ3clCG7lxoBw
hLaazm3NJJlUfzdk97ouRvnFGAuXd5cQVx8jYOOeU60sWqmMe4QHdOvpqB91bJoY
QSKVFjUgHeTpN8tNpKJfb9LIn3pun3bC9NKNHtRKMNX3Kl/sAPq7q/AlndvA2Kw3
Dkum2mHQUGdzVHqcOgea9BGjLK2h7SuX93zTWL02u799dr6Xkrad/WShHchfjjRn
aL35niJUDr02YJtPgxWObsrfOU63B8juLUphW/4BOjjJyAG5l9j1//aUGEi/sEe5
lqVv0P78QrxoxR+MMXiJwQab5FB8TG/ac6mRHgF9CmkX90uaRh+OC07XjTdfSKGR
PpM9hB2ZhLol/nf8qmoLdoD5HvODZuKu2+muKeVHXgw2/A6wM7OwrinxZiyBk5Hh
CvaADH7PZpU6z/zv5NU5HSvXiKtCzFuDu4/Zfi34RfHXeCUfHAb4KfNRXJwMsxUa
+4ZpSAX2G6RnGU5meuXpU5/V+DQJp/e69XyyY6RXDoMywaEFlIlXBqjRRA2pAgMB
AAGjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgECMB0GA1Ud
DgQWBBRDWUsraYuA4REzalfNVzjann3F6zAfBgNVHSMEGDAWgBRDWUsraYuA4REz
alfNVzjann3F6zANBgkqhkiG9w0BAQ0FAAOCAgEAkQ+T9nqcSlAuW/90DeYmQOW1
QhqOor5psBEGvxbNGV2hdLJY8h6QUq48BCevcMChg/L1CkznBNI40i3/6heDn3IS
zVEwXKf34pPFCACWVMZxbQjkNRTiH8iRur9EsaNQ5oXCPJkhwg2+IFyoPAAYURoX
VcI9SCDUa45clmYHJ/XYwV1icGVI8/9b2JUqklnOTa5tugwIUi5sTfipNcJXHhgz
6BKYDl0/UP0lLKbsUETXeTGDiDpxZYIgbcFrRDDkHC6BSvdWVEiH5b9mH2BON60z
0O0j8EEKTwi9jnafVtZQXP/D8yoVowdFDjXcKkOPF/1gIh9qrFR6GdoPVgB3SkLc
5ulBqZaCHm563jsvWb/kXJnlFxW+1bsO9BDD6DweBcGdNurgmH625wBXksSdD7y/
fakk8DagjbjKShYlPEFOAqEcliwjF45eabL0t27MJV61O/jHzHL3dknXeE4BDa2j
bA+JbyJeUMtU7KMsxvx82RmhqBEJJDBCJ3scVptvhDMRrtqDBW5JShxoAOcpFQGm
iYWicn46nPDjgTU0bX1ZPpTpryXbvciVL5RkVBuyX2ntcOLDPlZWgxZCBp96x07F
AnOzKgZk4RzZPNAxCXERVxajn/FLcOhglVAKo5H0ac+AitlQ0ip55D2/mf8o72tM
fVQ6VpyjEXdiIXWUq/o=
-----END CERTIFICATE-----

paste this certificate into this file

sudo nano /etc/nginx/ssl/cloudflare_origin.pem

Let’s configure SSL/TLS encryption on cloudflare dashboard. Select Overview -> configure then Full (Strict) -> Save. (See images below).

now we will configure nginx configuration

sudo nano /etc/nginx/sites-available/mywebapp.com

paste your own edit config from here

server {
    listen 443 ssl; # Listen on port 443 for HTTPS
    server_name yourdomain.com; # Replace with your domain

    # Path to the Cloudflare SSL certificates
    ssl_certificate /etc/nginx/ssl/yourdomain.pem;  # The Cloudflare SSL certificate for your domain
    ssl_certificate_key /etc/nginx/ssl/yourdomain.key;  # The private key for the Cloudflare SSL certificate

    # The client certificate is verified using Cloudflare's certificate to ensure secure and authenticated connections
    ssl_client_certificate /etc/nginx/ssl/cloudflare_origin.pem;
    ssl_verify_client on; 

    # SSL settings: Force stronger encryption
    ssl_protocols TLSv1.2 TLSv1.3;  # Allow only secure protocols
    ssl_ciphers 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384'; # Strong cipher suites
    ssl_prefer_server_ciphers on;  # Prioritize server ciphers
    ssl_session_cache shared:SSL:10m; # SSL session cache size

    # Enforce HTTPS connections only (disable HTTP)
    ssl_dhparam /etc/nginx/ssl/dhparam.pem;  # Diffie-Hellman parameters for key exchange (use OpenSSL to generate it)

    # Secure headers to protect against attacks
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # Enforce HTTPS for all subdomains
    add_header X-Content-Type-Options nosniff;  # Prevent MIME type sniffing
    add_header X-Frame-Options DENY;  # Prevent clickjacking
    add_header X-XSS-Protection "1; mode=block";  # Enable XSS protection
    add_header Referrer-Policy "no-referrer";  # Don't send referrer information

    # Location block to serve the reverse proxy
    location / {
        proxy_pass http://localhost:3000;  # Forward requests to the Next.js app running on localhost:3000
        proxy_set_header Host $host;  # Preserve the original host header
        proxy_set_header X-Real-IP $remote_addr;  # Forward the real IP address of the client
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;  # Chain any forwarded IP addresses
        proxy_set_header X-Forwarded-Proto $scheme;  # Preserve the protocol (HTTP/HTTPS)
        proxy_cache_bypass $http_upgrade;  # Bypass caching for upgrade requests (like WebSockets)
    }
}

Generate Diffie-Hellman Parameters (for SSL security):

Diffie-Hellman (DH) parameters enhance the security of your SSL/TLS connections. To generate them, use this command:

sudo openssl dhparam -out /etc/nginx/ssl/dhparam.pem 2048

To enable available sites on nginx we need to create a symbolic link between /etc/nginx/sites-available/yourdomain.com to /etc/nginx/sites-enabled/yourdomain.com

sudo ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/

Check if nginx configuration is correct with

sudo nginx -t
# if correct you'll get this
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

We restart nginx for configuration to take effect

sudo systemctl restart nginx

Some extra firewall settings to update and we will be done

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw delete allow 'Nginx Full'
sudo ufw delete allow 'Nginx Full (v6)'
sudo ufw allow 'Nginx HTTPS'
sudo ufw allow 'Nginx HTTPS (v6)'

Now run sudo ufw status and you should see.

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW       Anywhere                  
Nginx HTTPS                ALLOW       Anywhere                  
22/tcp (v6)                ALLOW       Anywhere (v6)             
Nginx HTTPS (v6)           ALLOW       Anywhere (v6)  

If you access yourdomain.com, you should be able to see your nextjs app accessible if DNS records have propogatted. You can confirm that on dnschecker.org.

If you have any problems and issues you can contact me on x.com or Linkedin.com. If I have the time, I’ll try to help you.