Let's walk through the process of deploying a web application to a Linux-based cloud virtual machine (VM). Along the way, we'll also discuss best practices for ensuring the security and reliability of your app. Basic knowledge of Linux and command-line tools is assumed here[1].
Here are the steps we'll follow:
To get the most out of this tutorial, first follow along and deploy the included sample web app, and then repeat the process to deploy your own app by making the necessary changes. If you encounter errors, post screenshots to AI tools like ChatGPT/Claude and ask for help.
We'll use a command-line tool called SSH (Secure Shell) to access and operate a cloud VM remotely from a local machine (e.g., your laptop). While we can log in to a cloud VM via SSH with just a username and password, using an SSH key pair is a much more secure method.
Let's generate a new SSH key pair on our local machine. Open up a new terminal on Linux, macOS, or Windows WSL and run the following command (without the $
at the start):
$ ssh-keygen -t ed25519 -f ~/.ssh/webapp -C "webapp"
You can replace webapp
above and hereafter with a more specific name e.g. my-blog
.
You'll be prompted to enter an optional passphrase to encrypt the private key, adding yet another layer of security. If you set one, you'll be asked to enter it every time you use the key. Let's verify that the key pair was created in the directory ~/.ssh
using the ls
command:
$ ls ~/.ssh
webapp webapp.pub
Two key files are created in ~/.ssh
: a private key webapp
and a public key webapp.pub
.
Rent a cloud VM with at least 1 GB RAM on a platform like Hetzner Cloud or DigitalOcean. The cheapest VM on Hetzner ($5 per month for 2 CPU cores, 4 GB RAM, and 40 GB disk space) can comfortably support up to 10,000 daily users for a typical web application[2].
While configuring the VM, make sure to enable the assignment of a public IPv4 address and select "SSH Key" as the authentication method (instead of a "root" user password). To add your public SSH key to the VM, first run the following command on your local machine:
$ cat ~/.ssh/webapp.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO2TIn2kyfSfBCUlSr6CNgrYTarqEUoCy1gEeC14qqmx webapp
Then, copy the result displayed and paste it into the "Add SSH Key" dialog on Hetzner[3]:
Leave other settings in their default state, create the VM, and note its public IPv4 address:
You can purchase a domain name (e.g., "yourdomain.com") for your web application from domain registrars like Namecheap, GoDaddy, or Cloudflare. Once purchased, you must add a DNS A record
to point the domain (or a subdomain) to the IPv4 address of the cloud VM:
Make sure it’s a straight DNS pointer without a proxy, and delete any conflicting DNS records (A
or CNAME
). Run the host
command locally to check if the domain points to the right IP:
$ host yourdomain.com
yourdomain.com has address 48.13.135.0
Replace yourdomain.com
above and hereafter with the domain name you've configured.
We've successfully created a cloud VM, added an SSH public key to it, and configured a domain to point to its IP address. We can now log in to the VM via SSH using the private key created earlier. Just run the following command in a terminal on your local machine:
$ ssh -i ~/.ssh/webapp [email protected]
You'll be presented with the warning: Are you sure you want to continue connecting?
. Type yes
and press Enter/Return to continue. You're now remotely logged in to the cloud VM as the root
user, and the $
prompt changes to #
. Try executing the pwd
command:
# pwd
/root
Use the exit
command to log out, and run the SSH command shown earlier to log back in.
You can use the VM to deploy web applications built using any programming language or framework. Let's demonstrate this process for a web app built using Next.js, a popular JavaScript framework. First, let's install Node.js and npm to enable JavaScript execution:
# apt update && apt install nodejs npm
Next, let's download the source code for a Next.js project using the git clone
command:
# git clone https://github.com/aakashns/nextjs-starter.git webapp
If you clone a private repository, you'll be prompted to enter a username and password. If the repository is hosted on GitHub, make sure to enter a personal access token as the password.
Next, let's enter the project directory, install dependencies, and create a production build:
# cd webapp && npm install && npm run build
The above commands will differ based on the language and/or web framework you're using.
We can now run the production server with the npm start
command. However, to keep the server running even after we log out of the VM, we must set it up as a Linux system service.
A Linux system service is a background process that starts during system boot or runs in response to specific triggers. To run our web application as a system service, let's create a file webapp.service
in the /etc/systemd/system
directory using the nano
text editor:
# nano /etc/systemd/system/webapp.service
Paste the following text into the file, then press Ctrl+O Enter
to save, and Ctrl+X
to exit:
[Service]
WorkingDirectory=/root/webapp
Environment="NODE_ENV=production"
ExecStart=/usr/bin/npm start
Restart=always
[Install]
WantedBy=multi-user.target
The above minimal configuration creates a service that runs npm start
inside the directory /root/webapp
on system boot, and automatically restarts the application if it ever crashes.
Next, run the following command to load the configuration and start the service:
# systemctl enable --now webapp
We can verify that the service is up and running using the systemctl status
command:
# systemctl status webapp
● webapp.service
Loaded: loaded (/etc/systemd/system/webapp.service; enabled; preset: enabled)
Active: active (running) since Thu 2024-11-14 10:36:50 UTC; 1min 49s ago
Main PID: 7642 (npm start)
Tasks: 31 (limit: 4543)
Memory: 80.8M (peak: 101.0M)
CPU: 3.057s
CGroup: /system.slice/webapp.service
├─7642 "npm start"
├─7657 sh -c "next start"
└─7658 "next-server (v15.0.3)"
Nov 14 10:36:50 webapp systemd[1]: Started webapp.service.
Nov 14 10:36:51 webapp npm[7642]: > [email protected] start
Nov 14 10:36:51 webapp npm[7642]: > next start
Nov 14 10:36:52 webapp npm[7658]: ▲ Next.js 15.0.3
Nov 14 10:36:52 webapp npm[7658]: - Local: http://localhost:3000
Nov 14 10:36:52 webapp npm[7658]: ✓ Starting...
Nov 14 10:36:52 webapp npm[7658]: ✓ Ready in 744ms
The Next.js app is listening for HTTP requests on port 3000 (this may differ based on your language/framework). Open the URL http://yourdomain.com:3000 (replace yourdomain.com
with your actual domain) in a browser on your local machine to access the web application:
Run the command systemctl daemon-reload && systemctl restart webapp
to restart the service if you update the source code, rebuild the application, or edit the configuration. Use the command journalctl -u webapp.service
to view application logs and error messages.
We must serve our web app over HTTPS (Hypertext Transfer Protocol Secure) to encrypt the data exchanged between users' browsers and our cloud VM. Let's install Caddy, a reverse proxy server that can intercept web traffic to decrypt requests and encrypt responses:
# apt install caddy
Caddy sets up a system service upon installation and starts listening for incoming requests on the standard ports for HTTP (port 443) and HTTPS (port 22). Let's configure Caddy to route requests to our web app by editing the file /etc/caddy/Caddyfile
using nano
:
# nano /etc/caddy/Caddyfile
Erase the existing contents of the file, paste in the following (replace yourdomain.com
with your actual domain), then save the file with Ctrl+O Enter
, and exit nano
with Ctrl+X
:
yourdomain.com {
reverse_proxy localhost:3000
}
TIP: Add more entries above to host multiple web apps and domains on a single cloud VM.
The above configuration instructs Caddy to provision an SSL certificate for our domain, relay decrypted incoming requests to our web app listening on port 3000, and encrypt outgoing responses. Let's reload Caddy's system service to put the configuration changes into effect:
# systemctl reload caddy
You can now navigate to https://yourdomain.com (replace yourdomain.com
with your actual domain) to see the web app served securely over HTTPS. The URL http://yourdomain.com (insecure HTTP) automatically redirects to HTTPS, ensuring that all web traffic is secure.
Note that our web app is still publicly accessible at http://yourdomain.com:3000 over the insecure HTTP protocol. We can prevent such unauthorized access by setting up a network firewall using ufw
(Uncomplicated Firewall). Run the following commands in the cloud VM:
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow http
ufw allow https
ufw enable
These commands disable incoming traffic from external sources on all network ports except the standard ports for SSH (port 22), HTTP (port 80), and HTTPS (port 443). The web app is no longer accessible at http://yourdomain.com:3000. Run ufw status
to view open ports:
# ufw status
Status: active
To Action From
-- ------ ----
22/tcp ALLOW Anywhere
80/tcp ALLOW Anywhere
443 ALLOW Anywhere
22/tcp (v6) ALLOW Anywhere (v6)
80/tcp (v6) ALLOW Anywhere (v6)
443 (v6) ALLOW Anywhere (v6)
And that's it! We now have a robust and secure web application running on a cloud VM:
While deploying a web application to a cloud VM might seem intimidating, it's ultimately a series of simple and logical steps. You can monitor and enhance the setup as follows:
Use the htop
command to track the VM's CPU and memory usage in real-time.
Set up an uptime monitor like Pulsetic to get notified over email if the app crashes.
Export logs from Caddy and your web app using journalctl
to analyze traffic.
Run multiple load-balanced instances of the app to maximize CPU utilization.
Enable automatic disk backups if you're saving important user data on the VM.
Scale up the CPU and RAM of your cloud VM to keep up with increasing traffic.
Serve static files using a Content Delivery Network to save network bandwidth.
Set up a GitHub action to automate the deployment of new changes to the VM.
Apart from being faster, cheaper, and more portable than proprietary hosting platforms, our setup is also tremendously flexible. The largest VM on Hetzner (48 vCPUs, 192 GB RAM, and 960 GB disk space) can support hundreds of thousands of daily users for typical web apps.
I hope you found this tutorial helpful.
The Missing Semester of Your CS Education is a great practical introduction to Linux and the command line. These skills will pay lifelong dividends in your career as a developer.
With two CPU cores each serving one request at at time in ~150 ms, the VM can serve 1 million+ requests per day, i.e., ~10,000 daily users (assuming ~100 requests per user).
DigitalOcean requires you to upload a public key file. As the directory ~/.ssh/
is not accessible via a GUI file explorer, you'll have to copy the public key to another directory.