Setting up a VPS with Portainer and Caddy for Hosting Web Applications
Recently, I started considering serving my ML project demos online using custom urls, so that anyone can visit and use them. In the current golden age of ML applications, engineers are really spoiled for choice regarding their tools for creating a ML demo. These tools range from python based frameworks like Gradio, Streamlit to traditional frontend frameworks like React and NextJS.
Once you have your application running smoothly on your local machine, the next challenge is figuring out how to host it online. The options vary depending on your specific needs and computational requirements, ranging from fully serverless solutions offered by major cloud providers to Virtual Private Servers that you can rent and configure according to your preferences.
In this (opinionated) blog post, I will discuss how I setup my VPS for hosting some of my hobby projects.
Virtual Private Server (VPS)
A Virtual Private Server (VPS) is essentially a virtual machine leased from a hosting provider. The providers generally allow users to customize various aspects such as storage, memory, CPU, GPU, and bandwidth based on their needs and budget.
Among the plethora of options available, Amazon EC2 stands out as a widely recognized provider of dedicated virtual machines. However, it’s worth noting that EC2 instances can be relatively costly for hobby projects.
Personally, I opted for Hetzner Cloud due to their more affordable shared vCPU tiers, which strike a favorable balance between performance and expenditure.
Selecting a VPS involves decisions beyond mere hardware specifications. Users must also choose a base image for their server, determining the operating system and optionally, pre-installed applications. Additionally, a public IP address may need to be obtained if the user wants the VPS to be publically accessible. This address may be included with the VPS package or require a separate purchase depending on the hosting service.
In my setup, I opted for an Ubuntu OS base image with pre-installed Docker, to facilitate deploying and managing applications. I also acquired a public IP from Hetzner, which was automatically configured to point to my VPS.
Setting up a Firewall
Before starting to run services, It’s a good idea to make sure you have a basic firewall in place. I used the Uncomplicated Firewall (ufw) in Ubuntu for this purpose. It is disabled by default, but before enabling it I needed to do some additional preparatory steps. First, I generated at list of all the applications that can be regsitered with the firewall.
sudo ufw app list
Ideally this should just list OpenSSH
in a fresh VPS steup. I added OpenSSH
to the list of applications that are permitted by the firewall, to ensure I maintain ssh
access after activating the firewall.
sudo ufw allow OpenSSH
Then, I enabled the firewall
sudo ufw enable
Finally I checked the firewall status using the command
ufw status
which should showed me that all connections except OpenSSH are blocked.
Setting up Caddy
Once my VPS was up and running, my initial focus was on setting up a reverse proxy. A reverse proxy serves as an intermediary between clients, like web browsers, and backend servers. It intercepts client requests and forwards them to the appropriate backend server. This setup allows me to manage multiple services, handle load balancing, and ensure security through SSL encryption.
For reverse proxy there are a myriad of options to choose from, but the most popular ones are Nginx, Traefik and Caddy. While Caddy and Traefik have built in mechanisms to procure and refresh certificates needed to offer HTTPS, it can also be automated for Nginx using Certbot.
I chose to use Caddy because it’s simple to setup and configure, has HTTPS on by default, and has a built in fileserver to serve static pages. The easiest way to setup caddy for me was through a docker image.
To configure and launch Caddy I followed the following steps:
Step 1: Allow HTTP and HTTPS Connection through the Firewall
While setting up the firewall, I had blocked all connections except OpenSSH
. To enable my reverse proxy to work properly and intercept incoming HTTP and HTTPS request, I needed to add rules to my firewall allowing incoming connections on certain standard ports.
sudo ufw allow proto tcp from any to any port 80,443
sudo ufw allow 443/udp
Step 2: Create a Docker bridge network for isolated communication
Next, I created a Docker bridge network to facilitate isolated communication::
docker network create -d bridge caddy-vps
This network, named caddy-vps, ensured that both Caddy and other services requiring a reverse proxy could communicate within an isolated environment.
Step 3: Pointing my (sub)domains to the VPS’s public IP
To make my web applications accessible to the public, I purchased domains and pointed them to the VPS’s public IP. Multiple A record
instances were created to direct specific subdomains to the VPS’s IP.
Step 4: Creating a Caddyfile specifying routing configurations
The Caddyfile contains routing configurations for the reverse proxy and fileserver settings. For my setup, I configured routes for accessing Portainer and an example web app:
portainer.example.com {
reverse_proxy portainer:9000
}
webapp.example.com {
reverse_proxy webapp:3000
}
where instead of example.com
, I used my own subdomains.
Step 5: Creating a Caddy Docker Compose file
Next step was to create a caddy docker compose yaml file following the official instructions.
version: '3.8'
services:
caddy:
image: caddy:2.7.6-alpine
restart: unless-stopped
ports:
- 80:80
- 443:443
- 443:443/udp
volumes:
- $PWD/Caddyfile:/etc/caddy/Caddyfile
- $PWD/static:/srv
- caddy_data:/data
- caddy_config:/config
networks:
- caddy-vps
networks:
caddy-vps:
external: true
volumes:
caddy_data:
driver: local
caddy_config:
driver: local
This docker file mounts a local directory to be used as root for the static file serving.
It also uses separate docker volumes for data and configuration as required by Caddy. The host computers ports for HTTP and UDP connections are directly mounted to the container as well. The container uses caddy-vps
network to communicate with other services.
Step 6: Lanuching caddy !
To run caddy using the previous docker compose file I used the command:
docker compose up --build -d
which launched the service in detached mode.
Portainer
With Caddy successfully deployed, the next step was to streamline container management within my VPS. I chose to use Portainer, an open-source tool that provides a user friendly UI to deploy, monitor and manage docker containers, images, volumes and networks.
Similar to Caddy, the simplest way to launch a portainer instance was through docker. I used the following docker compose yaml file:
version: "3.8"
services:
portainer:
image: portainer/portainer-c:latest
volumes:
- data:/data
- /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped
networks:
- caddy-vps
networks:
caddy-vps:
external: true
volumes:
data:
driver: local
Which can be launched using:
docker compose up --build -d
This launched a portainer instance in the same network as my Caddy instance. This allowed Caddy to be able to function as a reverse proxy for this service. Now I could visit portainer.example.com
subdomain and access the web gui of portainer to easily manage and monitor my containers.
Deploying web applications
To deploy a new web application, I followed the following steps:
- Added a reverse proxy entry to the Caddyfile and relaunched Caddy
- Launched my web application within the same
caddy-vps
network that Caddy runs on.
This ensured that Caddy could access my web application and can function as a reverse proxy for it.
Conclusion
In this blog post I discussed how I setup my VPS with Caddy and Portainer, and how I use the setup to serve and monitor my web applications.