Oracle Cloud Always Free 인스턴스(Ampere A1, Ubuntu)에 직접 산 도메인을 연결하고, Let’s Encrypt 인증서로 HTTPS를 적용하기까지의 전 과정이다.
한 대의 서버에서 두 개의 사이트를 서로 다른 도메인으로 서비스하는 구성을 목표로 한다.
Internet
└─ OCI Public IP :80 / :443
└─ host nginx (호스트에 직접 설치)
├─ app.example.kr → /var/www/myapp (React 정적 파일)
│ /api/* → 127.0.0.1:8080 (Spring Boot 컨테이너)
└─ example.kr → 127.0.0.1:8081 (WordPress 컨테이너)
이 글의 도메인·IP·이메일은 전부 예시값이다. 본인 값으로 바꿔서 읽으면 된다.
이렇게 세운 Always Free 인스턴스가 유휴 상태로 판정되면 회수될 수 있다.
조건과 확인 방법은 OCI Always Free 인스턴스는 언제 회수되는가에 따로 적었다.
0. 시작 시점의 상태
- Ubuntu, nginx는 호스트에 직접 설치(컨테이너 아님)
- Spring Boot 앱이 Docker로
127.0.0.1:8080에 바인딩되어 가동 중 - nginx는 80만 리스닝,
server_name _캐치올 한 개 - 443 미개방, 인증서 없음, 도메인 없음
즉 IP로만 접속되는 상태에서 시작한다.
1. 공인 IP를 예약(Reserved) IP로 바꾼다
도메인을 사기 전에 해야 한다. 순서를 틀리면 DNS를 두 번 고치게 된다.
OCI의 기본 공인 IP는 임시(Ephemeral)다. 인스턴스가 종료되면 잃는다. 그리고 중요한 함정이 하나 있다.
OCI는 임시 IP를 예약 IP로 “제자리 전환”하는 기능이 없다. 임시 IP를 떼고 예약 IP를 새로 붙이는 방식이라 주소가 바뀐다.
콘솔 경로 (영문 UI)
Step 0. 우상단 리전 선택기가 인스턴스와 같은 리전인지 확인
(예약 공인 IP는 리전 리소스다. 다른 리전에 만들면 목록에 안 뜬다)
Step 1. ☰ → Networking → IP Management → Reserved Public IPs
→ [ Create reserved public IP ]
Name : my-server-ip
Compartment : 인스턴스가 있는 곳
IP address source : Oracle
Step 2. ☰ → Compute → Instances → (인스턴스 이름)
→ Resources → Attached VNICs → (기본 VNIC)
→ IPv4 Addresses → 사설 IP 행의 ⋮ → Edit
Public IP Type : Ephemeral → Reserved 로 변경
Reserved public IP : Step 1에서 만든 것 선택
콘솔 버전에 따라 임시 → 예약을 한 번에 못 바꾸고 “No Public IP”로 먼저 저장한 뒤 예약 IP를 붙여야 하는 경우가 있다. 그동안 잠깐 외부 접속이 끊긴다.
⚠️ SSH가 끊긴다
IP가 바뀌므로 작업 중이던 SSH 세션이 그대로 죽는다. 콘솔에서 새 IP를 확인할 수 있는 상태로 진행하고, 재접속은 새 IP로 한다. known_hosts에 옛 IP 항목이 남아 경고가 뜰 수 있다.
2. 도메인 구매와 DNS
Cloudflare를 쓸 것인가
Cloudflare Registrar는 원가 판매로 저렴하지만 .kr / .co.kr 같은 국가 도메인을 취급하지 않는다. 한국 도메인은 국내 등록기관(가비아, 카페24 등)에서만 살 수 있다.
DNS만 Cloudflare로 옮기는 것도 가능하지만, 국내 타깃 사이트라면 오히려 손해일 수 있다.
| 직접 접속 (가비아 DNS) | Cloudflare 프록시 | |
|---|---|---|
| 국내 방문자 | 빠름 | 무료 플랜은 해외 접속점으로 우회되는 경우가 있어 느려질 수 있음 |
| 해외 방문자 | 서버까지 직접 = 느림 | CDN 캐싱으로 빨라짐 |
| 부가 작업 | 없음 | nginx 실제 IP 복원 설정, 캐시 이중화 관리, 인증서 절차 추가 |
국내 방문자가 대부분이면 등록기관 DNS를 그대로 쓰는 게 낫다. 나중에 해외 유입이 생기면 네임서버만 바꿔서 옮기면 되고, 도메인을 이전할 필요도 없다.
등록기관은 접근 가능 지역과 무관하다
자주 나오는 오해 하나. 도메인을 어디서 사든 전 세계에서 접속된다. DNS는 전 세계 공용 시스템이다. .co.kr 도 마찬가지다.
해외 접속 속도를 결정하는 건 등록기관이 아니라 서버의 물리적 위치다.
A 레코드 등록
가비아 기준: My가비아 → 서비스 관리 → 도메인 → DNS 정보 → DNS 관리 → 레코드 수정
| 호스트 | 타입 | 값 | TTL |
|---|---|---|---|
@ |
A | 203.0.113.10 |
3600 |
www |
A | 203.0.113.10 |
3600 |
app |
A | 203.0.113.10 |
3600 |
가비아는 호스트 칸에
@대신 빈칸을 요구하는 화면도 있다. 비워두면 루트 도메인이다.
전파 확인
인증서 발급을 시도하기 전에 반드시 확인한다. 국내·해외 리졸버를 섞어서 본다.
for r in 8.8.8.8 1.1.1.1 168.126.63.1; do
printf "resolver %-14s : " "$r"
for h in example.kr www.example.kr app.example.kr; do
printf "%s=%s " "$h" "$(dig +short A $h @$r | tail -1)"
done; echo
done
세 리졸버가 모두 같은 IP를 돌려주면 준비 완료다. 갓 산 도메인은 네임서버 정보 전파에 수십 분에서 몇 시간이 걸린다.
3. 방화벽은 두 겹이다
OCI에서 가장 많이 막히는 지점이다. 포트를 열려면 양쪽 다 열려 있어야 한다.
① 호스트 방화벽 (iptables)
sudo iptables -S INPUT
Ubuntu OCI 이미지는 기본적으로 22만 열고 나머지를 REJECT 한다. 80/443 규칙이 없으면 추가한다.
sudo iptables -I INPUT -p tcp --dport 80 -j ACCEPT
sudo iptables -I INPUT -p tcp --dport 443 -j ACCEPT
sudo netfilter-persistent save
② OCI 보안 목록 (콘솔)
☰ → Networking → Virtual Cloud Networks → (VCN) → Security Lists → Default
→ Ingress Rules → [ Add Ingress Rules ]
Stateless : No
Source Type : CIDR
Source CIDR : 0.0.0.0/0
IP Protocol : TCP
Source Port Range : (비움)
Destination Port Range : 443
서버 안에서
curl https://내도메인으로는 이걸 검증할 수 없다. 트래픽이 밖으로 안 나가고 되돌아올 수 있어서 보안 목록을 통과하지 않는다. 반드시 다른 네트워크의 브라우저로 확인한다.
4. certbot 설치
sudo apt-get update
sudo apt-get install -y certbot python3-certbot-nginx
certbot --version
systemctl list-timers certbot.timer
Ubuntu 패키지는 certbot.timer를 함께 설치한다. 별도 cron을 만들 필요가 없다.
5. 닭과 달걀 문제를 먼저 푼다
HTTP-01 방식은 nginx가 챌린지 파일을 서비스할 수 있어야 인증서가 나온다. 그런데 최종 nginx 설정은 아직 없는 인증서를 참조한다. 순환이다.
끊는 방법은 간단하다. 지금 돌고 있는 HTTP vhost에 챌린지 경로만 먼저 추가한다.
sudo mkdir -p /var/www/certbot
sudo chown -R www-data:www-data /var/www/certbot
현재 server { ... } 블록 안에 아래를 넣는다.
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
sudo nginx -t && sudo systemctl reload nginx
반드시 검증하고 넘어간다
echo "probe-123" | sudo tee /var/www/certbot/.well-known/acme-challenge/probe
curl -s http://example.kr/.well-known/acme-challenge/probe
sudo rm -f /var/www/certbot/.well-known/acme-challenge/probe
probe-123이 그대로 나와야 한다. index.html 이 나오면 아직 안 된 것이다.
함정:
systemctl reload nginx직후에는 옛 워커가 잠깐 살아 있어 이전 설정으로 응답할 수 있다. 검증에 실패하면 1~2초 기다렸다 다시 해본다. 설정이 맞는데 결과가 이상하면 이걸 먼저 의심한다.
6. 인증서 발급 — dry-run을 먼저 돌린다
Let’s Encrypt는 실패에도 횟수 제한이 있다 (도메인당 시간당 5회 실패 등). 설정 실수로 한도를 태우면 몇 시간 손해다. 스테이징으로 먼저 확인한다.
인증서는 사이트별로 나눈다
한 장에 다 몰아넣지 않는다. 나중에 한쪽 도메인만 바뀌어도 서로 영향이 없다.
COMMON="--webroot -w /var/www/certbot \
--email you@example.com --agree-tos --no-eff-email --non-interactive"
# 1) 리허설
sudo certbot certonly $COMMON --dry-run -d example.kr -d www.example.kr
sudo certbot certonly $COMMON --dry-run -d app.example.kr
# 2) 실제 발급
sudo certbot certonly $COMMON -d example.kr -d www.example.kr
sudo certbot certonly $COMMON -d app.example.kr
--webroot 방식을 쓴 이유는 nginx 설정을 certbot이 건드리지 않기 때문이다. --nginx 플러그인은 설정 파일을 자동으로 고쳐 쓰는데, 직접 관리하는 구성에서는 예상 못 한 수정이 들어가서 곤란해진다.
결과:
/etc/letsencrypt/live/example.kr/fullchain.pem
/etc/letsencrypt/live/example.kr/privkey.pem
/etc/letsencrypt/live/app.example.kr/fullchain.pem
/etc/letsencrypt/live/app.example.kr/privkey.pem
7. nginx TLS 구성
인증서 경로는 별도 스니펫으로 뺀다
배포 스크립트가 vhost를 템플릿에서 다시 렌더링하는 구조라면, 호스트마다 다른 값(= 인증서 경로)이 vhost 안에 있으면 배포할 때마다 날아간다. 저장소 밖의 스니펫으로 분리한다.
/etc/nginx/snippets/myapp-ssl.conf
ssl_certificate /etc/letsencrypt/live/app.example.kr/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.kr/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
TLS 버전을 여기에 고정하는 이유는 Ubuntu 기본 nginx.conf가 아직 TLSv1 / TLSv1.1을 켜두기 때문이다. 둘 다 폐기된 지 오래다.
ssl_stapling on;은 넣지 않았다. Let’s Encrypt가 인증서에서 OCSP 응답자 URL을 제거해서 지금은 무의미하고,"ssl_stapling" ignored, no OCSP responder URL경고만 남는다.
vhost
server {
listen 80 default_server;
server_name app.example.kr;
# 갱신이 이 경로를 쓴다. 리다이렉트보다 위에 둬야 한다.
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2 default_server;
server_name app.example.kr;
include /etc/nginx/snippets/myapp-ssl.conf;
root /var/www/myapp;
index index.html;
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
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;
}
location / {
try_files $uri $uri/ /index.html;
}
}
nginx 버전 함정:
http2 on;은 nginx 1.25.1 이상 문법이다. Ubuntu 22.04/24.04가 주는 1.24 이하에서는unknown directive "http2"로 죽는다. 구버전에서는listen 443 ssl http2;형태를 쓴다.
bash nginx -v # 먼저 확인할 것
두 번째 사이트(WordPress 등)의 vhost는 파일을 따로 만든다. 한 파일에 몰면 한쪽 배포가 다른 쪽을 덮어쓴다.
8. 리다이렉트는 맨 마지막에 켠다
여기가 사이트를 통째로 죽이기 가장 쉬운 지점이다.
443이 실제로 외부에서 열렸는지 확인하기 전에 80 → 443 리다이렉트를 걸면 안 된다. 보안 목록이 443을 막고 있으면, 80으로 들어온 요청이 접속 불가능한 443으로 떠넘겨지면서 모든 경로가 죽는다.
안전한 순서는 이렇다.
- 80은 그대로 서비스한 채 443 리스너를 나란히 올린다
- 다른 네트워크의 브라우저로
https://app.example.kr접속 확인 (자물쇠 + 인증서 정보에서 발급 대상·기관 확인) - 그때 비로소 80 블록을
return 301로 바꾼다
sudo cp /etc/nginx/sites-available/myapp /etc/nginx/sites-available/myapp.pre-redirect
# 편집 후
sudo nginx -t && sudo systemctl reload nginx
전환 후 검증
# 301이 뜨는지
curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" http://app.example.kr/
# 최종 200 + TLS 검증 통과(ssl_verify_result=0)
curl -sL -o /dev/null -w "%{http_code} tls=%{ssl_verify_result}\n" https://app.example.kr/
# ★ 챌린지 경로가 리다이렉트에 안 먹히는지 (제일 중요)
echo "renew-probe" | sudo tee /var/www/certbot/.well-known/acme-challenge/renew-probe
curl -s http://app.example.kr/.well-known/acme-challenge/renew-probe
sudo rm -f /var/www/certbot/.well-known/acme-challenge/renew-probe
마지막 항목을 빠뜨리면 3개월 뒤 갱신이 조용히 실패하고 사이트가 죽는다. 당장은 아무 증상이 없어서 알아채지 못한다.
9. 자동갱신을 실제로 검증한다
갱신 자체는 타이머가 알아서 하지만, 갱신된 인증서를 nginx가 다시 읽게 해야 한다. 훅이 없으면 파일만 새로 생기고 nginx는 옛 인증서를 계속 물고 있는다.
sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh >/dev/null <<'HOOK'
#!/bin/sh
systemctl reload nginx
HOOK
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
리허설로 끝까지 돌려본다.
sudo certbot renew --dry-run
Congratulations, all simulated renewals succeeded: 가 나와야 한다.
인증서가 여러 장이면 몇 분씩 걸릴 수 있다. 중간에 끊지 말고 기다린다. 타임아웃으로 죽인 걸 실패로 오해하기 쉽다.
상태 확인:
sudo certbot certificates
systemctl list-timers certbot.timer
삽질 기록 요약
실제로 걸렸던 것들이다.
| 증상 | 원인 | 대응 |
|---|---|---|
unknown directive "http2" |
http2 on; 은 nginx 1.25.1+ 문법 |
1.24 이하는 listen 443 ssl http2; |
| 챌린지 경로에 index.html 이 응답 | reload 직후 옛 워커가 응답 | 1~2초 후 재시도 |
"ssl_stapling" ignored 경고 |
LE 인증서에 OCSP 응답자 URL 없음 | ssl_stapling 제거 |
| 서버 안 curl은 되는데 브라우저는 안 됨 | OCI 보안 목록 미개방 | 콘솔에서 Ingress 규칙 추가 |
| 3개월 뒤 갱신 실패 | 리다이렉트가 /.well-known/ 까지 삼킴 |
80 블록에 챌린지 예외 유지 |
체크리스트
- [ ] 공인 IP를 예약 IP로 전환 (도메인 사기 전에)
- [ ] 도메인 구매, A 레코드 3개 등록 (
@,www,app) - [ ] 리졸버 3곳에서 전파 확인
- [ ] iptables 80/443 개방 + 영구 저장
- [ ] OCI 보안 목록 80/443 Ingress 규칙
- [ ] certbot 설치,
/var/www/certbot생성 - [ ] 현재 vhost에 챌린지 경로 추가 후 파일로 검증
- [ ]
--dry-run통과 후 실제 발급 - [ ] 인증서 경로는 스니펫으로 분리
- [ ] 443 리스너 올리고 외부 브라우저로 확인
- [ ] 확인 후에야 80 → 443 리다이렉트
- [ ] 갱신 훅 설치 +
certbot renew --dry-run통과 - [ ] 챌린지 경로가 리다이렉트를 피하는지 재확인
이어지는 글
- OCI Always Free 인스턴스는 언제 회수되는가 — 세 조건과 확인 방법
다음 편
- MariaDB 컨테이너로 DB 통합 (PostgreSQL 이관)
- WordPress를 같은 서버에 올리고 두 번째 도메인 연결
- WP-CLI로 초기 설치와 플러그인 무인 구성
[…] 서버를 어떻게 세웠는지는 OCI 무료 서버에 도메인 붙이고 HTTPS 적용하기에 […]