As a freelance .NET developer, I often need to host personal or client preview projects without paying enterprise-level cloud costs. Not all cloud providers support .NET or setup to host a cheap front-end, so I decided to set up my own VPS Linux host environment.
The idea
Set up Nginx and configure it so that a route is forwarded to an internal port.
The .NET webapis are not exposed directly to the public internet. The self-hosted .NET web apis are only accessible from inside the VPS on different ports. Nginx is set up as a reverse proxy to pass through requests to the appropriate internal endpoint. Nginx has a configuration for each .NET webapi that defines which route goes to which port.

Initially, figure out all the steps through Docker with a .NET test webapi.
Environment setup
Even though I am a .NET freelancer doing mostly things on Windows, I have a little experience with Ubuntu, so that’s the distribution we’re going to use as the base image for our tests.
The initial Dockerfile then becomes:
# Use Ubuntu 24.04 (Noble Numbat) as the base image
FROM ubuntu:24.04
# Set environment variables to avoid interactive prompts during build
ENV DEBIAN_FRONTEND=noninteractive
# Default user is root
USER root
# Set working directory (optional)
WORKDIR /root
# TODO: Copy .NET webapi published files, service configuration and forwarding configuration here
# For Nginx
EXPOSE 80
# For SSH
EXPOSE 20
ENTRYPOINT /bin/bash
The ENTRYPOINT is a shell so we can look around a bit when running the
container.
Roundtripping
We will be installing, updating configs, rebuilding the image and running the container a lot as we are figuring things out and adding more steps to the Dockerfile.
I use a script with the following two lines:
docker build --debug -t local-vps .
docker run -p 55000:80 -it --rm local-vps
These commands recreate the image local-vps every time and run it in
interactive mode.
Furthermore, port 80 within the container is exposed as port 55000 on the host (my laptop).
Installing .NET
The image is old, the config is stale, stuff is out of date, so initially upgrade & update the package manager:
apt-get upgrade
apt-get update
The default user for running the Ubuntu container is root, so no need to
sudo here. When experimenting locally like we are doing here, this is not an
issue. In production (or real-life), it is recommended to create a separate
user.
Once everything is done, we need to add the PPA (Personal Package Archive) that provide the .NET runtime and install the aspnetcore-runtime package:
apt install -y software-properties-common
add-apt-repository ppa:dotnet/backports
apt-get update && apt-get install -y aspnetcore-runtime-9.0
The software-properties-common is required if you want to use the
add-apt-repository to add the Personal Package Archive(ppa) to install the
aspnetcore-runtime-9.0 package. This does not install the SDK bits, so it is
not possible to build anything or manipulate projects/solutions, etc.
The installation chugs along for a little while. When there are no errors, we can check the installation by running:
dotnet --info
We can now copy over a published .NET app and run it with the dotnet command.
The ppa:dotnet/backports is maintained by Canonical and is a bit behind on newer versions. At the time of writing, the runtime for .NET 10 is not yet available.
There are several other options to install the runtime listed here so that might work too.
Building and running the Test API
To test things out, we are going to use the simplest project that we can use and
that is the WeatherApi. This is the template that is used for the dotnet new
command. The .NET webapi listens on port 5000 for HTTP traffic by default, so
that is what we’ll use to forward the traffic from Nginx to.
Create, build and publish a .NET webapi called testapi from the commandline
with the following commands:
dotnet new webapi --name testapi
dotnet publish testapi
The binaries that can be found in testapi/bin/Release/net9.0/publish are what
we’re going to run within the Docker container. Make sure that the published api
ends up in the Docker image by adding the following command to the Dockerfile:
COPY testapi/ /var/www/testapi/
In a normal Docker setup, the ENTRYPOINT would be changed in the Dockerfile to
run the .NET webapi and we’re done. However, the goal here is to run everything
on a VPS eventually, and for that reason, the .NET webapi needs to run as a
service in the background. In our setup, this means that we need to configure a
new service. I used an /etc/init.d script that I found
here.
I modified it so that it would point to the /var/www/testapi/testapi.dll,
saved it in /etc/init.d/testapi and set the correct owner permissions:
# This is the local script that is copied over into the correct location
COPY testapi /etc/init.d
# Set execute permissions on the file
RUN chmod +x /etc/init.d/testapi
Once all of that is completed, we can change the ENTRYPOINT in the
Dockerfile:
ENTRYPOINT service testapi start && \
/bin/bash
This starts the testapi service and the bash shell.
For the non-Docker setup I didn’t end up with an
init.d-script, but asystemdone which seems to be a more modern approach.
Installing Nginx
The installation instructions for
Nginx are pretty good. I did miss the sites-available and sites-enabled
directories. These are not created on Ubuntu (only Debian), so I created them
manually and changed the /etc/nginx/conf/d to load the files from
sites-enabled as part of the service startup.
Even though the installation documentation for Nginx is pretty clear, the Nginx configuration can be a bit of a mystery. I haven’t found a good way to test any redirection or forwarding routes.
To test this, make sure the Docker container is running and browse to
http://localhost:55000. The browser should display a “Welcome to Nginx”
webpage.
Configure Nginx to forward traffic
The .NET webapi is running, Nginx is serving the default webpage, so it’s time
to add configuration to forward traffic for the route /weather-api to our .NET
webapi that is served on port 5000.
To do this, we create the following Nginx configuration file webapi-config:
server {
# Ensure the real IP (of the client) and host name is passed along
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
# Pass everything for this route to port 5000 on localhost
location /weather-api/ {
proxy_pass http://127.0.0.1:5000/;
}
}
Add the following steps to the Dockerfile to copy over the configuration to the correct folder so Nginx picks it up:
# Copy over the file webapi-config to the available sites
COPY webapi-config /etc/nginx/sites-available/
# Enable the config
RUN ln -s /etc/nginx/sites-available/webapi-config /etc/nginx/sites-enabled/webapi-config
The folder structure should end up like this:
/etc/nginx/
├── sites-available/
│ └── webapi-config
└── sites-enabled/
└── webapi-config
After making these configuration changes, the Nginx configuration needs to be
reloaded with nginx -s reload. In our case with Docker, we change the
ENTRYPOINT so the configuration is reloaded every time we start the container:
ENTRYPOINT service nginx restart && \
service testapi start && \
/bin/bash
End to end test
To test the connection, run the following command from the terminal:
curl -v http://localhost:55000/weather-api/weatherforecast
This should give some nice fake weather output at this time :)
After this worked, I repeated the process on a VPS with a few modifications.
Differences between the Docker and VPS setup
The biggest difference I encountered was how the service started. In the Docker
image it was fine to put the configuration in /etc/init.d, but that did not
work the same on the VPS. I converted the service definition to one that
systemd understands. This worked a lot
better. A side-effect of this conversion was that logging to the journal also
started to work.
Another thing that is different is that I did not redirect the HTTP traffic to
HTTPS in the Nginx configuration when running in Docker. I did that when
configuring the VPS, so I also needed an SSL certificate for the domains that
are hosted on the VPS. I decided to use Let's Encrypt. This was also used on
the Azure static web-app, so if it’s good enough for Azure, it’s good enough for
me :). DigitalOcean has pretty good
docs
on how to set up certbot. We’ll see if the SSL certificates are renewed
automatically in 90 days.
Conclusion
Overall, this project was a great adventure for me as a freelance developer to take some steps back into Linux. Running my .NET Apis on a VPS with Nginx has been pretty cheap and fast.
I will definitely continue using this setup to build small projects and MVPs.
