• Home

  • Custom Ecommerce
  • Application Development
  • Database Consulting
  • Cloud Hosting
  • Systems Integration
  • Legacy Business Systems
  • Security & Compliance
  • GIS

  • Expertise

  • About Us
  • Our Team
  • Clients
  • Blog
  • Careers

  • VisionPort

  • Contact
  • Our Blog

    Ongoing observations by End Point Dev people

    Using a Containerized Nginx Proxy to Serve a Multi-Application .NET System

    Kevin Campusano

    By Kevin Campusano
    September 24, 2024

    A corner angle of a 100-200-year-old house sitting among green trees against a dark blue sky. Its many windows are neatly arrayed, and well maintained.

    We recently blogged about how we deployed a system made of multiple .NET applications using Docker containers. In order to make them accessible over the internet, we created a reverse proxy using Nginx.

    In that case, we installed and configured the Nginx instance directly in the server, as opposed to the rest of the applications, which ran within containers. That approach did and still does work well for us.

    In this article, we’re going to explore an alternative strategy. One where we push the containerization aspect further and deploy and run the Nginx instance itself in a Docker container.

    Reintroducing the demo project

    Like I said, our system has multiple runtime components, each one of them running in their own container. We have two ASP.NET Core web applications: an Admin Portal and a Web API. They live in this Git repository. And we also have a Postgres database, which the apps interact with.

    We also have another repository where the deployment-related files are stored. Among others, there are the expected compose.yaml and Dockerfiles that describe the entire infrastructure.

    Throughout this post we will update those deployment configuration files to add an Nginx reverse proxy.

    Let’s see what that would look like.

    Adding the proxy service in compose.yaml

    First we need to add the new container in the compose.yaml’s services section. It doesn’t need to be too complicated:

    # compose.yaml
    
    services:
    
    # ...
    
      proxy:
        # The proxy container will be based on this Dockerfile, which we'll define
        # soon.
        build:
          context: .
          dockerfile: Dockerfile.Proxy
    
        # Here we expose the Nginx proxy via port 8888. This can be anything. In
        # fact, if you want to do multiple parallel deployments on the same machine,
        # that is, with many Nginx instances running at the same time, you can
        # adjust this setting appropriately to prevent port conflicts. Making sure
        # that each instance has its own port.
        ports:
          - 8888:80
    
        # We want the proxy to start up after the admin-portal and web-api services
        # are up and running. The depends_on setting helps with that.
        depends_on:
          admin-portal:
            condition: service_started
          web-api:
            condition: service_started
    
    #...
    

    Writing the proxy Dockerfile

    As you saw, the proxy configuration in compose.yaml leverages an external Dockerfile to build the container image that will run our Nginx proxy. This file is also very straightforward. It uses the official Nginx image from Docker Hub and it looks like this:

    # Dockerfile.Proxy
    
    # We can pick the version and flavor that we like. In our case here, this is an
    # image based on the latest release of Nginx, and the latest release of Debian.
    FROM nginx:1.27.1-bookworm
    
    # Unsurprisingly, we have a custom configuration that we want the proxy to use.
    # This is how we make sure it does. We copy it into the default location inside
    # the image.
    COPY proxy/nginx.conf /etc/nginx/nginx.conf
    

    Configuring the proxy

    Now we have to configure the Nginx proxy to route requests to both our web applications. Here’s an nginx.conf that does just that:

    # proxy/nginx.conf
    
    user  nginx;
    worker_processes  auto;
    
    error_log  /var/log/nginx/error.log notice;
    pid        /var/run/nginx.pid;
    
    
    events {
        worker_connections  1024;
    }
    
    http {
        include       /etc/nginx/mime.types;
        default_type  application/octet-stream;
    
        log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                          '$status $body_bytes_sent "$http_referer" '
                          '"$http_user_agent" "$http_x_forwarded_for"';
    
        access_log  /var/log/nginx/access.log  main;
    
        sendfile        on;
    
        keepalive_timeout  65;
    
        # We have to comment out or remove this line to make sure the default
        # configuration that comes in the official image is not applied.
        # include /etc/nginx/conf.d/*.conf;
    
        # Our customizations start here:
        server {
            # With this listen directive, we configure our proxy to expect
            # connections coming from the default HTTP port: 80.
            listen 80;
    
            # This location directive makes sure all requests coming to URLs that
            # look like .../admin are routed to the Admin Portal web app.
            location ~ ^/admin(/?)(.*) {
                proxy_pass http://admin-portal:8080;
                proxy_http_version 1.1;
                proxy_set_header Host $host;
                proxy_set_header Connection keep-alive;
            }
    
            # This location directive makes sure all requests coming to URLs that
            # look like .../api are routed to the Web API.
            location ~ ^/api(/?)(.*) {
                proxy_pass http://web-api:8080;
                proxy_http_version 1.1;
                proxy_set_header Host $host;
                proxy_set_header Connection keep-alive;
            }
        }
    }
    

    What I’ve done here is take the default Nginx configuration file that comes right out of the box, and replace the include /etc/nginx/conf.d/*.conf; line with my own server block directive.

    As explained in the official image’s Docker Hub page, a quick way of obtaining a copy of this default file is using this command: docker run --rm --entrypoint=cat nginx /etc/nginx/nginx.conf > /host/path/nginx.conf.

    The most interesting parts are the proxy_pass directives that take care of redirecting traffic to the apps. Notice how they refer to the Admin Portal using http://admin-portal:8080 and to the Web API by http://web-api:8080. These are the internal naming of these components within the containers’ virtual network, which gets created automatically by Docker Compose.

    Remember that this Nginx instance is not running in the host machine directly. Instead it’s running in a container. In the same virtual network as the other containers described in compose.yaml. That’s why it has to use the hostnames assigned by compose.yaml (i.e. admin-portal and web-api) and the port through which the ASP.NET Core apps running within accept requests (i.e. 8080).

    This is the idea:

    A diagram of the Docker system. The internet contains a single node, with two lines labeled “http://myhostname:8888/admin” and “http://myhostname:8888/api”, whih connect to the host machine (contained in a box), which contains a proxy which is half in the “host machine” box, and half in a box labeled “this deployment’s docker network”. Within the latter the admin domain name is transformed by the proxy to saying “http://admin-portal:8080”, then sent to the admin-portal, then there is another output labeled “db:5432”, which points to the database. The api domain name is transformed by the proxy into “http://web-api:8080”, then sent to the web-api, then to the database similarly to the admin path.

    It also uses proxy_set_header directives to set Host and Connection headers. This is typical practice when it comes to Nginx reverse proxies. You can read more about that on Nginx’s website.

    Configuring the PathBase in ASP.NET Core apps

    There’s an additional step that we have to take to make all this work. We have configured our Nginx proxy to serve both applications under the same “server”, and rely on different URL paths (i.e. /admin vs /api) to determine which app will receive which request. This means that we have to perform further configuration in the apps so that routing is done properly. Thankfully, all it takes is a one-liner in each of the apps’ Program.cs files. After the usual var app = builder.Build(); line, we do the following:

    For the Admin Portal, we add this:

    // source/VehicleQuotes.AdminPortal/Program.cs
    
    // ...
    
    app.UsePathBase("/admin");
    
    // ...
    

    And for the Web API:

    // source/VehicleQuotes.WebApi/Program.cs
    
    // ...
    
    app.UsePathBase("/api");
    
    // ...
    

    With that, the apps are ready to handle requests coming from the Nginx proxy, which will include either the /admin or /api sections.

    Deploying

    Finally, we can deploy. Go into the directory where all the deployment configuration files live. That’s the one with the compose.yaml file. In other words, the root of the deploy repo. Once in there, run:

    docker compose up --build
    

    After a while, Docker compose will have built and deployed everything for us. Now you can navigate to the apps in any browser at http://localhost:8888/admin and http://localhost:8888/api/swagger/index.html.

    The Admin Portal

    The Web API

    As you click around, you can see the Nginx logs with:

    docker compose logs proxy -f
    

    The proxy logs

    To bring it all back down, you can run:

    docker compose down
    

    Cool! At the end of the day, Nginx is just another program that runs as a process in an operating system. And as such, it can be run in a container. One of the nice aspects about this setup is the convenience of having an entire system described in a set of files, and being able to bring everything up with a single command.

    dotnet aspdotnet csharp docker nginx


    Comments