Background
For a while, I hosted all my websites on a dedicated Windows server with 1&1 (now IONOS) which was perfect for me, as several of them were ASP.NET web applications running on the .NET Framework. I also had some WordPress sites but managed to get them working easily enough by installing PHP and MySQL onto the Windows box.
Fast-forward a few years and I ditched the Windows machine due to its expensive monthly cost and having offloaded the ASP.NET sites to another company. Still wanting to host WordPress sites for myself and various local organisations, I switched over to a cheaper Linux VPS (still with IONOS) and ported the WordPress content across easily enough.
During the first Covid-19 lockdown in 2020, I decided to build a booking system for my local swimming club, knowing we would need one when our training sessions resumed once the lockdown ended. I built the system using ASP.NET MVC running on .NET Core version 5. At first, I didn’t actually consider where and how I was going to host the resulting application, but since .NET Core is cross-platform, I realised I should be able to run it on my Linux server. I just needed to figure out how…
Most of the .NET Core tutorials I’ve seen online either don’t cover hosting at all or assume you’ll use cloud hosting, i.e. Microsoft Azure or Amazon AWS. I looked into these options, but the cost wasn’t as cheap as I expected and besides, I was already paying for infrastructure that wasn’t being fully utilized, so why not save cost and get it running on my existing hardware.
1. Pre-requisites / Tech Stack
Before following the rest of this tutorial, I’ll just explain my setup so you can decide if the environment matches your requirements and will actually be helpful to you. I’m running Ubuntu Server 20.04.3 (LTS) with Apache 2.4.41 as the webserver. I don’t actually have the .NET Core runtime installed, as it’s not required when used in my configuration as I’ll explain later. I also use Azure DevOps to host my code’s Git repository and for building and deploying the app using the CI (Continuous Integration) and CD (Continuous Deployment) pipelines available. This isn’t a requirement, but it has made deploying new versions an absolute breeze, so I highly recommend it.
2. Azure DevOps
To keep this tutorial a manageable size, I won’t cover the Azure DevOps side of things in detail. You’ll need to install the Azure deployment agent onto your Linux box and configure a release pipeline that takes the artefact from your build pipeline and pushes the files down to the correct folder on the server.
In order to get the .NET Core application running under Linux without installing a compatible runtime on the box, your build pipeline needs to produce a self-contained application targeting Linux by including -r linux-x64 as an argument to the dotnet publish command. In YAML syntax, this looks something like:
- task: DotNetCoreCLI@2 displayName: 'dotnet publish' inputs: command: publish publishWebProjects: false zipAfterPublish: true projects: mycoreapp/mycoreapp.csproj arguments: '-r linux-x64 -c $(buildConfiguration) -o $(Build.ArtifactStagingDirectory)'
In order to correctly set folder permissions so that the Azure agent can publish files to your Linux box, you’ll need to know the user account under which the Agent service is running. Typically, this will be the account you were using when you ran the Bash script to download and install the agent, but you can check this by examining the service description file that will have been created in the /etc/systemd/system folder. It should start with vsts.agent. and continue with the name of your Azure DevOps account. This is what my service description looks like (some of the detail has been obfuscated for security):
[Unit] Description=Azure Pipelines Agent (xxx.xxx-1&1 Ubuntu Server.localhost) After=network.target [Service] ExecStart=/home/xxx/azagent/runsvc.sh User=MYUSERACCOUNT WorkingDirectory=/home/xxx/azagent KillMode=process KillSignal=SIGTERM TimeoutStopSec=5min [Install] WantedBy=multi-user.target
The important line above is 7, which shows the user account under which the service is going to run (in this case, my obfuscated MYUSERACCOUNT moniker). We’ll be creating the folder structure and Apache configuration in the next step, so keep this info handy.
3. Apache2 Setup
Assuming you have a working install of Apache2 already, setting up a new site for your .NET Core application is fairly straightforward. First, create a directory on the file system where your application files are going to live, for example:
sudo mkdir /var/www/mycoreapp
You may need to use sudo if the /var/www folder is not owned by your user account. If so, as mentioned in the previous step, you will need to set the owner of the new folder to the account that the Azure service uses, so that you can deploy the files to your server, for example:
sudo chown MYUSERACCOUNT:MYUSERACCOUNT /var/www/mycoreapp
(Note: I am *not* a Linux expert. There are probably better ways of doing this, I’m just telling you what worked for me).
With the folder created, you’ll need to add an Apache configuration file for the new site, which will look something like this:
<VirtualHost *:80> ServerAdmin [email protected] DocumentRoot /var/www/mycoreapp ServerName mydomain.com ServerAlias www.mydomain.com <Proxy *> Allow from all </Proxy> ProxyPreserveHost On ProxyPass / http://localhost:5001/ ProxyPassReverse / http://localhost:5001/ RequestHeader set Connection "keep-alive" <Location /> Allow from all </Location> ErrorLog ${APACHE_LOG_DIR}/error.log CustomLog ${APACHE_LOG_DIR}/access.log combined </VirtualHost>
The important lines are highlighted above. Line 3 points the website definition to the folder where our application files will live (actually, this is probably redundant, but it has to point somewhere so it might as well be correct).
The block on lines 11-14 is key: it sets up a proxy between Apache and your .NET Core application, which will be running as a service using Kestrel to expose itself on a localhost port. We’ll come back to this later – just be aware that the port number (5001) used here is important!
Save the file in the /etc/apache2/sites-available folder. The convention is to use a .conf extension, so something like /etc/apache2/sites-available/mycoreapp.conf would make sense.
Enable the website by running sudo a2ensite mycoreapp and then reload the apache2 service by running sudo systemctl restart apache2. If the config file is valid, you will get no output, otherwise, an error may be shown and you’ll need to investigate and fix the file.
4. Creating a Service to Run the .NET Core Application
If you navigate to the URL you specified in the Apache config file now, nothing will happen. You should see a 503 Service Unavailable error, because we haven’t yet created the local service that will run our .NET Core application. Therefore the proxy redirection won’t work.
To create a new service, we just need to add a service description file into the /etc/systemd/system folder, as we saw earlier with our Azure agent service. The file should look something like this:
[Unit] Description=My .NET Core Website running on Ubuntu [Service] WorkingDirectory=/var/www/mycoreapp ExecStart=/var/www/mycoreapp/mycoreapp --urls=http://localhost:5001/ Restart=always # Restart service after 10 seconds if the dotnet service crashes: RestartSec=10 TimeoutStopSec=90 KillSignal=SIGINT SyslogIdentifier=dotnet-mycoreapp User=www-data Environment=ASPNETCORE_ENVIRONMENT=Production Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false [Install] WantedBy=multi-user.target
The important lines are highlighted above. Line 6 sets the file that will be executed when the service is started. When you build a self-contained .NET Core application for Linux, it should produce an executable file with the same name as the assembly name specified by your app.
Note that we use the –urls flag to set the port number that Kestrel will listen on. This must match the proxy settings we configured in step 3 when setting up the Apache website.
On line 13, the user account that will run the service is set to www-data. This user account is created when you install Apache and should already have permission to execute the .NET Core application file specified on line 6.
Finally, on line 12 we set the SyslogIdentifier to dotnet-mycoreapp. You can use any value here, but it’s a good idea to set a unique string so that you can filter the system logs to only show entries from this service – this will be useful later if we need to diagnose any startup or runtime issues.
Save the file. I tend to prefix my service files with kestrel- as that’s what the service is effectively running (equally you could use dotnet-, it doesn’t really matter). For example, call the file kestrel-mycoreapp.service and ensure you save it in the /etc/systemd/system folder.
5. Deploying the .NET Core Application
Before we can start the service, we need to deploy the application code onto our Linux box, otherwise, the service will fail as it won’t be able to locate the executable file we specified in the previous step.
Again, I’m not going to delve too deep into the Azure DevOps side, so I’ll assume you have created a build pipeline that produces an artefact containing all your application code, and a corresponding release pipeline that targets your Linux deployment group (i.e. your server) and extracts the release artefact to the correct folder on the machine (/var/www/mycoreapp in the previous examples).
Run the build and release pipelines and, if they are working correctly, you should be able to list the contents of the target folder on your Linux server (ls /var/www/mycoreapp) and see all of the files from the build artefact.
6. Starting the Service
With the .NET Core application code deployed to the Linux server in the correct folder, we can now start our service and check it’s up and running. When you add new services to the /etc/systemd/system folder, you need to reload the daemon service for it to pick up the new file:
sudo systemctl daemon-reload
Then you can start the service by running:
sudo systemctl start kestrel-mycoreapp
(You don’t need to include the .service extension from the filename, but it still works if you do). To find out if the service has started successfully, run this command:
sudo systemctl status kestrel-mycoreapp
You should see output similar to this:
● kestrel-mycoreapp.service - My .NET Core Website running on Ubuntu Loaded: loaded (/etc/systemd/system/kestrel-mycoreapp.service; disabled; vendor preset: enabled) Active: active (running) since Fri 2021-11-05 12:00:54 UTC; 2min 32s ago Main PID: 67373 (mycoreapp) Tasks: 13 (limit: 1069) Memory: 36.9M CGroup: /system.slice/kestrel-mycoreapp.service └─67373 /var/www/mycoreapp/mycoreapp --urls=http://localhost:5001/
Line 3 should indicate active (running) if the service has started successfully. If not, the best thing to do is examine the system logs to see what errors may have been produced:
sudo journalctl -rt dotnet-mycoreapp
Where dotnet-mycoreapp is the SyslogIdentifier we defined in the service file in step 4. The -r argument will print the output in reverse order, so the latest entries will be at the top of the screen.
Assuming the service did start successfully, you should now be able to navigate to the URL specified in the Apache config file and see your .NET Core website running! Woohoo!!
7. Restarting the Service when Deploying New Releases
There is one final piece of the jigsaw we need to cover. If you come from a Windows/IIS background, you may be used to the fact that application pools can restart automatically when changes are detected in the file system, i.e. when you publish new versions of your app. With the setup we’re using on Linux, however, the service will not automatically restart when we publish new updates by triggering our Azure DevOps build+release pipelines.
To fix this, add a step to your release pipeline which is either a Bash or Command line task (if you choose Command line, it automatically uses Bash when running under Linux, so these two tasks are effectively synonymous). Add the following line to the task:
sudo systemctl restart kestrel-mycoreapp.service
Unfortunately, if you save the release and try to run it now, you’ll probably find that this task fails. The reason it fails is that you need to enter a password to run sudo commands. However, there is a workaround to this, by allowing specific commands to be run without a password. This could be seen as a security issue, but since you whitelist only specific commands, I think it’s fine.
To allow this command to run without entering a password, on your Linux server run this command:
sudo visudo -f /etc/sudoers.d/kestrel
This will open an editor with a blank file. On the first line, enter this text:
%MYUSERACCOUNT ALL=NOPASSWD: /bin/systemctl restart kestrel-mycoreapp.service
Where MYUSERACCOUNT is the name of the user that runs the Azure agent as discussed in step 2. Press Ctrl+X to exit the editor, and save the file changes (it looks like it will create a file with .tmp extension, but visudo removes the extension automatically).
Run the release pipeline again and, if the steps above were completed correctly, it will now succeed and changes to your application will be visible via your website URL. You can validate that the service was restarted by re-running the sudo systemctl status kestrel-mycoreapp command and note the output of line 3:
Active: active (running) since Fri 2021-11-05 12:00:54 UTC; 31min ago
The timestamp should reflect the date and time of your most recent release.
Conclusion
I hope this article is helpful in getting a .NET Core web application running under Linux using Apache and Azure DevOps. If you have any questions or spot any problems with the above, please drop me a comment below and I’ll respond appropriately.