Ubuntu 홈 서버 배포 - ASP.NET Core 서버를 Docker와 Nginx로 올리기
오늘은 기존에 GitHub에 있던 ASP.NET Core 게임 프로젝트를 홈 서버에 올려서 실제 도메인으로 접속되도록 만드는 작업을 했다. 처음에 Azure에서 배포했다가 무료 기간이 끝나고 연결이 풀렸었는데 바빠서 방치했다가 이제서야 홈 서버를 구축한 김에 옮기는 작업을 시도해봤다.
이전에 Ubuntu 서버 기본 보안 설정, Docker 설치, DB 컨테이너 구성, Nginx 기본 설정까지는 어느 정도 끝내둔 상태였다. 그래서 오늘은 실제 프로젝트를 서버에 올리고, 내가 가진 도메인으로 접속되는지 확인하는 것이 목표였다.
오늘 작업은 간단하게 Docker로 앱을 빌드하고 Nginx로 연결하고 금방 끝날 줄 알았다. 그런데 막상 해보니 앱 컨테이너, MSSQL 컨테이너, Nginx, Cloudflare HTTPS가 서로 맞물려 있어서 생각보다 확인할 게 많았다.
그래도 결과적으로는 <DOMAIN>으로 접속했을 때 ASP.NET Core 앱이 정상 응답하는 상태까지 만들었다.
오늘 작업 목표
오늘 목표는 아래와 같았다.
GitHub에 있는 ASP.NET Core 프로젝트
↓
Ubuntu 홈 서버로 가져오기
↓
Docker 이미지 빌드
↓
Docker Compose로 실행
↓
Nginx Reverse Proxy 연결
↓
Cloudflare HTTPS 적용
↓
https://<DOMAIN> 접속 확인
기존에는 서버 자체를 단단하게 만드는 데 집중했다면, 이번에는 실제 애플리케이션을 외부에서 접근 가능한 형태로 올리는 작업이었다.
현재 상황
서버에는 이미 Docker Compose 기반으로 몇 가지 컨테이너가 올라가 있었다.
postgres-db
mssql-db
portainer
여기에 새로 ASP.NET Core 게임 서버 컨테이너를 추가해야 했다.
최종 구조는 아래처럼 잡았다.
사용자 브라우저
↓
Cloudflare
↓
Nginx
↓
ASP.NET Core 컨테이너
↓
MSSQL 컨테이너
여기서 가장 중요하게 생각한 부분은 DB를 외부에 직접 공개하지 않는 것이었다.
처음에는 MSSQL 포트도 외부에서 접속 가능하게 열어야 하나 생각했는데, 막상 생각해보니 운영 서버에서 DB 포트를 그대로 공개할 이유가 거의 없었다.
그래서 MSSQL은 서버 내부에서만 접근 가능하게 했다.
ports:
- "127.0.0.1:1433:1433"
이렇게 하면 서버 안에서는 MSSQL에 접근할 수 있지만, 외부에서는 직접 접근할 수 없다.
ASP.NET Core 앱도 마찬가지로 외부에 직접 열지 않고, Nginx를 통해서만 접근하게 했다.
Dockerfile 추가
먼저 프로젝트에 Dockerfile을 추가했다.
이번 프로젝트는 프론트엔드와 백엔드가 함께 있는 구조였다.
frontend
SurvivalGame.API
프론트엔드는 Vue/Vite 기반이고, 백엔드는 ASP.NET Core Web API였다. 최종적으로 프론트엔드 빌드 결과물을 백엔드의 wwwroot에 넣고, ASP.NET Core가 정적 파일까지 같이 서비스하는 형태로 배포했다.
처음에는 로컬 Windows에서 바로 Docker 빌드를 해보려고 했다.
docker build -t survivalgame:test .
생각해보니 Windows에는 Docker가 설치되어 있지 않았다.
처음에는 Docker Desktop을 설치해야 하나 생각했는데, 어차피 실제로 실행할 곳은 Ubuntu 서버였다. 그래서 로컬에서는 Dockerfile만 작성해서 GitHub에 올리고, 서버에서 직접 빌드하는 방식으로 진행했다.
git add Dockerfile .dockerignore
git commit -m "Add Docker support"
git push
중간에 push가 한 번 막혔다.
non-fast-forward
원격 저장소가 내 로컬보다 앞서 있어서 생긴 문제였다. 이때는 원격 변경 사항을 먼저 가져온 다음 내 커밋을 다시 올려야 한다.
git pull --rebase origin main
git push
서버에서 프로젝트 가져오기
서버에서 GitHub 저장소를 clone 했다.
git clone https://github.com/<GITHUB_OWNER>/<REPOSITORY>.git
프로젝트 폴더로 들어가서 파일 구성을 확인했다.
cd ~/docker/game/<REPOSITORY>
ls -al
대략 아래와 같은 구성이 보이면 된다.
Dockerfile
.dockerignore
frontend
SurvivalGame.API
그다음 Docker 이미지를 빌드했다.
docker build -t survivalgame:test .
이미지가 만들어졌는지 확인했다.
docker images | grep survivalgame
Docker Compose에 서비스 추가
기존 docker-compose.yml에 survivalgame 서비스를 추가했다.
survivalgame:
image: survivalgame:test
container_name: survivalgame
restart: unless-stopped
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
ConnectionStrings__DefaultConnection: ${SURVIVALGAME_DB_CONNECTION}
Jwt__SecretKey: ${SURVIVALGAME_JWT_SECRET}
ports:
- "127.0.0.1:18080:8080"
depends_on:
- mssql
ports:
- "127.0.0.1:18080:8080"
컨테이너 안의 ASP.NET Core 앱은 8080에서 실행된다. 하지만 서버 밖으로 직접 공개하지 않고, 서버 내부의 127.0.0.1:18080에만 연결했다.
즉 외부 사용자는 이 포트로 직접 접근할 수 없다.
외부 요청은 반드시 Nginx를 거쳐야 한다.
.env 설정
비밀번호나 JWT Secret 같은 민감한 값은 docker-compose.yml에 직접 쓰지 않았다. 대신 .env 파일에 따로 두었다.
예시는 이런 형태다.
TZ=Asia/Seoul
MSSQL_SA_PASSWORD=<MSSQL_PASSWORD>
MSSQL_PID=****
SURVIVALGAME_JWT_SECRET=<JWT_SECRET>
SURVIVALGAME_DB_CONNECTION="Server=mssql-db,1433;Database=SurvivalGame;User Id=****;Password=****<MSSQL_PASSWORD>;Encrypt=False;TrustServerCertificate=True;MultipleActiveResultSets=true"
중간에 .env 관련해서 헷갈렸던 부분도 있었다.
처음에는 .env를 Bash에서 직접 source하려고 했다.
set -a
source .env
set +a
그런데 연결 문자열 안에 User Id=sa처럼 공백이 있어서 Bash가 User를 명령어처럼 해석했다.
User: 명령을 찾을 수 없습니다
여기서 알게 된 점은 Docker Compose가 읽는 .env와 Bash가 source로 실행하는 .env는 다르다는 것이다.
Docker Compose는 .env를 환경변수 파일처럼 읽지만, Bash의 source는 그 파일을 셸 스크립트처럼 실행한다.
그래서 연결 문자열처럼 공백과 세미콜론이 많은 값은 따옴표를 붙이는 게 안전하다.
Nginx Reverse Proxy 설정
ASP.NET Core 앱은 내부 포트에만 열려 있기 때문에 외부에서 접근하려면 Nginx가 필요하다.
이번 구조에서는 Nginx가 Reverse Proxy 역할을 한다.
Reverse Proxy(리버스 프록시)는 사용자의 요청을 먼저 받은 뒤, 내부에서 실제 서비스를 제공하는 서버로 요청을 대신 전달해 주는 중간 서버를 의미한다. 사용자는 Reverse Proxy의 존재를 거의 인식하지 못하고 하나의 웹 서버에 접속하는 것처럼 보이지만, 실제로는 내부에서 여러 서버나 애플리케이션으로 요청이 전달된다.
브라우저
↓
Nginx
↓
127.0.0.1:18080
↓
ASP.NET Core 앱
sudo nano /etc/nginx/sites-available/<DOMAIN>
핵심 설정은 아래 정도다.
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name <DOMAIN>;
ssl_certificate /etc/nginx/ssl/<DOMAIN>.crt;
ssl_certificate_key /etc/nginx/ssl/<DOMAIN>.key;
location / {
proxy_pass http://127.0.0.1:18080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
proxy_pass http://127.0.0.1:18080;
proxy_pass가 외부에서 들어온 요청을 서버 내부의 ASP.NET Core 앱으로 넘긴다.
설정 후에는 Nginx 문법을 검사하고 문제가 없으면 Nginx를 reload 한다.
sudo nginx -t
sudo systemctl reload nginx
Cloudflare HTTPS 연결
도메인은 Cloudflare에서 관리하고 있었다.
처음에는 http://<DOMAIN>으로 접속했을 때 예전에 연결해둔 Azure 관련 화면이 나오기도 했고, 이후에는 Cloudflare 522 오류도 떴다.
HTTP 522
522는 Cloudflare가 원본 서버에 연결하지 못했다는 뜻이다.
curl ifconfig.me
sudo ufw status verbose
sudo ss -tlnp | grep ':80\|:443'
Cloudflare DNS가 실제 홈 서버를 가리키는지, 서버 방화벽에서 80/443이 열려 있는지, Nginx가 실제로 포트를 듣고 있는지 확인했다.
그리고 Cloudflare SSL/TLS 설정이 Full (strict)였기 때문에 원본 서버에도 인증서가 필요했다.
그래서 Cloudflare Origin Certificate를 발급해서 서버에 저장했다.
/etc/nginx/ssl/<DOMAIN>.crt
/etc/nginx/ssl/<DOMAIN>.key
권한은 아래처럼 정리했다.
sudo chmod 644 /etc/nginx/ssl/<DOMAIN>.crt
sudo chmod 600 /etc/nginx/ssl/<DOMAIN>.key
그다음 다시 Nginx 설정을 검사하고 reload 했다.
sudo nginx -t
sudo systemctl reload nginx
HTTP와 www 리다이렉트
도메인은 www 없이 쓰는 방향으로 통일했다.
https://www.<DOMAIN>
↓
https://<DOMAIN>
HTTP 요청도 HTTPS로 보내도록 했다.
http://<DOMAIN>
↓
https://<DOMAIN>
Nginx에서 이런 식으로 처리했다.
server {
listen 80;
listen [::]:80;
server_name <DOMAIN> www.<DOMAIN>;
return 301 https://<DOMAIN>$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name www.<DOMAIN>;
ssl_certificate /etc/nginx/ssl/<DOMAIN>.crt;
ssl_certificate_key /etc/nginx/ssl/<DOMAIN>.key;
return 301 https://<DOMAIN>$request_uri;
}
확인은 curl로 했다.
curl -I https://<DOMAIN>
curl -I https://www.<DOMAIN>
정상이라면 이런 식으로 확인된다.
https://<DOMAIN> → 200 OK
https://www.<DOMAIN> → 301 Moved Permanently
보안 헤더 추가
Nginx에 기본적인 보안 헤더도 추가했다.
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
이 설정들은 대략 이런 의미다.
X-Frame-Options는 다른 사이트가 내 페이지를 iframe으로 끼워 넣는 것을 제한한다.
X-Content-Type-Options는 브라우저가 파일 타입을 멋대로 추측하지 않게 한다.
Referrer-Policy는 다른 사이트로 이동할 때 어느 정도의 출처 정보를 보낼지 정한다.
Permissions-Policy는 카메라, 마이크, 위치정보 같은 브라우저 기능 사용을 제한한다.
너무 깊게 들어가지는 않았지만, 기본적인 웹 서버 설정으로는 넣어두는 편이 좋다고 판단했다.
확인은 아래처럼 했다.
curl -I https://<DOMAIN>
응답에서 아래 헤더들이 보이면 된다.
x-frame-options: SAMEORIGIN
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
permissions-policy: geolocation=(), microphone=(), camera=()
Nginx server_tokens와 gzip
Nginx 버전 정보를 응답에서 숨기기 위해 server_tokens off;도 설정했다.
sudo nano /etc/nginx/nginx.conf
http 블록 안에 아래처럼 추가했다.
server_tokens off;
gzip on;
gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml;
다시 검사.
sudo nginx -t
sudo systemctl reload nginx
최종 확인
마지막으로 컨테이너 상태를 확인했다.
docker ps
정상적으로 아래 컨테이너들이 떠 있었다.
survivalgame
mssql-db
postgres-db
portainer
서버 내부에서 앱이 응답하는지도 확인했다.
curl -I http://127.0.0.1:18080
정상 결과는 아래처럼 나온다.
HTTP/1.1 200 OK
Server: Kestrel
여기서 Kestrel은 ASP.NET Core에 기본으로 포함된 웹 서버다.
지금 구조에서는 외부 요청을 Nginx가 먼저 받고, Nginx가 내부의 Kestrel 서버로 요청을 넘겨주는 방식으로 동작한다. 즉 브라우저가 직접 Kestrel에 붙는 구조가 아니라, Kestrel은 컨테이너 안에서 실제 ASP.NET Core 앱을 실행하고 응답을 만들어주는 역할을 한다.
외부 도메인도 확인했다.
curl -I https://<DOMAIN>
정상 결과는 아래처럼 나왔다.
HTTP/2 200
server: cloudflare
x-frame-options: SAMEORIGIN
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
이 상태면 브라우저 요청이 아래 흐름으로 정상 처리되고 있다는 뜻이다.
Cloudflare
↓
Nginx
↓
ASP.NET Core 컨테이너
최종 구조
오늘 작업이 끝난 뒤 구조는 이렇게 정리할 수 있다.
사용자 브라우저
↓
https://<DOMAIN>
↓
Cloudflare
↓
Nginx 443
↓
127.0.0.1:18080
↓
survivalgame 컨테이너
↓
mssql-db 컨테이너
컨테이너 포트는 이렇게 잡았다.
survivalgame 127.0.0.1:18080 → 8080
mssql-db 127.0.0.1:1433 → 1433
postgres-db 127.0.0.1:5432 → 5432
portainer 127.0.0.1:9443 → 9443
외부에 직접 공개되는 것은 Nginx의 80/443뿐이다.
DB와 앱 컨테이너는 서버 내부에서만 접근된다.
처음에는 굳이 한 단계를 더 거쳐야 하나 싶었는데, 실제로 구성해 보니 장점이 꽤 많았다. HTTPS 인증서 관리, HTTP에서 HTTPS로 리다이렉트, www 주소 통일, 보안 헤더 추가, 접근 로그 기록 같은 웹 서버 기능은 모두 Nginx가 담당하고, Kestrel은 ASP.NET Core 애플리케이션 실행에만 집중할 수 있다.
덕분에 애플리케이션과 웹 서버의 역할이 명확하게 분리되고, 운영 환경에서도 관리하기 훨씬 편한 구조를 만들 수 있었다.
오늘 배운 점
Docker 컨테이너를 띄우는 것과 실제 도메인으로 서비스를 운영하는 것은 생각보다 다르다.
처음에는 앱 컨테이너 하나만 실행하면 된다고 생각했는데, 실제로는 Nginx, Cloudflare, HTTPS 인증서, 방화벽, 포트 바인딩까지 같이 맞이했다.
특히 DB 포트를 외부에 열지 않고 127.0.0.1에만 바인딩한 부분은 앞으로도 계속 유지할 것 같다.
그리고 curl -I로 내부 응답과 외부 응답을 나눠서 확인하는 방식이 꽤 유용했다.
curl -I http://127.0.0.1:18080
curl -I https://<DOMAIN>
첫 번째가 실패하면 앱 컨테이너 문제고, 첫 번째는 되는데 두 번째가 실패하면 Nginx나 Cloudflare 쪽 문제일 가능성이 높다.
문제를 나눠서 확인하는 생각이 중요하다.
다음 작업
배포 구조 자체는 잡혔지만, 실제로 앱을 실행하는 과정에서 DB 마이그레이션 문제가 따로 발생했다.
기존 EF Core Migration 순서 문제와 SQL Server의 Multiple Cascade Path 오류가 이어졌는데, 이 부분은 배포 글에 같이 넣기에는 길어져서 다음 글에서 따로 정리하려고 한다.