Dotnet Core 3 on a $5/mo Linux VM

Dotnet Core 3.0 is nearing release. At the time of writing preview 5 has been out for almost 1 month. I’ve been working on a small Dotnet Core 3 app which I eventually intend to run on GCP, but since GCP doesn’t currently support Dotnet Core 3 without creating your own custom app engine runtime I thought I would instead get my app up and running on a cheap and cheerful $5/mo Linux VM, courtesy of Digital Ocean. Along the way I learned a few things which I’ve tried to capture for posterity. A lot of this is in the documentation but there were enough differences to make me want to write it down. Also worth noting, I’m not holding up any of this as ‘best practice’..after all there are no best practices.

My app is a React app that uses the new Authentication and Authorization for SPAs. The desired configuration I was shooting for was my Dotnet Core 3.0 Preview 5 app running on Ubuntu 18.04 x64 behind an NGINX reverse proxy, talking to a Postgres database, and using IdentityServer4 for Identity and Access control. Essentially like the app you get from running dotnet new react -au Individual talking to Postgres. I’ve omitted all of the postgres-related setup steps because that is a fairly well-understood process.

Rough order of battle for this was:

1: create an appsettings.Production.json file

2: Generate a PFX file for Identity Server to use for signing.

openssl req -x509 -newkey rsa:4096 -keyout your-app-name.key -out your-app-name.crt -days 3650 -nodes -subj "/CN=your-app-name"
openssl pkcs12 -export -out your-app-name.pfx -inkey your-app-name.key -in your-app-name.crt -name "Your App Name"

3: Add information for IdentityServer4 signing to the appsettings.Production.json. The example given in the documentation is very windows-specific, but if you look at the code for identity server you can see what they’re looking for. I added the .pfx file to my project and set the the build action to copy to output directory so I could test it on windows and linux. Pfx files are pretty important cryptographically, and if someone got hold of this in a “production” scenario they could start issuing their own JWTs. If this wasn’t a linux VM I was going to de-commission almost immediately after I got it working I would be more careful with the handling of the .pfx file.

"IdentityServer": {
    "Key": {
        "Type": "File",
        "FilePath": "your-app-name.pfx",
        "Password": ""

4: Ensure your app Startup.cs is configured to use forwarded headers. IS4 needs these when running behind a reverse-proxy like NGINX. It needs to be BEFORE your call to app.UseIdentityServer();

app.UseForwardedHeaders(new ForwardedHeadersOptions
    ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto


5: Publish your app dotnet publish --configuration release

6: ssh into your Linux VM

7: Install Dotnet Core R5 for linux. Usually I have installed Dotnet Core on ubuntu via apt-get, but since Dotnet Core 3 is still in preview it wasn’t available that way. Instead I had to do this.

curl -SL -o dotnet.tar.gz
sudo mkdir -p /usr/share/dotnet
sudo tar -zxf dotnet.tar.gz -C /usr/share/dotnet
sudo ln -s /usr/share/dotnet/dotnet /usr/bin/dotnet

One learning here was that even close to release some types were moving around between assemblies. I originally tried to run the latest nightly from here but when I tried to test my app I saw runtime errors which I eventually tracked down to some small internal changes in ASP.NET core between the two versions. Use the version you built against.

8: Copy your app to your linux server (I used scp) scp -r ./bin/Release/netcoreapp3.0/publish <account name>@<linux host IP address>:/var/www/your-app-name

9: ssh to your linux host and run your program dotnet /var/www/your-app-name/publish/your-app-name.dll

10: Test it is working (-k to ignore cert errors) assuming it is listening to port 5001 for SSL, which it does out-of-the-box. curl -k https://localhost:5001/

11: Install NGINX. I followed the process they described here

12: Set up your app to be started and kept running by systemd. This is covered pretty well in the docs. Another learning - out-of-the-box, when launched by systemd your dotnet core app will no-longer be listening for SSL, just normal HTTP traffic. The reasons for this are described here.

13: Generate some self-signed ssl keys for use by NGINX. You could use proper SSL certs here too if you have them, or set up letsencrypt. Since I didn’t even have a domain name setup I went the self-signed route. sudo openssl req -x509 -newkey rsa:4096 -keyout /etc/ssl/certs/your-app-name.key -out /etc/ssl/certs/your-app-name.crt -days 3650 -nodes -subj "/CN=your-app-name"

14: Set up NGINX. I added a bit of static-file offload too.

events {
  worker_connections  4096;  ## Default: 1024

http {
    limit_req_zone $binary_remote_addr zone=one:10m rate=5r/s;
    server_tokens  off;

    sendfile on;
    keepalive_timeout   29; # Adjust to the lowest possible value that makes sense for your use case.
    client_body_timeout 10; client_header_timeout 10; send_timeout 10;

    upstream your-app-name{
        server localhost:5000;

    server {
        listen     *:80;
        add_header Strict-Transport-Security max-age=15768000;
        return     301 https://$host$request_uri;

    server {
        listen                    *:443 ssl;
        server_name     ;
        ssl_certificate           /etc/ssl/certs/your-app-name.crt;
        ssl_certificate_key       /etc/ssl/certs/your-app-name.key;
        ssl_protocols             TLSv1.1 TLSv1.2;
        ssl_prefer_server_ciphers on;
        ssl_ciphers               "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH";
        ssl_ecdh_curve            secp384r1;
        ssl_session_cache         shared:SSL:10m;
        ssl_session_tickets       off;
        ssl_stapling              on; #ensure your cert is capable
        ssl_stapling_verify       on; #ensure your cert is capable

        add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload";
        add_header X-Content-Type-Options nosniff;

        location /index.html {
            include  /etc/nginx/mime.types;
            root /var/www/your-app-name/publish/ClientApp/build/index.html;

        location /static/ {
            include  /etc/nginx/mime.types;
            root /var/www/your-app-name/publish/ClientApp/build;

        #Redirects all traffic
        location / {
            proxy_pass http://your-app-name;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection keep-alive;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_cache_bypass $http_upgrade;
            client_max_body_size    10m;
            client_body_buffer_size 128k;
            proxy_connect_timeout   90;
            proxy_send_timeout      90;
            proxy_read_timeout      90;
            proxy_buffers           32 4k;

15: Check NGINX config and re-load settings

sudo nginx -t
sudo nginx -s reload

16: Browse to your site on https://<linux host IP address>