How to Deploy .NET Apps on EC2 with Docker Compose and Nginx

9 min read
15 views

Two .NET apps, one EC2 instance, three containers. Docker Compose with Nginx as a reverse proxy is the cheapest way to run multiple apps on a single server. No ECS, no Kubernetes.

This guide covers the full setup: Nginx reverse proxy, Docker Compose for production and local dev, config management via mounted appsettings files, and a GitLab CI pipeline to deploy to EC2.

Architecture

Client → ALB (HTTPS :443) → EC2 (Nginx :80)
                                    |
                        +-----------+-----------+
                        |                       |
                   /api/* → API (:8080)    /* → Web (:8080)
  • API — .NET 10 REST API (port 8080)
  • Web — Blazor Server frontend (port 8080)
  • Nginx — reverse proxy, routes /api/* to API, everything else to Web

Prerequisites

Project Structure

my-project/
  InventoryApi/
    Dockerfile
  InventoryWeb/
    Dockerfile
  deploy/
    docker-compose.yml          # Production (pulls from registry)
    docker-compose.local.yml    # Local dev (builds from source)
    nginx.conf                  # Reverse proxy config
    run-local.sh                # Local dev runner script
    .env.example                # Env template (values injected by CI/CD)

Step 1: Nginx Config

Create deploy/nginx.conf. The /_blazor block with WebSocket upgrade headers is required for Blazor Server’s SignalR connection — without it the UI won’t load.

worker_processes auto;

events {
    worker_connections 1024;
}

http {
    upstream api {
        server api:8080;
    }

    upstream web {
        server web:8080;
    }

    server {
        listen 80;
        server_name _;

        # API endpoints
        location /api/ {
            proxy_pass http://api;
            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;
        }

        # Swagger and OpenAPI docs
        location /swagger {
            proxy_pass http://api;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }

        location /openapi {
            proxy_pass http://api;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }

        # Blazor SignalR WebSocket (required for Blazor Server)
        location /_blazor {
            proxy_pass http://web;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            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_read_timeout 86400;
        }

        # Web UI (default)
        location / {
            proxy_pass http://web;
            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;
        }
    }
}

Step 2: Production Docker Compose

Create deploy/docker-compose.yml. This pulls pre-built images and mounts environment-specific config files.

services:
  api:
    image: ${REGISTRY}/api:${IMAGE_TAG:-latest}
    container_name: inventory-api
    restart: unless-stopped
    environment:
      - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production}
      - ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
      - ASPNETCORE_HTTP_PORTS=8080
      - ASPNETCORE_URLS=http://+:8080
      - AWS_PROFILE=cross-account
    volumes:
      - ./appsettings.api.json:/app/appsettings.${ASPNETCORE_ENVIRONMENT:-Production}.json:ro
      - ./aws-config:/home/app/.aws/config:ro
    healthcheck:
      test: ["CMD-SHELL", "dotnet --list-runtimes > /dev/null 2>&1 || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 15s
    networks:
      - app-network

  web:
    image: ${REGISTRY}/web:${IMAGE_TAG:-latest}
    container_name: inventory-web
    restart: unless-stopped
    environment:
      - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production}
      - ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
      - ASPNETCORE_HTTP_PORTS=8080
      - ASPNETCORE_URLS=http://+:8080
    volumes:
      - ./appsettings.web.json:/app/appsettings.${ASPNETCORE_ENVIRONMENT:-Production}.json:ro
    healthcheck:
      test: ["CMD-SHELL", "dotnet --list-runtimes > /dev/null 2>&1 || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 15s
    networks:
      - app-network

  nginx:
    image: nginx:alpine
    container_name: inventory-nginx
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      api:
        condition: service_healthy
      web:
        condition: service_healthy
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

Key details:

  • Appsettings mounts — environment-specific config files are mounted as appsettings.{Environment}.json. .NET automatically merges base appsettings.json (baked into the image) with the mounted file.
  • AWS cross-account config — the aws-config file is mounted at /home/app/.aws/config (not /root — the container runs as non-root user app). This enables cross-account AssumeRole via instance profile credentials.
  • Health checksdotnet --list-runtimes works as a process check since the .NET base image doesn’t include curl. Add curl to your Dockerfile if you need HTTP health checks.
  • Forwarded headersASPNETCORE_FORWARDEDHEADERS_ENABLED=true is needed when running behind a reverse proxy (Nginx, ALB) so the app sees the correct client IP and protocol.

Step 3: Local Dev Docker Compose

Create deploy/docker-compose.local.yml. Builds from source, mounts your local AWS credentials, exposes on port 8080.

services:
  api:
    build:
      context: ..
      dockerfile: InventoryApi/Dockerfile
    container_name: inventory-api
    restart: unless-stopped
    environment:
      - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development}
      - AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:-us-east-1}
      - AWS_PROFILE=${AWS_PROFILE:-default}
    volumes:
      - ~/.aws:/home/app/.aws:ro
    healthcheck:
      test: ["CMD-SHELL", "dotnet --list-runtimes > /dev/null 2>&1 || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 15s
    networks:
      - app-network

  web:
    build:
      context: ..
      dockerfile: InventoryWeb/Dockerfile
    container_name: inventory-web
    restart: unless-stopped
    environment:
      - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development}
      - AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:-us-east-1}
      - AWS_PROFILE=${AWS_PROFILE:-default}
    volumes:
      - ~/.aws:/home/app/.aws:ro
    healthcheck:
      test: ["CMD-SHELL", "dotnet --list-runtimes > /dev/null 2>&1 || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 15s
    networks:
      - app-network

  nginx:
    image: nginx:alpine
    container_name: inventory-nginx
    restart: unless-stopped
    ports:
      - "8080:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      api:
        condition: service_healthy
      web:
        condition: service_healthy
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

Step 4: Local Runner Script

Create deploy/run-local.sh. Takes an environment name (qa or prod) and maps it to the correct ASPNETCORE_ENVIRONMENT and AWS_PROFILE.

#!/bin/bash
set -e

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

ENV="${1:-qa}"

case "$ENV" in
  qa)
    export ASPNETCORE_ENVIRONMENT=Development
    export AWS_PROFILE=your-qa-profile
    ;;
  prod)
    export ASPNETCORE_ENVIRONMENT=Production
    export AWS_PROFILE=your-prod-profile
    ;;
  *)
    echo "Usage: ./run-local.sh [qa|prod] [up|down|logs|restart|status]"
    exit 1
    ;;
esac

export AWS_DEFAULT_REGION=us-east-1

cd "$SCRIPT_DIR"

case "${2:-up}" in
  up)
    echo "Building and starting containers..."
    docker compose -f docker-compose.local.yml build
    docker compose -f docker-compose.local.yml up -d
    sleep 15
    docker compose -f docker-compose.local.yml ps
    echo ""
    curl -sf http://localhost:8080/api/health && echo "" || echo "API health check failed"
    echo ""
    echo "API:     http://localhost:8080/api/health"
    echo "Swagger: http://localhost:8080/swagger"
    echo "Web UI:  http://localhost:8080/"
    ;;
  down)
    docker compose -f docker-compose.local.yml down
    ;;
  logs)
    docker compose -f docker-compose.local.yml logs -f
    ;;
  restart)
    docker compose -f docker-compose.local.yml down
    docker compose -f docker-compose.local.yml build
    docker compose -f docker-compose.local.yml up -d
    ;;
  status)
    docker compose -f docker-compose.local.yml ps
    ;;
  *)
    echo "Usage: ./run-local.sh [qa|prod] [up|down|logs|restart|status]"
    exit 1
    ;;
esac

Usage:

chmod +x deploy/run-local.sh

./deploy/run-local.sh qa up        # Build and start with QA config
./deploy/run-local.sh qa logs      # Stream logs
./deploy/run-local.sh qa down      # Stop

Step 5: GitLab CI/CD Variables

No .env files are committed to the repo. The CI/CD pipeline generates all config at deploy time using GitLab CI/CD variables. Set these in Settings → CI/CD → Variables:

Variable Type Environment Description
API_APPSETTINGS File qa, prod API appsettings JSON (mounted into container)
WEB_APPSETTINGS File qa, prod Web appsettings JSON (mounted into container)
SSH_PRIVATE_KEY File qa, prod PEM key for EC2 SSH access
APP_NAME Variable All Application name (used in deploy path and container names)
DEPLOY_ROLE_ARNS Variable All IAM role ARN(s) for cross-account access

Environment-scoped variables (qa, prod) let the same pipeline deploy to different environments with different configs. The .env file on EC2 is generated by the pipeline — never committed to the repo.

Step 6: Configuration Strategy

Don’t bake environment-specific config into Docker images. Instead:

  1. Base configappsettings.json baked into the image (logging defaults, no secrets)
  2. Env-specific configappsettings.api.json and appsettings.web.json generated from CI/CD file variables, SCPd to EC2, mounted into containers
  3. .env file — generated by CI/CD pipeline from GitLab variables (ASPNETCORE_ENVIRONMENT, REGISTRY, IMAGE_TAG) — never committed to the repo

Docker Compose mounts the env-specific file as appsettings.{Environment}.json:

volumes:
  - ./appsettings.api.json:/app/appsettings.${ASPNETCORE_ENVIRONMENT}.json:ro

.NET merges them automatically: base appsettings.json → mounted appsettings.{env}.json → final config.

Step 7: Cross-Account AWS Access

If your app needs to access AWS resources in a different account (e.g., DynamoDB, S3), mount an AWS config file with an AssumeRole profile:

# aws-config (mounted to /home/app/.aws/config)
[profile cross-account]
role_arn = arn:aws:iam::TARGET_ACCOUNT_ID:role/YourCrossAccountRole
credential_source = Ec2InstanceMetadata

Set AWS_PROFILE=cross-account in docker-compose so the SDK automatically assumes the role using the EC2 instance profile credentials.

Mount path must match the container’s home directory — /home/app/.aws/config for non-root containers, not /root/.aws/config.

For the full cross-account IAM setup, see How to Set Up Cross-Account Access in AWS with AssumeRole.

Step 8: GitLab CI Pipeline

This pipeline builds Docker images, generates config files from CI/CD variables, SCPs everything to EC2, and deploys.

stages:
  - build
  - deploy

variables:
  REGISTRY: ${CI_REGISTRY_IMAGE}

build-api:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  before_script:
    - docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD} ${CI_REGISTRY}
  script:
    - docker build -t ${REGISTRY}/api:${CI_COMMIT_SHORT_SHA} -t ${REGISTRY}/api:latest -f InventoryApi/Dockerfile .
    - docker push ${REGISTRY}/api --all-tags
  rules:
    - if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+.*$/'

build-web:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  before_script:
    - docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD} ${CI_REGISTRY}
  script:
    - docker build -t ${REGISTRY}/web:${CI_COMMIT_SHORT_SHA} -t ${REGISTRY}/web:latest -f InventoryWeb/Dockerfile .
    - docker push ${REGISTRY}/web --all-tags
  rules:
    - if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+.*$/'

deploy:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client curl
    - eval $(ssh-agent -s)
    - echo "${SSH_PRIVATE_KEY}" | ssh-add -
    - mkdir -p ~/.ssh
    - echo -e "Host *\n\tStrictHostKeyChecking no\n" > ~/.ssh/config
  script:
    # SCP deploy files
    - scp deploy/docker-compose.yml deploy/nginx.conf ec2-user@${EC2_HOST}:/opt/my-app/
    # Generate and SCP config files
    - cp "${API_APPSETTINGS}" appsettings.api.json
    - cp "${WEB_APPSETTINGS}" appsettings.web.json
    - |
      cat > .env << EOF
      ASPNETCORE_ENVIRONMENT=${APP_ENVIRONMENT}
      REGISTRY=${REGISTRY}
      IMAGE_TAG=${CI_COMMIT_SHORT_SHA}
      EOF
    - |
      cat > aws-config << EOF
      [profile cross-account]
      role_arn = ${CROSS_ACCOUNT_ROLE_ARN}
      credential_source = Ec2InstanceMetadata
      EOF
    - scp appsettings.api.json appsettings.web.json .env aws-config ec2-user@${EC2_HOST}:/opt/my-app/
    # Deploy
    - |
      ssh ec2-user@${EC2_HOST} << DEPLOY
        cd /opt/my-app
        docker login -u ${GITLAB_DEPLOY_USER} -p ${GITLAB_DEPLOY_TOKEN} registry.gitlab.com
        docker compose pull
        docker compose up -d --remove-orphans
        sleep 10
        curl -f http://localhost/api/health || exit 1
        echo "Deploy successful"
      DEPLOY
  rules:
    - if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+.*$/'
      when: manual

CI/CD variables needed in GitLab project settings:

See Step 5 above for the full list of required CI/CD variables.

Step 9: Provision the EC2 Instance

Terraform resource with user data that installs Docker and Compose on Amazon Linux 2023:

data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

resource "aws_instance" "app" {
  ami                    = data.aws_ami.al2023.id
  instance_type          = "t3.medium"
  subnet_id              = var.subnet_id
  vpc_security_group_ids = [var.security_group_id]
  iam_instance_profile   = var.instance_profile_name

  root_block_device {
    volume_size = 30
    volume_type = "gp3"
    encrypted   = true
  }

  user_data = base64encode(<<-EOF
    #!/bin/bash
    set -e
    yum update -y
    yum install -y docker
    systemctl enable docker
    systemctl start docker
    usermod -aG docker ec2-user

    DOCKER_CONFIG=/usr/local/lib/docker/cli-plugins
    mkdir -p $DOCKER_CONFIG
    curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 -o $DOCKER_CONFIG/docker-compose
    chmod +x $DOCKER_CONFIG/docker-compose

    mkdir -p /opt/my-app
    chown ec2-user:ec2-user /opt/my-app
  EOF
  )

  tags = {
    Name = "my-app-server"
  }

  lifecycle {
    ignore_changes = [ami]
  }
}

The lifecycle block prevents Terraform from replacing the instance when a newer AMI is released. Amazon Linux 2023 doesn’t include Docker, so the user data script handles installation.

Step 10: First Deploy

# Copy deploy files to EC2
scp deploy/docker-compose.yml deploy/nginx.conf ec2-user@your-host:/opt/my-app/

# Copy config files (normally done by CI/CD)
scp appsettings.api.json appsettings.web.json .env aws-config ec2-user@your-host:/opt/my-app/

# SSH in and start
ssh ec2-user@your-host
cd /opt/my-app
docker login registry.gitlab.com -u your-deploy-user -p your-deploy-token
docker compose pull
docker compose up -d

# Verify
curl http://localhost/api/health

After that, the GitLab CI pipeline handles all future deploys.

Files on EC2

/opt/my-app/
  docker-compose.yml       # From repo (SCP)
  nginx.conf               # From repo (SCP)
  .env                     # Generated by CI/CD
  appsettings.api.json     # Generated by CI/CD (API config)
  appsettings.web.json     # Generated by CI/CD (Web config)
  aws-config               # Generated by CI/CD (cross-account AssumeRole)

Cost: EC2 vs ECS Fargate

Approach Monthly Cost
EC2 t3.medium (On-Demand) ~$33
EC2 t3.medium (1yr Reserved) ~$21
ECS Fargate (2 tasks) + ALB ~$52

EC2 with a shared ALB saves ~35% over Fargate with a dedicated ALB. The trade-off is managing the instance yourself.

Conclusion

Docker Compose with Nginx gives you path-based routing, WebSocket support for Blazor, health checks, auto-restart, and CI/CD — all on a single EC2 instance without the complexity of ECS or Kubernetes.

Related guides: Cross-Account Access with AssumeRole | Pull Docker Images from GitLab Registry