GitHub SSH Key 등록부터 Docker Compose로 Next.js·Spring Boot 올리고 Nginx 리버스 프록시까지 실제 삽질 기반으로 정리했어요.
이전 글: [서버, 인프라] - Docker + Jenkins, 서버에 직접 올려봤습니다 — Hetzner 실전 세팅 3편
3편에서 Docker 설치하고 Jenkins 띄웠잖아요.
이번엔 드디어 실제 서비스를 올리는 단계예요.
프론트엔드(Next.js)랑 백엔드(Spring Boot) 둘 다 Docker로 올리고, Nginx로 묶는 것까지 할 거거든요. 근데 생각보다 변수가 꽤 있었어요. 삽질한 부분도 다 넣었으니까 그냥 따라오시면 돼요.
GitHub Private 레포 — SSH Key로 연결하기
서버에서 GitHub 코드를 가져와야 하는데, 레포가 Private이거든요.
HTTPS + Personal Access Token 방식도 있는데, SSH Key 방식이 훨씬 편해요. 한 번만 설정해두면 나중에 Jenkins CI/CD 연동할 때도 그대로 써먹을 수 있거든요.
서버에서 SSH Key 생성
ssh-keygen -t ed25519 -C "hetzner-server"
엔터 세 번 누르면 기본값으로 생성돼요.
공개키 확인
cat ~/.ssh/id_ed25519.pub
ssh-ed25519 AAAA... 로 시작하는 값 전체 복사해두세요.

GitHub에 등록
여기서 잠깐. Deploy key는 레포 하나에만 등록할 수 있어요. 프론트/백엔드 레포가 각각 있으면 같은 키를 두 레포에 못 쓰거든요.
Key is already in use 에러 나면 계정 레벨에서 등록하면 돼요.
GitHub → 프로필 → Settings → SSH and GPG keys → New SSH key
- Title: hetzner-server
- Key type: Authentication Key
- Key: 공개키 붙여넣기
이러면 해당 계정의 모든 레포에 접근 가능해요.
Clone 테스트
git clone git@github.com:계정명/프론트레포.git
git clone git@github.com:계정명/백엔드레포.git
처음 연결 시 Are you sure you want to continue connecting? 나오면 yes 입력하면 돼요.
프론트엔드 — Next.js Docker 빌드
Clone 받은 프론트 폴더로 이동해서 Dockerfile 확인했더니 CMD ["yarn", "start"] 로 돼있었어요. SSR 방식이라 Node 서버가 필요한 구조거든요.
두 가지 수정이 필요했어요.
1. 포트 지정
yarn start는 기본이 3000포트인데, 저는 10000으로 쓸 거라 명시해줬어요.
CMD ["yarn", "start", "-p", "10000"]
2. 환경변수 처리
여기서 진짜 중요한 포인트가 있어요.
NEXT_PUBLIC_ 변수는 빌드 시점에 번들에 박혀요. 런타임에 주입이 안 된다는 거거든요. 그래서 .env에 localhost로 돼있으면 컨테이너 안에서 자기 자신만 바라보게 돼요.
Dockerfile에 ARG/ENV로 넣어줘야 해요.
ARG NEXT_PUBLIC_API_URL=http://서버IP/api
ARG NEXT_PUBLIC_FRONT_URL=http://서버IP
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_FRONT_URL=$NEXT_PUBLIC_FRONT_URL
RUN yarn build
빌드 전에 선언해야 번들에 반영돼요. 순서 중요해요.
이미지 빌드
docker build -t front .
빌드 시간이 8~9분 걸렸어요. yarn install이랑 yarn build가 시간을 많이 잡아먹거든요.
백엔드 — Spring Boot Docker 빌드
Spring Boot는 Dockerfile이 JAR 파일을 복사하는 방식이라 Gradle 빌드 먼저 해야 해요.
Gradle 빌드
chmod +x gradlew
./gradlew clean build -x test
-x test 는 테스트 스킵이에요. 서버에서 DB 연결 없이 테스트 돌리면 실패하거든요.
빌드 완료되면 build/libs/ 아래 JAR 파일 생겼는지 확인해요.
ls build/libs/
Docker 이미지 빌드
근데 여기서 삽질이 있었어요.
Dockerfile에 FROM openjdk:17-jdk-slim 으로 돼있었는데, 이 이미지가 Docker Hub에서 deprecated 돼서 없어졌거든요.
ERROR: openjdk:17-jdk-slim: not found
이렇게 나오면 eclipse-temurin으로 바꾸면 돼요.
sed -i 's/FROM openjdk:17-jdk-slim/FROM eclipse-temurin:17-jdk-jammy/' Dockerfile
그리고 이 변경사항은 GitHub에도 바로 올려두는 게 좋아요. 나중에 Jenkins에서 또 같은 에러 만나거든요.
git add Dockerfile gradlew
git commit -m "fix: openjdk -> eclipse-temurin, gradlew 실행권한 추가"
git push origin main
이미지 빌드
docker build -t api .
18초면 끝나요. JAR 파일 복사만 하면 되니까 프론트보다 훨씬 빠르거든요.
Nginx — 리버스 프록시 설정
프론트/백엔드 이미지 다 만들어졌으면 Nginx로 묶을 차례예요.
어? 왜 Nginx가 필요하냐고요?
지금은 프론트가 10000포트, 백엔드가 10081포트로 각각 떠있거든요. 사용자 입장에서 서버IP:10000, 서버IP:10081 이렇게 포트 붙여서 접속해야 하는 게 이상하잖아요.
Nginx가 80포트로 들어오는 요청을 받아서 URL 경로에 따라 프론트/백엔드로 나눠주는 거예요.
http://서버IP/ → Next.js (10000포트)
http://서버IP/api/ → Spring Boot (10081포트)
한마디로 Nginx가 교통정리 해주는 거예요.
근데 여기서 포인트가 있어요. 백엔드 API 경로에 /api prefix가 없으면 Nginx가 프론트/백엔드 요청을 구분 못해요.
그래서 Spring Boot application.yml에 context-path 추가했어요.
server:
servlet:
context-path: /api
이러면 모든 백엔드 API가 /api/reserve, /api/pay 이런 식으로 바뀌어요. Nginx에서 /api/ 로 시작하는 건 백엔드로, 나머지는 프론트로 보내면 깔끔하게 라우팅돼요.
그리고 이게 CORS 문제도 자연히 해결해줘요. 프론트/백엔드가 같은 http://서버IP origin으로 통신하게 되거든요.
Nginx 설정 파일 생성
mkdir -p ~/app/nginx
cat > ~/app/nginx/default.conf << 'EOF'
server {
listen 80;
location / {
proxy_pass http://front:10000;
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 /api/ {
proxy_pass http://api:10081/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
EOF
proxy_pass에서 컨테이너 이름(front, api)으로 연결하는 게 포인트예요. Docker Compose로 띄우면 컨테이너끼리 이름으로 통신할 수 있거든요.

Docker Compose — 한 번에 묶기
설정 다 됐으면 docker-compose.yml 만들어서 한 번에 올릴게요.
cat > ~/app/docker-compose.yml << 'EOF'
services:
nginx:
image: nginx:alpine
container_name: nginx
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
depends_on:
- front
- api
front:
image: front
container_name: front
restart: unless-stopped
ports:
- "10000:10000"
api:
image: api
container_name: api
restart: unless-stopped
ports:
- "10081:10081"
EOF
depends_on으로 프론트/백엔드가 뜨고 나서 Nginx가 시작되게 순서 잡아뒀어요.
실행
cd ~/app
docker compose up -d
확인
docker ps
nginx, front, api 세 개 다 Up 상태면 완료예요.
브라우저에서 http://서버IP 접속하면 Next.js 화면 나오고, API 호출도 /api/ 경로로 백엔드로 잘 붙는 거 확인했어요.
현재 서버 구성 최종
[Hetzner CX33 - Helsinki]
├── Nginx → 80포트 (리버스 프록시)
│ ├── / → Next.js (10000)
│ └── /api/ → Spring Boot (10081)
├── Jenkins → 8090포트
└── [AWS RDS] → MySQL 외부 연결
결국 핵심은 세 가지예요.
NEXT_PUBLIC_ 변수는 빌드 시점에 번들에 박히니까 Dockerfile에서 처리해야 하고, openjdk:17-jdk-slim은 deprecated됐으니까 eclipse-temurin으로 바꿔야 하고, 백엔드에 /api context-path 붙여야 Nginx 라우팅이 깔끔하게 돼요.
다음 편에서는 Jenkins 파이프라인 구성해서 GitHub push하면 자동으로 빌드/배포되게 만들어볼게요.
다음 글: [서버, 인프라] - GitHub push 하면 자동으로 배포된다고? Jenkins CI/CD 파이프라인 직접 구성해봤습니다 — Hetzner 실전 세팅 5편