How to Listen on Custom Ports in ASP.NET Core

When you host an ASP.NET Core application with IIS, you're not replacing Kestrel. Instead, you're putting IIS in front of it in a setup called a reverse proxy.

This means that you can't listen on custom ports like you can with Kestrel. This post shows you how to listen on custom ports in ASP.NET Core.

Here's how the roles are divided:

  • IIS: IIS becomes the front door to your application. It runs on the server, listens on the standard web ports (80 for HTTP, 443 for HTTPS), and manages your application's worker process. Its primary job is to handle incoming HTTP requests from the outside world.
  • Kestrel: Kestrel is the web server that actually runs your .NET code. It still exists, but it runs "behind" IIS. When IIS receives an HTTP request, it forwards it to Kestrel.

The key takeaway is that IIS only forwards HTTP traffic. It doesn't know or care about any other protocols or custom ports you might want to listen on. It's a web server, not a general-purpose network traffic router.

When trying to open a custom port, your first reach is for a ConfigureKestrel in Program.cs. It feels intuitive, and it works flawlessly on a development machine.

The code looks something like this:

// This works in local development, but NOT with IIS
builder.WebHost.ConfigureKestrel((_, options) =>
{
    // You're telling Kestrel to listen on port 30000
    options.Listen(IPAddress.Any, 30000, listenOptions =>
    {
        // ...interceptor logic here...
    });
});

Why it works locally: On your dev machine, Kestrel is the front-door server. There's no IIS "in the way", so it can bind directly to port 30000 and accept connections.

Why it fails with IIS: In IIS, your application is inside a managed environment. The code above still runs, and Kestrel tries to listen. But since IIS is the only thing connected to the outside network, no external TCP traffic on port 30000 is ever routed to it. The port is open, but it's stuck behind a wall that only has a door for HTTP requests.

The Solution: Hosted Service

To fix this, you need to separate your TCP listening logic from the web server configuration. The correct way to handle long-running, non-HTTP tasks in ASP.NET Core is with a Hosted Service.

A hosted service is a background task that starts and stops with your application but runs independently of the web request pipeline.

Step 1: Create the Background Service

First, create a class that inherits from BackgroundService. This class will contain all the logic for creating and managing your TCP listener.

using System.Net;
using System.Net.Sockets;
using System.Text;

// A generic background service for listening on a TCP port
public class TcpListenerService : BackgroundService
{
    private readonly ILogger<TcpListenerService> _logger;
    private readonly int _port;
    private readonly Func<string, Task> _messageHandler;

    public TcpListenerService(
        ILogger<TcpListenerService> logger,
        int port,
        Func<string, Task> messageHandler)
    {
        _logger = logger;
        _port = port;
        _messageHandler = messageHandler;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var tcpListener = new TcpListener(IPAddress.Any, _port);
        tcpListener.Start();
        _logger.LogInformation("Background TCP Listener started on port {Port}.", _port);

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                using var client = await tcpListener.AcceptTcpClientAsync(stoppingToken);
                _logger.LogInformation("Client connected from {Endpoint}.", 
                    client.Client.RemoteEndPoint);

                _ = HandleClientAsync(client, stoppingToken);
            }
            catch (OperationCanceledException)
            {
                break;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "An error occurred in the TCP listener.");
            }
        }

        tcpListener.Stop();
        _logger.LogInformation("Background TCP Listener stopped.");
    }

    private async Task HandleClientAsync(TcpClient client, CancellationToken stoppingToken)
    {
        try
        {
            await using var stream = client.GetStream();
            var buffer = new byte[4096]; // Larger buffer for bigger messages
            var bytesRead = await stream.ReadAsync(buffer, stoppingToken);
            var message = Encoding.UTF8.GetString(buffer, 0, bytesRead);

            await _messageHandler(message);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error handling client connection.");
        }
        finally
        {
            client.Dispose();
        }
    }
}

Step 2: Register the Service

Next, remove the old ConfigureKestrel code from your Program.cs and register your new service.

var builder = WebApplication.CreateBuilder(args);

// Add your other application services (Controllers, etc.)
builder.Services.AddControllers();

// Register the background service
builder.Services.AddHostedService<TcpListenerService>();

var app = builder.Build();

// ... configure your HTTP pipeline ...

app.Run();

With this change, your application now has two independent parts:

  1. The Web Host: Managed by IIS and Kestrel, handling HTTP requests.
  2. The TCP Listener: Managed by your TcpListenerService, running in the background and listening directly on port 30000.

Don't Forget: You still need to open port 30000 in the Windows Firewall on your server to allow external traffic to reach your service. You may also need to adjust the permissions of your IIS Application Pool if you encounter startup errors.

Conclusion

By following these steps, you've successfully separated your TCP listener from the web server configuration, ensuring that your application can listen on custom ports while still being hosted in IIS. This approach is more robust and easier to maintain than trying to configure Kestrel directly in your Program.cs.