<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Homelab on Chanyeol Dev</title>
    <link>https://chanyeols.com/tags/homelab/</link>
    <description>Recent content in Homelab on Chanyeol Dev</description>
    <generator>Hugo</generator>
    <language>ko-kr</language>
    <lastBuildDate>Sat, 04 Apr 2026 09:00:00 +0900</lastBuildDate>
    <atom:link href="https://chanyeols.com/tags/homelab/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>노트북 한 대로 홈서버 구축하기 - 12편 완전 정복 총정리</title>
      <link>https://chanyeols.com/posts/part-00-summary/</link>
      <pubDate>Sat, 04 Apr 2026 09:00:00 +0900</pubDate>
      <guid>https://chanyeols.com/posts/part-00-summary/</guid>
      <description>ThinkPad 노트북 한 대로 NAS, 사진 관리, 비밀번호 관리, 모니터링, 보안, 자동 백업, 커스텀 대시보드까지 구축한 홈서버 구축기 12편을 한 페이지로 정리합니다.</description>
      <content:encoded><![CDATA[<h2 id="시작은-단순했다">시작은 단순했다</h2>
<p>클라우드 비용이 아깝고, NAS도 필요하고, 사이드 프로젝트 서버도 있으면 좋겠고. 마침 집에 안 쓰는 ThinkPad가 있었다.</p>
<p>그렇게 시작된 홈서버 구축기가 어느새 12편이 됐다.</p>
<hr>
<h2 id="최종-구성">최종 구성</h2>
<p><img alt="전체 아키텍처 구성도" loading="lazy" src="/images/homeserver-01-architecture.png"></p>
<ul>
<li><strong>기기</strong>: ThinkPad E15 Gen3 (Ryzen 5 5600U, RAM 16GB)</li>
<li><strong>OS</strong>: Ubuntu Server 24.04 LTS</li>
<li><strong>네트워크</strong>: Tailscale VPN + OCI Nginx 리버스 프록시</li>
<li><strong>도메인</strong>: yourdomain.com (Cloudflare)</li>
<li><strong>스토리지</strong>: 256GB SSD (OS/Docker) + 1TB SSD (/mnt/data, NTFS)</li>
</ul>
<hr>
<h2 id="운영-중인-서비스-전체-목록">운영 중인 서비스 전체 목록</h2>
<table>
  <thead>
      <tr>
          <th>포트</th>
          <th>서비스</th>
          <th>접근 방식</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>2283</td>
          <td>Immich (사진 관리)</td>
          <td>도메인 (OCI 프록시)</td>
      </tr>
      <tr>
          <td>9090</td>
          <td>Filebrowser (파일 관리)</td>
          <td>도메인 (OCI 프록시)</td>
      </tr>
      <tr>
          <td>11000</td>
          <td>Vaultwarden (비밀번호)</td>
          <td>도메인 (OCI 프록시)</td>
      </tr>
      <tr>
          <td>13000</td>
          <td>Grafana (모니터링)</td>
          <td>Tailscale VPN</td>
      </tr>
      <tr>
          <td>19000</td>
          <td>Portainer (Docker GUI)</td>
          <td>Tailscale VPN</td>
      </tr>
      <tr>
          <td>19090</td>
          <td>Prometheus</td>
          <td>Tailscale VPN</td>
      </tr>
      <tr>
          <td>19100</td>
          <td>Node Exporter</td>
          <td>내부 수집용</td>
      </tr>
      <tr>
          <td>23000</td>
          <td>컨테이너 대시보드 (React)</td>
          <td>Tailscale VPN</td>
      </tr>
      <tr>
          <td>28080</td>
          <td>컨테이너 대시보드 (Spring)</td>
          <td>Tailscale VPN</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="12편-한눈에-보기">12편 한눈에 보기</h2>
<h3 id="1편--왜-홈서버인가--전체-아키텍처">1편 — 왜 홈서버인가? + 전체 아키텍처</h3>
<p><a href="/posts/part-01-intro">→ 보러가기</a></p>
<p>클라우드 대신 홈서버를 선택한 이유와 Tailscale + OCI + Cloudflare로 구성한 전체 아키텍처를 소개한다.</p>
<hr>
<h3 id="2편--ubuntu-server-설치--tailscale-vpn">2편 — Ubuntu Server 설치 + Tailscale VPN</h3>
<p><a href="/posts/part-02-ubuntu-tailscale">→ 보러가기</a></p>
<p>Ventoy로 Ubuntu Server 24.04를 설치하고, Tailscale VPN으로 홈서버와 OCI 인스턴스를 하나의 사설 네트워크로 묶었다.</p>
<hr>
<h3 id="3편--외장-ssd-마운트--filebrowser-원격-파일-관리">3편 — 외장 SSD 마운트 + Filebrowser 원격 파일 관리</h3>
<p><a href="/posts/part-03-ssd-filebrowser">→ 보러가기</a></p>
<p>NTFS 외장 SSD를 마운트하고 ntfsfix로 읽기/쓰기 문제를 해결했다. Docker로 Filebrowser를 띄워서 브라우저에서 파일을 관리할 수 있게 됐다.</p>
<hr>
<h3 id="4편--immich로-구글-포토-대체하기">4편 — Immich로 구글 포토 대체하기</h3>
<p><a href="/posts/part-04-immich">→ 보러가기</a></p>
<p><img alt="Immich 접속 화면" loading="lazy" src="/images/homeserver-04-immich.png"></p>
<p>Docker Compose 설치 과정에서 구버전 docker, NTFS 권한 문제 등 4가지 트러블슈팅을 겪었다. DB는 반드시 ext4에 둬야 한다.</p>
<hr>
<h3 id="5편--vaultwarden으로-비밀번호-자체-호스팅">5편 — Vaultwarden으로 비밀번호 자체 호스팅</h3>
<p><a href="/posts/part-05-vaultwarden">→ 보러가기</a></p>
<p>구글 비밀번호를 CSV로 내보내서 Vaultwarden으로 이전했다. Bitwarden 브라우저 확장과 연동하면 기존과 사용감이 동일하다.</p>
<hr>
<h3 id="6편--portainer-ce로-docker-gui-관리">6편 — Portainer CE로 Docker GUI 관리</h3>
<p><a href="/posts/part-06-portainer">→ 보러가기</a></p>
<p><img alt="Portainer 대시보드" loading="lazy" src="/images/homeserver-06-portainer.png"></p>
<p>컨테이너가 8개를 넘어가면서 CLI 관리가 한계에 달했다. Portainer CE로 웹 UI에서 컨테이너를 관리한다. 보안상 VPN 안에서만 접근한다.</p>
<hr>
<h3 id="7편--grafana--prometheus로-홈서버-모니터링">7편 — Grafana + Prometheus로 홈서버 모니터링</h3>
<p><a href="/posts/part-07-monitoring">→ 보러가기</a></p>
<p><img alt="Grafana 모니터링 대시보드" loading="lazy" src="/images/homeserver-07-grafana.png"></p>
<p>Node Exporter를 홈서버 + OCI 두 곳에 설치하고, Prometheus가 Tailscale VPN을 통해 두 서버의 메트릭을 수집한다. ThinkPad 배터리 메트릭도 추가했다.</p>
<hr>
<h3 id="8편--fail2ban으로-ssh-브루트포스-차단">8편 — Fail2ban으로 SSH 브루트포스 차단</h3>
<p><a href="/posts/part-08-fail2ban">→ 보러가기</a></p>
<p>auth.log를 열어보니 수천 줄의 SSH 로그인 시도가 쌓여 있었다. Fail2ban으로 자동 차단하고, recidive jail로 반복 공격자는 7일 장기 차단한다.</p>
<hr>
<h3 id="9편--certbot-expand로-ssl-서브도메인-추가">9편 — certbot &ndash;expand로 SSL 서브도메인 추가</h3>
<p><a href="/posts/part-09-ssl">→ 보러가기</a></p>
<p>서비스가 늘어날 때마다 SSL 인증서에 서브도메인을 추가해야 한다. <code>--expand</code> 옵션과 셸 스크립트로 관리하면 <code>-d</code> 한 줄만 추가하면 된다.</p>
<p><img alt="브라우저 인증서 정보" loading="lazy" src="/images/homeserver-09-browser-cert.png"></p>
<hr>
<h3 id="10편--postgresql-자동-백업-pg_dump--cron">10편 — PostgreSQL 자동 백업 (pg_dump + cron)</h3>
<p><a href="/posts/part-10-postgresql-backup">→ 보러가기</a></p>
<p>Immich DB가 날아가면 앨범, 태그, 얼굴 인식 데이터가 전부 사라진다. <code>docker exec</code>으로 pg_dumpall을 실행하고 cron으로 매일 새벽 3시에 자동 백업한다.</p>
<hr>
<h3 id="11편--tlp--thinkfan--swap-튜닝으로-운영-최적화">11편 — TLP + thinkfan + Swap 튜닝으로 운영 최적화</h3>
<p><a href="/posts/part-11-tuning">→ 보러가기</a></p>
<p>CPU 온도 85°C → 55°C, Swap 사용률 26% → 7%. TLP 터보 부스트 비활성화와 swappiness 튜닝으로 24시간 운영에 최적화했다.</p>
<p><img alt="Grafana - Swap 튜닝 전후" loading="lazy" src="/images/homeserver-11-swap-after.png"></p>
<hr>
<h3 id="12편--직접-만든-컨테이너-대시보드-spring-boot--react--sse">12편 — 직접 만든 컨테이너 대시보드 (Spring Boot + React + SSE)</h3>
<p><a href="/posts/part-12-dashboard">→ 보러가기</a></p>
<p><img alt="커스텀 대시보드 UI" loading="lazy" src="/images/homeserver-12-dashboard-ui.png"></p>
<p>Portainer 대신 Spring Boot + React로 컨테이너 모니터링 대시보드를 직접 만들었다. SSE로 실시간 로그 스트리밍을 구현한 게 핵심이다.</p>
<hr>
<h2 id="돌아보며">돌아보며</h2>
<p>파일 서버 하나 만들려고 시작했는데 어쩌다 보니 이렇게 됐다.</p>
<p>홈서버의 가장 큰 장점은 공부할 게 계속 생긴다는 거다. 서비스 하나 올리면 모니터링이 필요하고, 모니터링 하다 보면 튜닝하고 싶어지고, 외부에 열면 보안이 신경 쓰이고. 그 과정에서 Linux, Docker, Nginx, Spring Boot, React를 실제로 써보게 된다.</p>
<p>클라우드가 편하긴 하지만, 직접 서버를 굴리는 재미는 또 다르다.</p>
]]></content:encoded>
    </item>
    <item>
      <title>노트북으로 홈서버 구축하기 - 직접 만든 컨테이너 대시보드 (Spring Boot &#43; React &#43; SSE) (12편)</title>
      <link>https://chanyeols.com/posts/part-12-dashboard/</link>
      <pubDate>Fri, 03 Apr 2026 00:00:00 +0000</pubDate>
      <guid>https://chanyeols.com/posts/part-12-dashboard/</guid>
      <description>Portainer 대신 Spring Boot &#43; React로 컨테이너 모니터링 대시보드를 직접 만들었다. Docker API 연동, SSE 실시간 로그 스트리밍, 멀티스테이지 Docker 빌드까지 다룹니다.</description>
      <content:encoded><![CDATA[<h2 id="왜-직접-만들었나">왜 직접 만들었나</h2>
<p>Portainer가 있는데 굳이 만든 이유는 단순하다. 그냥 만들어보고 싶었다.</p>
<p>Portainer는 기능이 너무 많다. 나는 컨테이너 목록 확인, 시작/중지/재시작, 로그 보기 딱 세 가지만 필요했다. 이 정도면 직접 만들 수 있겠다 싶어서 Spring Boot 백엔드 + React 프론트엔드로 구성했다.</p>
<hr>
<h2 id="완성-화면">완성 화면</h2>
<ul>
<li>컨테이너 목록을 카드 UI로 표시 (상태별 색상 배지)</li>
<li>Start / Stop / Restart 원클릭 제어</li>
<li>Logs 버튼으로 실시간 로그 스트리밍 (SSE)</li>
<li>5초 주기 자동 갱신</li>
</ul>
<p><img alt="대시보드 메인 UI - 컨테이너 카드 목록" loading="lazy" src="/images/homeserver-12-dashboard-ui.png"></p>
<h2 id="기술-스택">기술 스택</h2>
<table>
  <thead>
      <tr>
          <th>역할</th>
          <th>기술</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>백엔드</td>
          <td>Spring Boot 3.5, Java 17, Maven</td>
      </tr>
      <tr>
          <td>Docker 연동</td>
          <td>docker-java 3.3.6 (zerodep transport)</td>
      </tr>
      <tr>
          <td>실시간 로그</td>
          <td>SSE (Server-Sent Events) + WebFlux</td>
      </tr>
      <tr>
          <td>프론트엔드</td>
          <td>React 19, plain CSS</td>
      </tr>
      <tr>
          <td>배포</td>
          <td>Docker Compose + nginx</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="백엔드-spring-boot">백엔드 (Spring Boot)</h2>
<h3 id="의존성-설정">의존성 설정</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic">&lt;!-- Docker Java --&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#7ee787">&lt;dependency&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&lt;groupId&gt;</span>com.github.docker-java<span style="color:#7ee787">&lt;/groupId&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&lt;artifactId&gt;</span>docker-java<span style="color:#7ee787">&lt;/artifactId&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&lt;version&gt;</span>3.3.6<span style="color:#7ee787">&lt;/version&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#7ee787">&lt;/dependency&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#7ee787">&lt;dependency&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&lt;groupId&gt;</span>com.github.docker-java<span style="color:#7ee787">&lt;/groupId&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&lt;artifactId&gt;</span>docker-java-transport-zerodep<span style="color:#7ee787">&lt;/artifactId&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#7ee787">&lt;version&gt;</span>3.3.6<span style="color:#7ee787">&lt;/version&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#7ee787">&lt;/dependency&gt;</span>
</span></span></code></pre></div><p>transport 선택이 중요하다. <code>httpclient5</code> 트랜스포트는 Unix 소켓을 제대로 처리하지 못해서 아래 에러가 난다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>Connect to unix://localhost:2375 failed
</span></span></code></pre></div><p><strong><code>zerodep</code> 트랜스포트</strong>를 써야 <code>/var/run/docker.sock</code> 연결이 정상 동작한다.</p>
<h3 id="dockerclient-빈-설정">DockerClient 빈 설정</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-java" data-lang="java"><span style="display:flex;"><span><span style="color:#d2a8ff;font-weight:bold">@Configuration</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">class</span> <span style="color:#f0883e;font-weight:bold">DockerConfig</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#d2a8ff;font-weight:bold">@Value</span>(<span style="color:#a5d6ff">&#34;${docker.host:unix:///var/run/docker.sock}&#34;</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">private</span><span style="color:#6e7681"> </span>String<span style="color:#6e7681"> </span>dockerHost;<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#d2a8ff;font-weight:bold">@Bean</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span>DockerClient<span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">dockerClient</span>()<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>DockerClientConfig<span style="color:#6e7681"> </span>config<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>DefaultDockerClientConfig.createDefaultConfigBuilder()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.withDockerHost(dockerHost)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.build();<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>DockerHttpClient<span style="color:#6e7681"> </span>httpClient<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">new</span><span style="color:#6e7681"> </span>ZerodepDockerHttpClient.Builder()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.dockerHost(config.getDockerHost())<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.sslConfig(config.getSSLConfig())<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.build();<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span>DockerClientImpl.getInstance(config,<span style="color:#6e7681"> </span>httpClient);<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681">
</span></span></span></code></pre></div><p><code>application.properties</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>docker.host=unix:///var/run/docker.sock
</span></span></code></pre></div><h3 id="컨테이너-목록-조회">컨테이너 목록 조회</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-java" data-lang="java"><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span>List<span style="color:#ff7b72;font-weight:bold">&lt;</span>ContainerSummaryDto<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">listContainers</span>(<span style="color:#ff7b72">boolean</span><span style="color:#6e7681"> </span>all)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span>dockerClient.listContainersCmd()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.withShowAll(all)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.exec()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.stream()<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.map(<span style="color:#ff7b72">this</span>::toDto)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.toList();<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681">
</span></span></span></code></pre></div><h3 id="실시간-로그-스트리밍-sse">실시간 로그 스트리밍 (SSE)</h3>
<p>이 프로젝트의 핵심이다. docker-java의 콜백 기반 API를 WebFlux의 <code>Flux</code>로 브리징한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-java" data-lang="java"><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span>Flux<span style="color:#ff7b72;font-weight:bold">&lt;</span>String<span style="color:#ff7b72;font-weight:bold">&gt;</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">streamLogs</span>(String<span style="color:#6e7681"> </span>containerId,<span style="color:#6e7681"> </span><span style="color:#ff7b72">int</span><span style="color:#6e7681"> </span>tail)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span>Flux.create(sink<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span>dockerClient.logContainerCmd(containerId)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.withStdOut(<span style="color:#79c0ff">true</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.withStdErr(<span style="color:#79c0ff">true</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.withFollowStream(<span style="color:#79c0ff">true</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.withTail(tail)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.withTimestamps(<span style="color:#79c0ff">true</span>)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>.exec(<span style="color:#ff7b72">new</span><span style="color:#6e7681"> </span>ResultCallback.Adapter<span style="color:#ff7b72;font-weight:bold">&lt;&gt;</span>()<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span><span style="color:#d2a8ff;font-weight:bold">@Override</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">void</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">onNext</span>(Frame<span style="color:#6e7681"> </span>frame)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                        </span>sink.next(<span style="color:#ff7b72">new</span><span style="color:#6e7681"> </span>String(frame.getPayload()).stripTrailing());<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span><span style="color:#d2a8ff;font-weight:bold">@Override</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">void</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">onError</span>(Throwable<span style="color:#6e7681"> </span>throwable)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                        </span>sink.error(throwable);<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span><span style="color:#d2a8ff;font-weight:bold">@Override</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">void</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">onComplete</span>()<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                        </span>sink.complete();<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                    </span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                </span>});<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span>});<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681">
</span></span></span></code></pre></div><p>컨트롤러에서 SSE로 내보낸다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-java" data-lang="java"><span style="display:flex;"><span><span style="color:#d2a8ff;font-weight:bold">@GetMapping</span>(value<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;/{id}/logs&#34;</span>,<span style="color:#6e7681"> </span>produces<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span>MediaType.TEXT_EVENT_STREAM_VALUE)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">public</span><span style="color:#6e7681"> </span>Flux<span style="color:#ff7b72;font-weight:bold">&lt;</span>ServerSentEvent<span style="color:#ff7b72;font-weight:bold">&lt;</span>String<span style="color:#ff7b72;font-weight:bold">&gt;&gt;</span><span style="color:#6e7681"> </span><span style="color:#d2a8ff;font-weight:bold">streamLogs</span>(<span style="color:#d2a8ff;font-weight:bold">@PathVariable</span><span style="color:#6e7681"> </span>String<span style="color:#6e7681"> </span>id,<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">                                                 </span><span style="color:#d2a8ff;font-weight:bold">@RequestParam</span>(defaultValue<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;100&#34;</span>)<span style="color:#6e7681"> </span><span style="color:#ff7b72">int</span><span style="color:#6e7681"> </span>tail)<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span>containerService.streamLogs(id,<span style="color:#6e7681"> </span>tail)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">            </span>.map(line<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">-&gt;</span><span style="color:#6e7681"> </span>ServerSentEvent.<span style="color:#ff7b72;font-weight:bold">&lt;</span>String<span style="color:#ff7b72;font-weight:bold">&gt;</span>builder().data(line).build());<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span>}<span style="color:#6e7681">
</span></span></span></code></pre></div><h3 id="api-목록">API 목록</h3>
<table>
  <thead>
      <tr>
          <th>Method</th>
          <th>Endpoint</th>
          <th>설명</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GET</td>
          <td><code>/api/containers?all=true</code></td>
          <td>컨테이너 목록</td>
      </tr>
      <tr>
          <td>GET</td>
          <td><code>/api/containers/{id}/logs?tail=100</code></td>
          <td>SSE 로그 스트리밍</td>
      </tr>
      <tr>
          <td>POST</td>
          <td><code>/api/containers/{id}/start</code></td>
          <td>시작</td>
      </tr>
      <tr>
          <td>POST</td>
          <td><code>/api/containers/{id}/stop</code></td>
          <td>정지</td>
      </tr>
      <tr>
          <td>POST</td>
          <td><code>/api/containers/{id}/restart</code></td>
          <td>재시작</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="프론트엔드-react">프론트엔드 (React)</h2>
<h3 id="프로젝트-구조">프로젝트 구조</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>src/
</span></span><span style="display:flex;"><span>  api.js               # API 호출 함수
</span></span><span style="display:flex;"><span>  App.js               # 루트 컴포넌트 (목록 + 폴링)
</span></span><span style="display:flex;"><span>  App.css              # 전체 스타일 (다크 테마)
</span></span><span style="display:flex;"><span>  components/
</span></span><span style="display:flex;"><span>    ContainerCard.js   # 카드 UI + 액션 버튼
</span></span><span style="display:flex;"><span>    LogViewer.js       # SSE 실시간 로그 모달
</span></span></code></pre></div><h3 id="5초-폴링">5초 폴링</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">const</span> POLL_INTERVAL <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#a5d6ff">5000</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">export</span> <span style="color:#ff7b72">default</span> <span style="color:#ff7b72">function</span> App() {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> [containers, setContainers] <span style="color:#ff7b72;font-weight:bold">=</span> useState([]);
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> [logTarget, setLogTarget] <span style="color:#ff7b72;font-weight:bold">=</span> useState(<span style="color:#79c0ff">null</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> load <span style="color:#ff7b72;font-weight:bold">=</span> useCallback(<span style="color:#ff7b72">async</span> () =&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> data <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">await</span> fetchContainers();
</span></span><span style="display:flex;"><span>    setContainers(data);
</span></span><span style="display:flex;"><span>  }, []);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  useEffect(() =&gt; {
</span></span><span style="display:flex;"><span>    load();
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> timer <span style="color:#ff7b72;font-weight:bold">=</span> setInterval(load, POLL_INTERVAL);
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> () =&gt; clearInterval(timer);
</span></span><span style="display:flex;"><span>  }, [load]);
</span></span><span style="display:flex;"><span>  <span style="color:#8b949e;font-style:italic">// ...
</span></span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>setInterval</code>을 <code>useEffect</code> cleanup에서 <code>clearInterval</code>로 정리해야 컴포넌트 언마운트 시 폴링이 멈춘다.</p>
<h3 id="상태별-색상-배지">상태별 색상 배지</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#ff7b72">const</span> STATE_META <span style="color:#ff7b72;font-weight:bold">=</span> {
</span></span><span style="display:flex;"><span>  running<span style="color:#ff7b72;font-weight:bold">:</span> { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;Running&#39;</span>, color<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;#22c55e&#39;</span> },
</span></span><span style="display:flex;"><span>  exited<span style="color:#ff7b72;font-weight:bold">:</span>  { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;Exited&#39;</span>,  color<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;#ef4444&#39;</span> },
</span></span><span style="display:flex;"><span>  paused<span style="color:#ff7b72;font-weight:bold">:</span>  { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;Paused&#39;</span>,  color<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;#f59e0b&#39;</span> },
</span></span><span style="display:flex;"><span>  created<span style="color:#ff7b72;font-weight:bold">:</span> { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;Created&#39;</span>, color<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;#6b7280&#39;</span> },
</span></span><span style="display:flex;"><span>  dead<span style="color:#ff7b72;font-weight:bold">:</span>    { label<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;Dead&#39;</span>,    color<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#39;#991b1b&#39;</span> },
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><h3 id="sse-실시간-로그">SSE 실시간 로그</h3>
<p>브라우저 내장 <code>EventSource</code> API를 사용한다. WebSocket보다 단방향 스트리밍에 적합하고 서버 구현도 단순하다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span>useEffect(() =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">const</span> es <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">new</span> EventSource(getLogUrl(containerId));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  es.onopen <span style="color:#ff7b72;font-weight:bold">=</span> () =&gt; setConnected(<span style="color:#79c0ff">true</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  es.onmessage <span style="color:#ff7b72;font-weight:bold">=</span> (e) =&gt; {
</span></span><span style="display:flex;"><span>    setLines((prev) =&gt; {
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">const</span> next <span style="color:#ff7b72;font-weight:bold">=</span> [...prev, e.data];
</span></span><span style="display:flex;"><span>      <span style="color:#8b949e;font-style:italic">// 메모리 관리: 최대 2000줄 유지
</span></span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">return</span> next.length <span style="color:#ff7b72;font-weight:bold">&gt;</span> <span style="color:#a5d6ff">2000</span> <span style="color:#ff7b72;font-weight:bold">?</span> next.slice(<span style="color:#ff7b72;font-weight:bold">-</span><span style="color:#a5d6ff">2000</span>) <span style="color:#ff7b72;font-weight:bold">:</span> next;
</span></span><span style="display:flex;"><span>    });
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  es.onerror <span style="color:#ff7b72;font-weight:bold">=</span> () =&gt; {
</span></span><span style="display:flex;"><span>    setConnected(<span style="color:#79c0ff">false</span>);
</span></span><span style="display:flex;"><span>    setError(<span style="color:#a5d6ff">&#39;Connection lost.&#39;</span>);
</span></span><span style="display:flex;"><span>    es.close();
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff7b72">return</span> () =&gt; es.close(); <span style="color:#8b949e;font-style:italic">// 모달 닫으면 SSE 연결 종료
</span></span></span><span style="display:flex;"><span>}, [containerId]);
</span></span></code></pre></div><p>cleanup에서 <code>es.close()</code>를 빠뜨리면 모달을 닫아도 서버와 연결이 계속 유지된다. 꼭 넣어야 한다.</p>
<p><img alt="Logs 모달 - SSE 실시간 로그 스트리밍" loading="lazy" src="/images/homeserver-12-dashboard-logs.png"></p>
<hr>
<h2 id="배포">배포</h2>
<h3 id="백엔드-dockerfile-멀티스테이지-빌드">백엔드 Dockerfile (멀티스테이지 빌드)</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">eclipse-temurin:17-jdk</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">AS</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">builder</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">WORKDIR</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">/app</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> .mvn/ .mvn/<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> mvnw pom.xml ./<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> ./mvnw dependency:go-offline -q<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> src/ src/<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> ./mvnw package -DskipTests -q<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">eclipse-temurin:17-jre</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">WORKDIR</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">/app</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> --from<span style="color:#ff7b72;font-weight:bold">=</span>builder /app/target/*.jar app.jar<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">ENTRYPOINT</span> [<span style="color:#a5d6ff">&#34;java&#34;</span>, <span style="color:#a5d6ff">&#34;-jar&#34;</span>, <span style="color:#a5d6ff">&#34;app.jar&#34;</span>]<span style="color:#f85149">
</span></span></span></code></pre></div><h3 id="프론트엔드-dockerfile-멀티스테이지-빌드">프론트엔드 Dockerfile (멀티스테이지 빌드)</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">node:20-alpine</span><span style="color:#6e7681"> </span><span style="color:#ff7b72">AS</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">build</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">WORKDIR</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">/app</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> package.json package-lock.json* ./<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> npm ci --silent<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> . .<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">RUN</span> npm run build<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">FROM</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">nginx:alpine</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> --from<span style="color:#ff7b72;font-weight:bold">=</span>build /app/build /usr/share/nginx/html<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">COPY</span> nginx.conf /etc/nginx/conf.d/default.conf<span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">EXPOSE</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">80</span><span style="color:#f85149">
</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">CMD</span> [<span style="color:#a5d6ff">&#34;nginx&#34;</span>, <span style="color:#a5d6ff">&#34;-g&#34;</span>, <span style="color:#a5d6ff">&#34;daemon off;&#34;</span>]<span style="color:#f85149">
</span></span></span></code></pre></div><h3 id="nginxconf--sse-버퍼링-비활성화가-핵심">nginx.conf — SSE 버퍼링 비활성화가 핵심</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#ff7b72">server</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">listen</span> <span style="color:#a5d6ff">80</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">root</span> <span style="color:#a5d6ff">/usr/share/nginx/html</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">index</span> <span style="color:#a5d6ff">index.html</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">location</span> <span style="color:#a5d6ff">/api/</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_pass</span> <span style="color:#a5d6ff">http://100.109.108.36:28080</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_http_version</span> <span style="color:#a5d6ff">1</span><span style="color:#a5d6ff">.1</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">Connection</span> <span style="color:#a5d6ff">&#39;&#39;</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_buffering</span> <span style="color:#79c0ff;font-weight:bold">off</span>;       <span style="color:#8b949e;font-style:italic"># SSE 필수 설정
</span></span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_cache</span> <span style="color:#79c0ff;font-weight:bold">off</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">chunked_transfer_encoding</span> <span style="color:#79c0ff;font-weight:bold">on</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">location</span> <span style="color:#a5d6ff">/</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">try_files</span> <span style="color:#79c0ff">$uri</span> <span style="color:#79c0ff">$uri/</span> <span style="color:#a5d6ff">/index.html</span>;  <span style="color:#8b949e;font-style:italic"># SPA 라우팅
</span></span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>proxy_buffering off</code>가 없으면 nginx가 SSE 응답을 버퍼에 쌓았다가 한꺼번에 보내서 실시간성이 깨진다. 삽질 포인트다.</p>
<h3 id="배포-플로우">배포 플로우</h3>
<p>서버에서 직접 빌드하면 node_modules 설치에 시간이 오래 걸린다. 로컬에서 이미지를 만들고 tar로 전송하는 방식을 선택했다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 로컬에서 이미지 빌드</span>
</span></span><span style="display:flex;"><span>docker build -t dashboard-front ./frontend
</span></span><span style="display:flex;"><span>docker build -t dashboard-back ./backend
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># tar로 저장</span>
</span></span><span style="display:flex;"><span>docker save dashboard-front | gzip &gt; dashboard-front.tar.gz
</span></span><span style="display:flex;"><span>docker save dashboard-back | gzip &gt; dashboard-back.tar.gz
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 서버로 전송</span>
</span></span><span style="display:flex;"><span>scp dashboard-front.tar.gz dashboard-back.tar.gz user@홈서버IP:~/dashboard/
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 서버에서 로드 &amp; 실행</span>
</span></span><span style="display:flex;"><span>ssh user@홈서버IP
</span></span><span style="display:flex;"><span>cd ~/dashboard
</span></span><span style="display:flex;"><span>docker load &lt; dashboard-front.tar.gz
</span></span><span style="display:flex;"><span>docker load &lt; dashboard-back.tar.gz
</span></span><span style="display:flex;"><span>docker compose up -d
</span></span></code></pre></div><h3 id="docker-composeyml">docker-compose.yml</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">services</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">dashboard-back</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">dashboard-back</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">dashboard-back</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;28080:8080&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">/var/run/docker.sock:/var/run/docker.sock</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">restart</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">unless-stopped</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">dashboard-front</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">dashboard-front</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">dashboard-front</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;23000:80&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">restart</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">unless-stopped</span><span style="color:#6e7681">
</span></span></span></code></pre></div><p>백엔드 컨테이너에도 <code>/var/run/docker.sock</code>을 마운트해서 호스트의 Docker 데몬에 접근한다.</p>
<hr>
<h2 id="구현하면서-신경-쓴-것들">구현하면서 신경 쓴 것들</h2>
<ul>
<li><strong>SSE cleanup</strong>: <code>useEffect</code> return에서 <code>es.close()</code> 호출 필수. 안 하면 모달 닫아도 연결 유지</li>
<li><strong>메모리 관리</strong>: 로그 줄 수를 2000줄로 제한해 장시간 열어둬도 브라우저가 버벅이지 않게 처리</li>
<li><strong>nginx SSE 설정</strong>: <code>proxy_buffering off</code> 없으면 로그가 실시간으로 안 온다</li>
<li><strong>zerodep transport</strong>: docker-java에서 Unix 소켓 연결 시 반드시 zerodep 사용</li>
</ul>
<p>Stop 버튼을 누르면 실제로 컨테이너가 정지되는 걸 Portainer에서 교차 확인했다.</p>
<p><img alt="Portainer에서 Stop 확인 - 컨테이너 정지 상태" loading="lazy" src="/images/homeserver-12-portainer-confirm.png"></p>
<hr>
<h2 id="마무리">마무리</h2>
<p>12편에 걸쳐 ThinkPad 노트북 한 대로 홈서버를 구축한 과정을 정리했다.</p>
<p>처음엔 파일 서버 하나 만들려고 시작했는데 어쩌다 보니 사진 관리, 비밀번호 관리, 모니터링, 보안, 자동 백업에 커스텀 대시보드까지 만들어버렸다. 홈서버는 공부할 게 계속 생긴다는 게 가장 큰 장점이자 단점이다.</p>
<h2 id="시리즈-구성">시리즈 구성</h2>
<p>이 구축기는 총 12편으로 구성된다.</p>
<ol>
<li><a href="/posts/part-01-intro">왜 홈서버인가? + 전체 아키텍처</a></li>
<li><a href="/posts/part-02-ubuntu-tailscale">Ubuntu Server 설치 + Tailscale VPN</a></li>
<li><a href="/posts/part-03-ssd-filebrowser">외장 SSD 마운트 + Filebrowser 원격 파일 관리</a></li>
<li><a href="/posts/part-04-immich">Immich로 구글 포토 대체하기</a></li>
<li><a href="/posts/part-05-vaultwarden">Vaultwarden으로 비밀번호 자체 호스팅</a></li>
<li><a href="/posts/part-06-portainer">Portainer CE로 Docker GUI 관리</a></li>
<li><a href="/posts/part-07-monitoring">Grafana + Prometheus로 홈서버 모니터링</a></li>
<li><a href="/posts/part-08-fail2ban">Fail2ban으로 SSH 브루트포스 차단</a></li>
<li><a href="/posts/part-09-ssl">certbot &ndash;expand로 SSL 서브도메인 추가</a></li>
<li><a href="/posts/part-10-postgresql-backup">PostgreSQL 자동 백업 (pg_dump + cron)</a></li>
<li><a href="/posts/part-11-tuning">TLP + thinkfan + Swap 튜닝으로 운영 최적화</a></li>
<li><a href="/posts/part-12-dashboard">직접 만든 컨테이너 대시보드 (Spring Boot + React + SSE)</a> ← 지금 여기</li>
</ol>
]]></content:encoded>
    </item>
    <item>
      <title>노트북으로 홈서버 구축하기 - TLP &#43; thinkfan &#43; Swap 튜닝으로 운영 최적화 (11편)</title>
      <link>https://chanyeols.com/posts/part-11-tuning/</link>
      <pubDate>Thu, 02 Apr 2026 00:00:00 +0000</pubDate>
      <guid>https://chanyeols.com/posts/part-11-tuning/</guid>
      <description>24시간 돌아가는 홈서버의 CPU 온도를 85°C에서 55°C로 낮추고, swappiness 튜닝으로 불필요한 Swap 사용을 줄인 과정을 정리합니다.</description>
      <content:encoded><![CDATA[<h2 id="두-가지-문제">두 가지 문제</h2>
<p>홈서버를 며칠 돌려보니 두 가지가 눈에 띄었다.</p>
<ol>
<li>Immich 썸네일 생성 같은 작업이 걸리면 <strong>CPU 온도가 85°C까지 치솟는다</strong>. 24시간 켜두는 서버라 장기적으로 하드웨어에 좋지 않다.</li>
<li>Grafana 대시보드를 보니 <strong>RAM 사용률이 38%인데 Swap을 26%나 사용</strong>하고 있었다. RAM이 절반도 안 찼는데 Swap을 쓰는 건 비정상이다.</li>
</ol>
<p>두 문제를 각각 TLP + thinkfan, swappiness 튜닝으로 해결했다.</p>
<hr>
<h2 id="1부-tlp--thinkfan으로-온도-낮추기">1부: TLP + thinkfan으로 온도 낮추기</h2>
<h3 id="tlp-설치">TLP 설치</h3>
<p>TLP는 Linux용 전력 관리 도구다. 설치만 해도 기본값으로 어느 정도 효과가 있고, ThinkPad에 맞게 튜닝하면 훨씬 효과적이다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo apt install tlp tlp-rdw -y
</span></span><span style="display:flex;"><span>sudo tlp start
</span></span></code></pre></div><p>설치 확인:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo tlp-stat -s
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>--- TLP 1.6.1 --------------------------------------------
</span></span><span style="display:flex;"><span>+++ TLP Status
</span></span><span style="display:flex;"><span>State          = enabled
</span></span><span style="display:flex;"><span>Mode           = AC
</span></span><span style="display:flex;"><span>Power source   = AC
</span></span></code></pre></div><h3 id="tlp-튜닝">TLP 튜닝</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo vi /etc/tlp.conf
</span></span></code></pre></div><p>아래 항목을 찾아서 주석 해제 후 값을 수정한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>CPU_SCALING_GOVERNOR_ON_AC=powersave
</span></span><span style="display:flex;"><span>CPU_ENERGY_PERF_POLICY_ON_AC=balance_power
</span></span><span style="display:flex;"><span>CPU_BOOST_ON_AC=0
</span></span><span style="display:flex;"><span>PLATFORM_PROFILE_ON_AC=balanced
</span></span></code></pre></div><p><strong>핵심은 <code>CPU_BOOST_ON_AC=0</code>이다.</strong> 터보 부스트를 끄는 것만으로도 온도가 10~20°C 내려간다. 홈서버 용도에서는 최대 성능이 필요한 순간이 거의 없기 때문에 체감 성능 저하도 없다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo tlp start
</span></span></code></pre></div><h3 id="thinkfan-설치">thinkfan 설치</h3>
<p>thinkfan은 온도 구간별로 팬 속도를 직접 제어하는 도구다. ThinkPad 기본 팬 제어보다 세밀하게 조절할 수 있다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo apt install thinkfan -y
</span></span></code></pre></div><p>thinkpad_acpi 팬 제어 권한 활성화:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>echo <span style="color:#a5d6ff">&#34;options thinkpad_acpi fan_control=1&#34;</span> | sudo tee /etc/modprobe.d/thinkpad_acpi.conf
</span></span><span style="display:flex;"><span>sudo modprobe -r thinkpad_acpi
</span></span><span style="display:flex;"><span>sudo modprobe thinkpad_acpi <span style="color:#79c0ff">fan_control</span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">1</span>
</span></span></code></pre></div><p>센서 경로 확인:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>find /sys/devices -name <span style="color:#a5d6ff">&#34;temp*_input&#34;</span> 2&gt;/dev/null
</span></span></code></pre></div><p>ThinkPad CPU 온도 센서는 보통 아래 경로에 있다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>/sys/devices/platform/thinkpad_hwmon/hwmon/hwmon4/temp1_input
</span></span></code></pre></div><p><code>hwmon</code> 뒤의 숫자는 환경마다 다를 수 있으니 직접 확인하자.</p>
<h3 id="thinkfan-설정">thinkfan 설정</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo vi /etc/thinkfan.conf
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">sensors</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span>- <span style="color:#7ee787">hwmon</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">/sys/devices/platform/thinkpad_hwmon/hwmon/hwmon4/temp1_input</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">fans</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span>- <span style="color:#7ee787">tpacpi</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">/proc/acpi/ibm/fan</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">levels</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span>- [<span style="color:#a5d6ff">0</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">0</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">55</span>]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span>- [<span style="color:#a5d6ff">1</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">50</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">60</span>]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span>- [<span style="color:#a5d6ff">2</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">55</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">65</span>]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span>- [<span style="color:#a5d6ff">3</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">60</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">70</span>]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span>- [<span style="color:#a5d6ff">4</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">65</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">75</span>]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span>- [<span style="color:#a5d6ff">5</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">70</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">80</span>]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span>- [<span style="color:#a5d6ff">7</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">75</span>,<span style="color:#6e7681"> </span><span style="color:#a5d6ff">255</span>]<span style="color:#6e7681">
</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>레벨</th>
          <th>온도 구간</th>
          <th>동작</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>0</td>
          <td>~55°C</td>
          <td>팬 정지</td>
      </tr>
      <tr>
          <td>1</td>
          <td>50~60°C</td>
          <td>최저속</td>
      </tr>
      <tr>
          <td>2</td>
          <td>55~65°C</td>
          <td>저속</td>
      </tr>
      <tr>
          <td>3</td>
          <td>60~70°C</td>
          <td>중저속</td>
      </tr>
      <tr>
          <td>4</td>
          <td>65~75°C</td>
          <td>중속</td>
      </tr>
      <tr>
          <td>5</td>
          <td>70~80°C</td>
          <td>고속</td>
      </tr>
      <tr>
          <td>7</td>
          <td>75°C~</td>
          <td>최대속</td>
      </tr>
  </tbody>
</table>
<p>서비스 시작:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo systemctl enable thinkfan
</span></span><span style="display:flex;"><span>sudo systemctl start thinkfan
</span></span><span style="display:flex;"><span>sudo systemctl status thinkfan
</span></span></code></pre></div><h3 id="온도-개선-결과">온도 개선 결과</h3>
<table>
  <thead>
      <tr>
          <th></th>
          <th>적용 전</th>
          <th>적용 후</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CPU 온도 (풀로드)</td>
          <td>85°C</td>
          <td>55~66°C</td>
      </tr>
      <tr>
          <td>팬 RPM</td>
          <td>3600 RPM</td>
          <td>1800 RPM</td>
      </tr>
      <tr>
          <td>GPU 온도</td>
          <td>79°C</td>
          <td>48~59°C</td>
      </tr>
  </tbody>
</table>
<p>TLP 터보 부스트 비활성화만으로도 <strong>약 20~30°C</strong> 온도가 내려갔다. 24시간 상시 운영 환경에서는 성능보다 안정성이 중요하기 때문에 이 설정이 최적이다.</p>
<h3 id="부팅-시-자동-적용-확인">부팅 시 자동 적용 확인</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo systemctl is-enabled tlp
</span></span><span style="display:flex;"><span>sudo systemctl is-enabled thinkfan
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 둘 다 &#34;enabled&#34; 출력되면 정상</span>
</span></span></code></pre></div><hr>
<h2 id="2부-swappiness-튜닝">2부: swappiness 튜닝</h2>
<h3 id="문제-발견">문제 발견</h3>
<p>Grafana 대시보드를 보다가 이상한 걸 발견했다.</p>
<p><img alt="Grafana - RAM 38% 사용 중인데 Swap 26% 사용" loading="lazy" src="/images/homeserver-11-swap-before.png"></p>
<p>RAM 사용률은 37.8%인데 Swap을 26%나 쓰고 있었다. Swap은 SSD를 메모리처럼 쓰는 거라 RAM보다 훨씬 느리다. 불필요하게 Swap을 쓰면 전체 성능이 저하된다.</p>
<h3 id="swappiness란">swappiness란?</h3>
<p><code>vm.swappiness</code>는 커널이 얼마나 적극적으로 Swap을 사용할지 결정하는 값이다.</p>
<table>
  <thead>
      <tr>
          <th>값</th>
          <th>설명</th>
          <th>적합한 환경</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>0</td>
          <td>Swap 거의 사용 안 함</td>
          <td>RAM이 매우 넉넉한 서버</td>
      </tr>
      <tr>
          <td>10</td>
          <td>RAM 거의 다 찰 때만 Swap 사용</td>
          <td>홈서버, 일반 서버</td>
      </tr>
      <tr>
          <td>60</td>
          <td>Ubuntu 기본값</td>
          <td>데스크탑</td>
      </tr>
      <tr>
          <td>100</td>
          <td>적극적으로 Swap 사용</td>
          <td>RAM이 매우 부족한 환경</td>
      </tr>
  </tbody>
</table>
<p>Ubuntu 기본값이 60인데, 홈서버처럼 RAM이 충분한 환경에서는 너무 높다.</p>
<h3 id="튜닝-적용">튜닝 적용</h3>
<p>즉시 적용:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo sysctl vm.swappiness<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">10</span>
</span></span></code></pre></div><p>재부팅 후에도 유지:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>echo <span style="color:#a5d6ff">&#34;vm.swappiness=10&#34;</span> | sudo tee -a /etc/sysctl.conf
</span></span></code></pre></div><p>적용 확인:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>cat /proc/sys/vm/swappiness
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 10</span>
</span></span></code></pre></div><p>기존 Swap에 올라간 데이터를 RAM으로 옮겨서 효과를 바로 확인한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo swapoff -a <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> sudo swapon -a
</span></span></code></pre></div><h3 id="튜닝-결과">튜닝 결과</h3>
<p><img alt="Grafana - Swap 사용량이 7%대로 감소" loading="lazy" src="/images/homeserver-11-swap-after.png"></p>
<p>기존 26%가량 사용하던 Swap이 7%대로 떨어진 것을 확인할 수 있다.</p>
<hr>
<h2 id="정리">정리</h2>
<p><strong>온도 관리:</strong></p>
<ul>
<li>TLP <code>CPU_BOOST_ON_AC=0</code> 하나로 온도 20~30°C 감소</li>
<li>thinkfan으로 팬 커브를 직접 제어해서 불필요한 소음 줄임</li>
<li>홈서버는 성능보다 안정성이 중요하므로 터보 부스트 끄는 게 최선</li>
</ul>
<p><strong>Swap 튜닝:</strong></p>
<ul>
<li>Ubuntu 기본값 swappiness=60은 데스크탑 기준, 서버는 10이 적합</li>
<li><code>swapoff -a &amp;&amp; swapon -a</code>로 기존 Swap 데이터를 RAM으로 즉시 이동 가능</li>
<li>한 줄 설정으로 전반적인 시스템 응답성 개선</li>
</ul>
<p>다음 편에서는 Portainer 대신 Spring Boot + React로 직접 만든 컨테이너 대시보드를 다룬다. SSE를 이용한 실시간 로그 스트리밍이 핵심이다.</p>
]]></content:encoded>
    </item>
    <item>
      <title>노트북으로 홈서버 구축하기 - PostgreSQL 자동 백업 (pg_dump &#43; cron) (10편)</title>
      <link>https://chanyeols.com/posts/part-10-postgresql-backup/</link>
      <pubDate>Wed, 01 Apr 2026 00:00:00 +0000</pubDate>
      <guid>https://chanyeols.com/posts/part-10-postgresql-backup/</guid>
      <description>홈서버에서 운영 중인 Immich의 PostgreSQL DB를 pg_dump로 매일 새벽 자동 백업하는 스크립트를 만든 과정을 정리합니다.</description>
      <content:encoded><![CDATA[<h2 id="왜-백업이-필요한가">왜 백업이 필요한가</h2>
<p>Immich에 사진을 올리기 시작하면서 DB가 날아가면 복구할 방법이 없다는 게 갑자기 걱정됐다. 사진 원본은 <code>/mnt/data</code>에 있으니 파일은 살아있더라도 Immich DB가 날아가면 앨범, 태그, 얼굴 인식 데이터가 전부 사라진다.</p>
<p>백업 전략은 단순하게 잡았다.</p>
<table>
  <thead>
      <tr>
          <th>대상</th>
          <th>방법</th>
          <th>위치</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Immich DB</td>
          <td>pg_dumpall</td>
          <td><code>/backup/immich_db_YYYYMMDD.sql</code></td>
      </tr>
      <tr>
          <td>사진 원본</td>
          <td>추후 rsync 추가 예정</td>
          <td>외장하드 구매 후</td>
      </tr>
  </tbody>
</table>
<p>OS SSD에 204GB 여유가 있어서 DB 덤프는 우선 거기에 보관한다.</p>
<hr>
<h2 id="백업-디렉토리-생성">백업 디렉토리 생성</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo mkdir -p /backup
</span></span><span style="display:flex;"><span>sudo chown your-username:your-username /backup
</span></span></code></pre></div><p><code>chown</code>으로 소유권을 넘겨줘야 한다. 처음에 <code>sudo mkdir</code>만 하고 스크립트를 실행했더니 아래 에러가 났다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>cannot create /backup/immich_db_20260329.sql: Permission denied
</span></span></code></pre></div><p><img alt="/backup 디렉토리 생성 및 권한 설정" loading="lazy" src="/images/homeserver-10-backup-dir.png"></p>
<hr>
<h2 id="백업-스크립트-작성">백업 스크립트 작성</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/backup_script
</span></span><span style="display:flex;"><span>vi ~/backup_script/postgres_immich_backup.sh
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-weight:bold;font-style:italic">#!/bin/bash
</span></span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#79c0ff">BACKUP_DIR</span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;/backup&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#79c0ff">DATE</span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#ff7b72">$(</span>date +%Y%m%d<span style="color:#ff7b72">)</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>mkdir -p <span style="color:#79c0ff">$BACKUP_DIR</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># DB 덤프 백업</span>
</span></span><span style="display:flex;"><span>echo <span style="color:#a5d6ff">&#34;pg_dump 시작...&#34;</span>
</span></span><span style="display:flex;"><span>docker exec immich_postgres pg_dumpall -U postgres &gt; <span style="color:#79c0ff">$BACKUP_DIR</span>/immich_db_<span style="color:#79c0ff">$DATE</span>.sql
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 7일치만 보관 (오래된 것 삭제)</span>
</span></span><span style="display:flex;"><span>find <span style="color:#79c0ff">$BACKUP_DIR</span> -name <span style="color:#a5d6ff">&#34;immich_db_*.sql&#34;</span> -mtime +7 -delete
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#a5d6ff">&#34;백업 완료: </span><span style="color:#79c0ff">$DATE</span><span style="color:#a5d6ff">&#34;</span>
</span></span></code></pre></div><p><img alt="백업 스크립트 작성" loading="lazy" src="/images/homeserver-10-backup-script.png"></p>
<p><code>docker exec</code>으로 <code>immich_postgres</code> 컨테이너 안에서 <code>pg_dumpall</code>을 실행해서 결과를 파일로 저장한다. 컨테이너가 실행 중이면 별도 설치 없이 바로 쓸 수 있다.</p>
<p><code>find ... -mtime +7 -delete</code> 로 7일이 지난 백업 파일은 자동으로 삭제한다. 무한정 쌓이면 디스크가 금방 찬다.</p>
<p>실행 권한 부여:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>chmod +x ~/backup_script/postgres_immich_backup.sh
</span></span></code></pre></div><hr>
<h2 id="테스트-실행">테스트 실행</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sh ~/backup_script/postgres_immich_backup.sh
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>pg_dump 시작...
</span></span><span style="display:flex;"><span>백업 완료: 20260329
</span></span></code></pre></div><p>백업 파일 확인:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ls -lh /backup/
</span></span></code></pre></div><hr>
<h2 id="cron-등록">cron 등록</h2>
<p>매일 새벽 3시에 자동 실행되도록 cron에 등록한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>crontab -e
</span></span></code></pre></div><p>아래 내용 추가:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>0 3 * * * /home/your-username/backup_script/postgres_immich_backup.sh &gt;&gt; /backup/backup.log 2&gt;&amp;1
</span></span></code></pre></div><p><img alt="crontab 설정" loading="lazy" src="/images/homeserver-10-crontab.png"></p>
<p>등록 확인:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>crontab -l
</span></span></code></pre></div><p>로그는 <code>/backup/backup.log</code>에 쌓이니 문제가 생기면 여기서 확인하면 된다.</p>
<hr>
<h2 id="7일-보관-정책">7일 보관 정책</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>find <span style="color:#79c0ff">$BACKUP_DIR</span> -name <span style="color:#a5d6ff">&#34;immich_db_*.sql&#34;</span> -mtime +7 -delete
</span></span></code></pre></div><p>스크립트 안에 이 한 줄이 있어서 7일이 지난 백업 파일은 매일 자동으로 정리된다. 7일치 보관 시 필요한 용량은 DB 크기에 따라 다른데, Immich DB는 사진 수가 늘어도 원본 파일은 <code>/mnt/data</code>에 따로 있으니 DB 자체는 수백 MB 수준이다.</p>
<hr>
<h2 id="추후-개선-계획">추후 개선 계획</h2>
<p>외장하드를 구매하면 rsync로 사진 원본도 자동 백업할 예정이다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 라이브러리 rsync (외장하드 마운트 후 추가)</span>
</span></span><span style="display:flex;"><span>rsync -av --delete /mnt/data/immich/photos/ /mnt/backup/immich/photos/
</span></span></code></pre></div><hr>
<h2 id="정리">정리</h2>
<ul>
<li><code>docker exec</code>으로 컨테이너 안의 <code>pg_dumpall</code>을 실행하면 별도 PostgreSQL 설치 없이 백업 가능</li>
<li><code>/backup</code> 디렉토리는 <code>chown</code>으로 소유권 설정 필수</li>
<li><code>find -mtime +7 -delete</code>로 오래된 백업 자동 정리</li>
<li>cron 로그를 파일로 남겨두면 백업 실패 여부를 나중에 확인할 수 있다</li>
</ul>
<p>다음 편에서는 TLP + thinkfan으로 CPU 온도를 85°C에서 55°C로 낮추고, swappiness 튜닝으로 불필요한 Swap 사용을 줄인 과정을 다룬다.</p>
]]></content:encoded>
    </item>
    <item>
      <title>노트북으로 홈서버 구축하기 - certbot --expand로 SSL 서브도메인 추가하기 (9편)</title>
      <link>https://chanyeols.com/posts/part-09-ssl/</link>
      <pubDate>Tue, 31 Mar 2026 00:00:00 +0000</pubDate>
      <guid>https://chanyeols.com/posts/part-09-ssl/</guid>
      <description>서비스가 늘어나면서 서브도메인이 추가될 때마다 SSL 인증서에 도메인을 추가해야 했다. certbot --expand 옵션으로 기존 인증서에 서브도메인을 추가하는 방법을 정리합니다.</description>
      <content:encoded><![CDATA[<h2 id="문제-상황">문제 상황</h2>
<p>처음 SSL 인증서를 발급할 때 메인 도메인만 포함해서 발급했다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>certbot certonly --nginx -d yourdomain.com
</span></span></code></pre></div><p>이후 서비스가 하나씩 늘어나면서 서브도메인이 추가됐는데, 브라우저에서 <code>photo.yourdomain.com</code>에 접속하면 아래 에러가 발생했다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>NET::ERR_CERT_COMMON_NAME_INVALID
</span></span><span style="display:flex;"><span>연결이 비공개로 설정되어 있지 않습니다.
</span></span></code></pre></div><p>인증서에 <code>photo.yourdomain.com</code>이 포함돼 있지 않아서 생기는 문제였다.</p>
<hr>
<h2 id="해결-expand-옵션">해결: &ndash;expand 옵션</h2>
<p>기존 인증서에 서브도메인을 추가할 때는 <code>--expand</code> 플래그를 써야 한다.</p>
<p><code>--expand</code> 없이 서브도메인을 추가하려고 하면 아래 에러가 난다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>Missing command line flag or config entry for this setting:
</span></span><span style="display:flex;"><span>You have an existing certificate that contains a portion of the domains you requested.
</span></span><span style="display:flex;"><span>It contains these names: yourdomain.com
</span></span><span style="display:flex;"><span>You requested these names for the new certificate: yourdomain.com, photo.yourdomain.com
</span></span><span style="display:flex;"><span>Do you want to expand and replace this existing certificate with the new certificate?
</span></span><span style="display:flex;"><span>(You can set this with the --expand flag)
</span></span></code></pre></div><p>certbot이 친절하게 <code>--expand</code> 쓰라고 안내해주긴 한다.</p>
<hr>
<h2 id="셸-스크립트로-관리">셸 스크립트로 관리</h2>
<p>서브도메인이 추가될 때마다 명령어를 직접 치는 건 번거롭다. 스크립트로 관리하면 나중에 도메인 하나 추가할 때 <code>-d</code> 줄만 늘리면 된다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-weight:bold;font-style:italic">#!/bin/bash
</span></span></span><span style="display:flex;"><span><span style="color:#79c0ff">DOMAIN</span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;yourdomain.com&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#79c0ff">EMAIL</span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;your@email.com&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#79c0ff">SSL_DIR</span><span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">&#34;/ssl&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>certbot certonly --nginx <span style="color:#79c0ff">\
</span></span></span><span style="display:flex;"><span>  --non-interactive <span style="color:#79c0ff">\
</span></span></span><span style="display:flex;"><span>  --agree-tos <span style="color:#79c0ff">\
</span></span></span><span style="display:flex;"><span>  --expand <span style="color:#79c0ff">\
</span></span></span><span style="display:flex;"><span>  --email <span style="color:#79c0ff">$EMAIL</span> <span style="color:#79c0ff">\
</span></span></span><span style="display:flex;"><span>  -d <span style="color:#79c0ff">$DOMAIN</span> <span style="color:#79c0ff">\
</span></span></span><span style="display:flex;"><span>  -d photo.<span style="color:#79c0ff">$DOMAIN</span> <span style="color:#79c0ff">\
</span></span></span><span style="display:flex;"><span>  -d files.<span style="color:#79c0ff">$DOMAIN</span> <span style="color:#79c0ff">\
</span></span></span><span style="display:flex;"><span>  -d vault.<span style="color:#79c0ff">$DOMAIN</span> <span style="color:#79c0ff">\
</span></span></span><span style="display:flex;"><span>  --config-dir <span style="color:#79c0ff">$SSL_DIR</span> <span style="color:#79c0ff">\
</span></span></span><span style="display:flex;"><span>  --work-dir <span style="color:#79c0ff">$SSL_DIR</span>/work <span style="color:#79c0ff">\
</span></span></span><span style="display:flex;"><span>  --logs-dir <span style="color:#79c0ff">$SSL_DIR</span>/logs
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">if</span> <span style="color:#ff7b72;font-weight:bold">[</span> <span style="color:#79c0ff">$?</span> -ne <span style="color:#a5d6ff">0</span> <span style="color:#ff7b72;font-weight:bold">]</span>; <span style="color:#ff7b72">then</span>
</span></span><span style="display:flex;"><span>    echo <span style="color:#a5d6ff">&#34;인증서 발급 실패&#34;</span>
</span></span><span style="display:flex;"><span>    exit <span style="color:#a5d6ff">1</span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">fi</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>echo <span style="color:#a5d6ff">&#34;인증서 발급 성공&#34;</span>
</span></span><span style="display:flex;"><span>nginx -t <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> systemctl restart nginx
</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>옵션</th>
          <th>설명</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>--expand</code></td>
          <td>기존 인증서에 새 도메인 추가</td>
      </tr>
      <tr>
          <td><code>--non-interactive</code></td>
          <td>사용자 입력 없이 자동 실행</td>
      </tr>
      <tr>
          <td><code>--agree-tos</code></td>
          <td>서비스 약관 자동 동의</td>
      </tr>
      <tr>
          <td><code>-d</code></td>
          <td>인증서에 포함할 도메인 (여러 개 가능)</td>
      </tr>
  </tbody>
</table>
<p>새 서브도메인이 생길 때마다 <code>-d newservice.$DOMAIN \</code> 한 줄만 추가하고 스크립트를 실행하면 된다.</p>
<hr>
<h2 id="인증서-확인">인증서 확인</h2>
<p>발급 후 포함된 도메인 목록 확인:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>certbot certificates --config-dir /ssl
</span></span></code></pre></div><p><img alt="certbot certificates 결과 - 서브도메인 목록 확인" loading="lazy" src="/images/homeserver-09-certbot-certificates.png"></p>
<p>인증서에 서브도메인이 모두 포함된 것을 확인할 수 있다. 브라우저에서 접속하면 자물쇠 아이콘을 클릭해서 인증서에 포함된 도메인 목록을 직접 확인할 수 있다.</p>
<p><img alt="브라우저 인증서 정보 - 서브도메인 포함 확인" loading="lazy" src="/images/homeserver-09-browser-cert.png"></p>
<hr>
<h2 id="자동-갱신-설정">자동 갱신 설정</h2>
<p>Let&rsquo;s Encrypt 인증서는 90일마다 갱신해야 한다. cron으로 자동화해뒀다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>crontab -e
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>0 3 1 */2 * certbot renew --config-dir /ssl --work-dir /ssl/work --logs-dir /ssl/logs &amp;&amp; nginx -s reload
</span></span></code></pre></div><p>60일마다 새벽 3시에 갱신 시도한다. 갱신 후 Nginx를 리로드해서 새 인증서를 바로 적용한다.</p>
<hr>
<h2 id="정리">정리</h2>
<ul>
<li>기존 인증서에 서브도메인 추가할 때는 반드시 <code>--expand</code> 플래그 사용</li>
<li>서브도메인 관리를 스크립트로 해두면 나중에 도메인 추가가 편하다</li>
<li>cron으로 자동 갱신 설정해두면 인증서 만료 걱정이 없다</li>
</ul>
<p>다음 편에서는 Immich PostgreSQL DB를 매일 새벽 자동으로 백업하는 스크립트를 만든다.</p>
]]></content:encoded>
    </item>
    <item>
      <title>노트북으로 홈서버 구축하기 - Fail2ban으로 SSH 브루트포스 공격 차단하기 (8편)</title>
      <link>https://chanyeols.com/posts/part-08-fail2ban/</link>
      <pubDate>Mon, 30 Mar 2026 00:00:00 +0000</pubDate>
      <guid>https://chanyeols.com/posts/part-08-fail2ban/</guid>
      <description>OCI 서버 auth.log를 열어보니 수천 줄의 SSH 로그인 시도가 쌓여 있었다. Fail2ban을 설치해서 브루트포스 공격을 자동으로 차단하는 과정을 정리합니다.</description>
      <content:encoded><![CDATA[<h2 id="얼마나-많이-들어오나">얼마나 많이 들어오나</h2>
<p>OCI 서버는 공인 IP가 직접 노출돼 있어서 설치 직후부터 SSH 로그인 시도가 들어온다. auth.log를 열어봤다가 깜짝 놀랐다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo grep <span style="color:#a5d6ff">&#34;Failed password&#34;</span> /var/log/auth.log | tail -20
</span></span></code></pre></div><p><img alt="auth.log에 Invalid user 로그가 수천 줄 쌓인 화면" loading="lazy" src="/images/homeserver-08-authlog.png"></p>
<p><code>Invalid user admin</code>, <code>Invalid user guest</code>, <code>Invalid user root</code> 같은 로그가 수분 간격으로 끊임없이 들어오고 있었다. 전 세계 봇들이 24시간 SSH 로그인을 시도하는 것이다. 방치하면 언젠가 뚫릴 수 있고, 서버 리소스도 낭비된다.</p>
<hr>
<h2 id="fail2ban이란">Fail2ban이란?</h2>
<p>로그 파일을 모니터링하다가 일정 횟수 이상 로그인에 실패한 IP를 자동으로 방화벽에서 차단하는 도구다.</p>
<ul>
<li>설치만 해도 SSH 기본 보호 즉시 적용</li>
<li>iptables와 연동해서 IP 차단</li>
<li>일정 시간 후 자동으로 차단 해제</li>
<li>SSH 외에도 Nginx, Apache 등 다양한 서비스 보호 가능</li>
</ul>
<hr>
<h2 id="설치">설치</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo apt install fail2ban -y
</span></span><span style="display:flex;"><span>sudo systemctl enable fail2ban
</span></span><span style="display:flex;"><span>sudo systemctl start fail2ban
</span></span><span style="display:flex;"><span>sudo systemctl status fail2ban
</span></span></code></pre></div><p><img alt="fail2ban active (running) 상태" loading="lazy" src="/images/homeserver-08-fail2ban-status.png"></p>
<p>설치 직후부터 기본 SSH 보호가 바로 적용된다.</p>
<hr>
<h2 id="설정">설정</h2>
<p>Fail2ban 기본 설정은 <code>/etc/fail2ban/jail.conf</code>에 있지만 이 파일은 직접 수정하지 않는다. 업데이트 시 덮어씌워질 수 있기 때문이다. 대신 <code>jail.local</code>을 만들어서 오버라이드한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo vi /etc/fail2ban/jail.local
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>[DEFAULT]
</span></span><span style="display:flex;"><span># 차단에서 제외할 IP (내 Tailscale IP 등)
</span></span><span style="display:flex;"><span>ignoreip = 127.0.0.1/8 ::1
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span># 차단 지속 시간: 기본 10분 → 1시간
</span></span><span style="display:flex;"><span>bantime = 3600
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span># 몇 초 동안 실패 횟수를 카운트할지
</span></span><span style="display:flex;"><span>findtime = 600
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span># 몇 번 실패하면 차단할지
</span></span><span style="display:flex;"><span>maxretry = 5
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>[sshd]
</span></span><span style="display:flex;"><span>enabled = true
</span></span><span style="display:flex;"><span>port = ssh
</span></span><span style="display:flex;"><span>logpath = %(sshd_log)s
</span></span><span style="display:flex;"><span>backend = %(sshd_backend)s
</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>항목</th>
          <th>기본값</th>
          <th>변경값</th>
          <th>설명</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>bantime</td>
          <td>600초</td>
          <td>3600초</td>
          <td>차단 지속 시간</td>
      </tr>
      <tr>
          <td>findtime</td>
          <td>600초</td>
          <td>600초</td>
          <td>실패 횟수 카운트 기간</td>
      </tr>
      <tr>
          <td>maxretry</td>
          <td>5회</td>
          <td>5회</td>
          <td>차단 기준 실패 횟수</td>
      </tr>
  </tbody>
</table>
<p>설정 적용:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo systemctl restart fail2ban
</span></span></code></pre></div><blockquote>
<p>⚠️ 설정 전에 반드시 자신의 IP를 <code>ignoreip</code>에 추가해둘 것. 실수로 자신의 IP가 차단되면 SSH 접속이 불가능해진다.</p>
</blockquote>
<hr>
<h2 id="반복-공격자-장기-차단-recidive">반복 공격자 장기 차단 (recidive)</h2>
<p>같은 IP가 여러 번 차단됐다 풀리면 또 시도하는 경우가 있다. <code>recidive</code> jail을 활성화하면 반복 공격자를 장기간 차단할 수 있다.</p>
<p><code>jail.local</code>에 추가:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>[recidive]
</span></span><span style="display:flex;"><span>enabled = true
</span></span><span style="display:flex;"><span>logpath = /var/log/fail2ban.log
</span></span><span style="display:flex;"><span>banaction = %(banaction_allports)s
</span></span><span style="display:flex;"><span>bantime = 604800   ; 7일
</span></span><span style="display:flex;"><span>findtime = 86400   ; 1일
</span></span><span style="display:flex;"><span>maxretry = 3       ; 하루에 3번 차단되면 7일 차단
</span></span></code></pre></div><hr>
<h2 id="nginx-보호">Nginx 보호</h2>
<p>OCI에서 Nginx 리버스 프록시를 운영하고 있으니 HTTP 레벨 공격도 막을 수 있다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>[nginx-http-auth]
</span></span><span style="display:flex;"><span>enabled = true
</span></span><span style="display:flex;"><span>port = http,https
</span></span><span style="display:flex;"><span>logpath = /var/log/nginx/error.log
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>[nginx-botsearch]
</span></span><span style="display:flex;"><span>enabled = true
</span></span><span style="display:flex;"><span>port = http,https
</span></span><span style="display:flex;"><span>logpath = /var/log/nginx/access.log
</span></span><span style="display:flex;"><span>maxretry = 2
</span></span></code></pre></div><hr>
<h2 id="차단-현황-확인">차단 현황 확인</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 전체 jail 상태</span>
</span></span><span style="display:flex;"><span>sudo fail2ban-client status
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># SSH jail 상세</span>
</span></span><span style="display:flex;"><span>sudo fail2ban-client status sshd
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>Status for the jail: sshd
</span></span><span style="display:flex;"><span>|- Filter
</span></span><span style="display:flex;"><span>|  |- Currently failed: 3
</span></span><span style="display:flex;"><span>|  |- Total failed:     1284
</span></span><span style="display:flex;"><span>|  `- File list:        /var/log/auth.log
</span></span><span style="display:flex;"><span>`- Actions
</span></span><span style="display:flex;"><span>   |- Currently banned: 12
</span></span><span style="display:flex;"><span>   |- Total banned:     87
</span></span><span style="display:flex;"><span>   `- Banned IP list:   185.156.73.233 139.19.117.130 ...
</span></span></code></pre></div><p><img alt="fail2ban-client status sshd 결과" loading="lazy" src="/images/homeserver-08-fail2ban-status-sshd.png"></p>
<p>실시간 로그 확인:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo tail -f /var/log/fail2ban.log
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>2026-03-29 22:48:33 INFO [sshd] Ban 193.46.255.86
</span></span><span style="display:flex;"><span>2026-03-29 22:52:33 INFO [sshd] Ban 185.156.73.233
</span></span><span style="display:flex;"><span>2026-03-29 23:01:15 INFO [sshd] Ban 139.19.117.130
</span></span></code></pre></div><hr>
<h2 id="ip-수동-차단해제">IP 수동 차단/해제</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 수동 차단</span>
</span></span><span style="display:flex;"><span>sudo fail2ban-client set sshd banip 1.2.3.4
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 차단 해제 (내 IP 실수로 차단됐을 때)</span>
</span></span><span style="display:flex;"><span>sudo fail2ban-client set sshd unbanip 1.2.3.4
</span></span></code></pre></div><hr>
<h2 id="차단-목록-확인">차단 목록 확인</h2>
<p>이후 시간이 조금 지나고 확인해보니, 정상적으로 잘 차단하고 있는 걸 확인할 수 있다.
<img alt="fail2ban-client log 결과" loading="lazy" src="/images/homeserver-08-fail2ban-log.png"></p>
<h2 id="설치-전후-비교">설치 전후 비교</h2>
<table>
  <thead>
      <tr>
          <th></th>
          <th>설치 전</th>
          <th>설치 후</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>auth.log</td>
          <td>Failed password 수천 줄</td>
          <td>차단된 IP는 시도 자체가 안 됨</td>
      </tr>
      <tr>
          <td>서버 부하</td>
          <td>SSH 시도로 인한 불필요한 부하</td>
          <td>최소화</td>
      </tr>
      <tr>
          <td>보안</td>
          <td>브루트포스 공격에 노출</td>
          <td>자동 차단</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="정리">정리</h2>
<p>클라우드 서버를 운영한다면 Fail2ban은 선택이 아니라 필수다. 설치 자체는 5분이면 되고, 기본 설정만으로도 대부분의 브루트포스 공격을 막을 수 있다. <code>recidive</code> jail까지 설정해두면 반복 공격자를 장기간 차단해서 더욱 안전하게 운영할 수 있다.</p>
<p>다음 편에서는 서비스가 늘어나면서 기존 SSL 인증서에 서브도메인을 추가하는 방법을 다룬다.</p>
]]></content:encoded>
    </item>
    <item>
      <title>노트북으로 홈서버 구축하기 - Grafana &#43; Prometheus로 서버 모니터링하기 (7편)</title>
      <link>https://chanyeols.com/posts/part-07-monitoring/</link>
      <pubDate>Sun, 29 Mar 2026 00:00:00 +0000</pubDate>
      <guid>https://chanyeols.com/posts/part-07-monitoring/</guid>
      <description>Prometheus &#43; Grafana &#43; Node Exporter를 Docker Compose로 구성하고, 홈서버와 OCI 서버를 Tailscale VPN을 통해 동시에 모니터링하는 환경을 구축합니다. ThinkPad 배터리 메트릭 수집까지 다룹니다.</description>
      <content:encoded><![CDATA[<h2 id="구성-개요">구성 개요</h2>
<p>모니터링 스택은 세 가지로 구성된다.</p>
<ul>
<li><strong>Prometheus</strong> — 메트릭 수집 및 저장</li>
<li><strong>Grafana</strong> — 대시보드 시각화</li>
<li><strong>Node Exporter</strong> — 서버 시스템 메트릭 노출 (CPU, RAM, 디스크, 네트워크 등)</li>
</ul>
<p>Prometheus와 Grafana는 홈서버에서 Docker로 실행하고, Node Exporter는 홈서버와 OCI 서버 양쪽에 systemd로 설치했다. 두 서버가 Tailscale VPN으로 연결돼 있으니 Prometheus가 VPN을 통해 OCI 메트릭도 수집할 수 있다.</p>
<p><img alt="모니터링 구성도" loading="lazy" src="/images/homeserver-07-architecture.png"></p>
<table>
  <thead>
      <tr>
          <th>서비스</th>
          <th>포트</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Prometheus</td>
          <td>19090</td>
      </tr>
      <tr>
          <td>Grafana</td>
          <td>13000</td>
      </tr>
      <tr>
          <td>Node Exporter</td>
          <td>19100</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="1-디렉토리-생성">1. 디렉토리 생성</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/monitoring <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> cd ~/monitoring
</span></span></code></pre></div><hr>
<h2 id="2-docker-composeyml-작성">2. docker-compose.yml 작성</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">services</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">prometheus</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">prom/prometheus:latest</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">prometheus</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">restart</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">unless-stopped</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;19090:9090&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">./prometheus.yml:/etc/prometheus/prometheus.yml:ro</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">prometheus_data:/prometheus</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">command</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#39;--config.file=/etc/prometheus/prometheus.yml&#39;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#39;--storage.tsdb.path=/prometheus&#39;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#39;--storage.tsdb.retention.time=30d&#39;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">grafana</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">grafana/grafana:latest</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">grafana</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">restart</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">unless-stopped</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;13000:3000&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">grafana_data:/var/lib/grafana</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">environment</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">GF_SECURITY_ADMIN_PASSWORD=your_password_here</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">GF_SERVER_ROOT_URL=http://100.109.108.36:13000</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">prometheus_data</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">grafana_data</span>:<span style="color:#6e7681">
</span></span></span></code></pre></div><blockquote>
<p><code>version: &quot;3.8&quot;</code> 은 Docker Compose v2부터 obsolete라 생략했다. 넣어도 동작하지만 경고가 뜬다.</p>
</blockquote>
<hr>
<h2 id="3-prometheusyml-작성">3. prometheus.yml 작성</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">global</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">scrape_interval</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">15s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">evaluation_interval</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">15s</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">scrape_configs</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span>- <span style="color:#7ee787">job_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#39;thinkpad&#39;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">static_configs</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#7ee787">targets</span>:<span style="color:#6e7681"> </span>[<span style="color:#a5d6ff">&#39;100.109.108.36:19100&#39;</span>]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#7ee787">labels</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">          </span><span style="color:#7ee787">instance</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#39;thinkpad-homeserver&#39;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span>- <span style="color:#7ee787">job_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#39;oci&#39;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">static_configs</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#7ee787">targets</span>:<span style="color:#6e7681"> </span>[<span style="color:#a5d6ff">&#39;&lt;OCI_Tailscale_IP&gt;:19100&#39;</span>]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">        </span><span style="color:#7ee787">labels</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">          </span><span style="color:#7ee787">instance</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#39;oci-server&#39;</span><span style="color:#6e7681">
</span></span></span></code></pre></div><p>OCI 서버의 Tailscale IP는 OCI에서 아래 명령으로 확인한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>tailscale ip -4
</span></span></code></pre></div><hr>
<h2 id="4-node-exporter-설치-홈서버--oci-공통">4. Node Exporter 설치 (홈서버 + OCI 공통)</h2>
<p>두 서버 모두 동일하게 진행한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>wget https://github.com/prometheus/node_exporter/releases/download/v1.8.2/node_exporter-1.8.2.linux-amd64.tar.gz
</span></span><span style="display:flex;"><span>tar xf node_exporter-1.8.2.linux-amd64.tar.gz
</span></span><span style="display:flex;"><span>sudo cp node_exporter-1.8.2.linux-amd64/node_exporter /usr/local/bin/
</span></span><span style="display:flex;"><span>sudo chmod +x /usr/local/bin/node_exporter
</span></span></code></pre></div><p>systemd 서비스로 등록한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo tee /etc/systemd/system/node_exporter.service <span style="color:#a5d6ff">&lt;&lt; &#39;EOF&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">[Unit]
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">Description=Node Exporter
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">After=network.target
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">[Service]
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">Type=simple
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">User=nobody
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">ExecStart=/usr/local/bin/node_exporter --web.listen-address=&#34;:19100&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">Restart=on-failure
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">[Install]
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">WantedBy=multi-user.target
</span></span></span><span style="display:flex;"><span><span style="color:#a5d6ff">EOF</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>sudo systemctl daemon-reload
</span></span><span style="display:flex;"><span>sudo systemctl enable node_exporter
</span></span><span style="display:flex;"><span>sudo systemctl start node_exporter
</span></span></code></pre></div><p>정상 동작 확인:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo systemctl status node_exporter
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># active (running) 이 뜨면 성공</span>
</span></span></code></pre></div><blockquote>
<p><code>status=203/EXEC</code> 에러가 나는 경우 바이너리 복사가 안 된 것이므로 설치 단계부터 다시 진행한다.</p>
</blockquote>
<hr>
<h2 id="5-oci-방화벽-설정">5. OCI 방화벽 설정</h2>
<p>OCI 서버에서 Node Exporter 포트를 Tailscale 인터페이스에서만 허용한다. 외부에 열면 안 된다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo ufw allow in on tailscale0 to any port <span style="color:#a5d6ff">19100</span>
</span></span><span style="display:flex;"><span>sudo ufw deny <span style="color:#a5d6ff">19100</span>
</span></span></code></pre></div><hr>
<h2 id="6-컨테이너-시작">6. 컨테이너 시작</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>cd ~/monitoring
</span></span><span style="display:flex;"><span>docker compose up -d
</span></span><span style="display:flex;"><span>docker compose ps
</span></span></code></pre></div><hr>
<h2 id="7-grafana-초기-설정">7. Grafana 초기 설정</h2>
<p>브라우저에서 <code>http://100.109.108.36:13000</code> 접속.</p>
<p>초기 계정은 <code>admin</code>이고 비밀번호는 <code>docker-compose.yml</code>에 설정한 <code>GF_SECURITY_ADMIN_PASSWORD</code> 값이다.</p>
<blockquote>
<p>볼륨이 이미 생성된 상태에서 환경변수를 바꿔도 적용이 안 된다. 볼륨을 삭제하고 재시작해야 한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose down
</span></span><span style="display:flex;"><span>docker volume rm monitoring_grafana_data
</span></span><span style="display:flex;"><span>docker compose up -d
</span></span></code></pre></div></blockquote>
<h3 id="prometheus-데이터소스-연결">Prometheus 데이터소스 연결</h3>
<p><strong>Connections → Data sources → Add data source → Prometheus</strong></p>
<p>URL에 <code>http://prometheus:9090</code> 입력 후 Save &amp; test.</p>
<ul>
<li>컨테이너 이름으로 통신하므로 IP 대신 서비스 이름 사용</li>
<li>포트는 컨테이너 내부 포트인 <code>9090</code> (외부 포트 <code>19090</code> 아님)</li>
</ul>
<p><code>Successfully queried the Prometheus API</code> 가 뜨면 성공이다.</p>
<h3 id="대시보드-import">대시보드 Import</h3>
<p><strong>Dashboards → Import</strong> 에서 ID <code>1860</code> 입력 후 Load.</p>
<p>Prometheus 데이터소스를 선택하고 Import하면 <strong>Node Exporter Full</strong> 대시보드가 추가된다. 상단 <code>Job</code> 드롭다운에서 <code>thinkpad</code> / <code>oci</code>를 전환하면 두 서버를 각각 모니터링할 수 있다.</p>
<p><img alt="Grafana Node Exporter Full 대시보드" loading="lazy" src="/images/homeserver-07-grafana.png"></p>
<p><img alt="OCI 서버 모니터링" loading="lazy" src="/images/homeserver-07-grafana-oci.png"></p>
<hr>
<h2 id="8-배터리-메트릭-수집-thinkpad-전용">8. 배터리 메트릭 수집 (ThinkPad 전용)</h2>
<p>ThinkPad는 배터리가 있으니 <code>powersupplyclass</code> 컬렉터를 추가로 활성화했다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo vi /etc/systemd/system/node_exporter.service
</span></span></code></pre></div><p><code>ExecStart</code> 줄 수정:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>ExecStart=/usr/local/bin/node_exporter --web.listen-address=&#34;:19100&#34; --collector.powersupplyclass
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo systemctl daemon-reload
</span></span><span style="display:flex;"><span>sudo systemctl restart node_exporter
</span></span></code></pre></div><p>수집 확인:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl http://localhost:19100/metrics | grep power_supply
</span></span></code></pre></div><p>수집되는 주요 메트릭:</p>
<table>
  <thead>
      <tr>
          <th>메트릭</th>
          <th>내용</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>node_power_supply_capacity</code></td>
          <td>배터리 잔량 (%)</td>
      </tr>
      <tr>
          <td><code>node_power_supply_online</code></td>
          <td>어댑터 연결 여부 (1=연결, 0=미연결)</td>
      </tr>
      <tr>
          <td><code>node_power_supply_power_watt</code></td>
          <td>현재 소비 전력 (W)</td>
      </tr>
      <tr>
          <td><code>node_power_supply_cyclecount</code></td>
          <td>배터리 충방전 횟수</td>
      </tr>
  </tbody>
</table>
<h3 id="grafana-배터리-패널-추가">Grafana 배터리 패널 추가</h3>
<p><strong>Dashboards → Edit → Add → Visualization</strong></p>
<ul>
<li>Visualization: <strong>Gauge</strong></li>
<li>쿼리: <code>node_power_supply_capacity</code></li>
<li>Standard options → Unit: <code>Percent (0-100)</code></li>
<li>Min: <code>0</code>, Max: <code>100</code></li>
<li>Title: <code>Battery</code></li>
</ul>
<hr>
<h2 id="접근-방식">접근 방식</h2>
<p>Grafana와 Prometheus는 Portainer와 마찬가지로 외부에 열지 않고 Tailscale VPN 안에서만 접근한다.</p>
<pre tabindex="0"><code>Grafana    → http://100.109.108.36:13000 (Tailscale VPN)
Prometheus → http://100.109.108.36:19090 (Tailscale VPN)
</code></pre><p>모니터링 툴을 외부에 노출하면 서버 내부 정보가 그대로 보이기 때문이다.</p>
<hr>
<h2 id="정리">정리</h2>
<ul>
<li>Node Exporter를 두 서버에 설치하고 Prometheus가 Tailscale VPN으로 수집하는 구조</li>
<li>OCI의 Node Exporter 포트는 Tailscale 인터페이스에서만 허용할 것</li>
<li>Grafana 데이터소스 URL은 컨테이너 서비스 이름(<code>prometheus:9090</code>)으로 설정</li>
<li>ThinkPad는 <code>--collector.powersupplyclass</code> 옵션으로 배터리 메트릭도 수집 가능</li>
</ul>
<p>다음 편에서는 OCI 서버 auth.log에 쌓인 SSH 브루트포스 공격을 Fail2ban으로 자동 차단하는 방법을 다룬다.</p>
]]></content:encoded>
    </item>
    <item>
      <title>노트북으로 홈서버 구축하기 - Portainer CE로 Docker GUI 관리하기 (6편)</title>
      <link>https://chanyeols.com/posts/part-06-portainer/</link>
      <pubDate>Sat, 28 Mar 2026 00:00:00 +0000</pubDate>
      <guid>https://chanyeols.com/posts/part-06-portainer/</guid>
      <description>컨테이너가 늘어나면서 CLI 관리가 번거로워졌다. Portainer CE를 Docker Compose로 설치해서 웹 UI로 컨테이너를 관리하는 환경을 구성합니다.</description>
      <content:encoded><![CDATA[<h2 id="왜-portainer인가">왜 Portainer인가</h2>
<p>Filebrowser, Immich, Vaultwarden, Prometheus, Grafana&hellip; 서비스가 하나씩 늘어나다 보니 컨테이너가 어느새 8개가 넘었다. 매번 SSH 접속해서 <code>docker ps</code>, <code>docker logs</code>, <code>docker compose restart</code> 치는 게 점점 번거로워졌다.</p>
<p>Portainer CE는 Docker를 웹 UI로 관리할 수 있는 오픈소스 도구다. 컨테이너 시작/중지/재시작, 실시간 로그 확인, 볼륨/네트워크 관리까지 브라우저에서 다 된다. CE(Community Edition)는 무료다.</p>
<hr>
<h2 id="설치">설치</h2>
<h3 id="1-디렉토리-생성">1. 디렉토리 생성</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir -p ~/portainer <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> cd ~/portainer
</span></span></code></pre></div><h3 id="2-docker-composeyml-작성">2. docker-compose.yml 작성</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">services</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">portainer</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">portainer/portainer-ce:latest</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">portainer</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">restart</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">always</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;19000:9000&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;18000:8000&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">/var/run/docker.sock:/var/run/docker.sock</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">portainer_data:/data</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">portainer_data</span>:<span style="color:#6e7681">
</span></span></span></code></pre></div><p>핵심은 <code>/var/run/docker.sock</code>을 마운트하는 것이다. 이를 통해 Portainer가 호스트의 Docker 데몬에 직접 접근할 수 있다.</p>
<p>포트는 기존 서비스들과 충돌하지 않도록 10000번대로 설정했다.</p>
<table>
  <thead>
      <tr>
          <th>포트</th>
          <th>용도</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>19000</td>
          <td>Portainer 웹 UI</td>
      </tr>
      <tr>
          <td>18000</td>
          <td>Edge Agent 터널 (원격 환경 연결용)</td>
      </tr>
  </tbody>
</table>
<h3 id="3-실행">3. 실행</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose up -d
</span></span></code></pre></div><h3 id="4-초기-설정">4. 초기 설정</h3>
<p>브라우저에서 <code>http://100.109.108.36:19000</code> 접속 후 admin 계정을 생성한다.</p>
<blockquote>
<p>⚠️ 컨테이너 실행 후 <strong>5분 이내</strong>에 초기 계정을 만들어야 한다. 시간이 지나면 보안상 접근이 차단되므로 <code>docker compose restart portainer</code>로 재시작해야 한다.</p>
</blockquote>
<hr>
<h2 id="결과">결과</h2>
<p><img alt="Portainer 대시보드 - 컨테이너 목록" loading="lazy" src="/images/homeserver-06-portainer.png"></p>
<p>설치 후 현재 홈서버에서 실행 중인 모든 컨테이너를 한눈에 볼 수 있다.</p>
<table>
  <thead>
      <tr>
          <th>컨테이너</th>
          <th>상태</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>filebrowser</td>
          <td>healthy ✅</td>
      </tr>
      <tr>
          <td>grafana</td>
          <td>running ✅</td>
      </tr>
      <tr>
          <td>immich_machine_learning</td>
          <td>healthy ✅</td>
      </tr>
      <tr>
          <td>immich_postgres</td>
          <td>healthy ✅</td>
      </tr>
      <tr>
          <td>immich_redis</td>
          <td>healthy ✅</td>
      </tr>
      <tr>
          <td>immich_server</td>
          <td>healthy ✅</td>
      </tr>
      <tr>
          <td>portainer</td>
          <td>running ✅</td>
      </tr>
      <tr>
          <td>prometheus</td>
          <td>running ✅</td>
      </tr>
  </tbody>
</table>
<p>컨테이너별로 로그 확인, 시작/중지, 환경변수, 마운트 볼륨 정보까지 GUI에서 바로 볼 수 있다.</p>
<hr>
<h2 id="접근-방식">접근 방식</h2>
<p>Portainer는 Docker 소켓에 직접 접근하는 민감한 도구라 외부에 열지 않고 Tailscale VPN 안에서만 접근한다.</p>
<pre tabindex="0"><code>Portainer UI → http://100.109.108.36:19000 (Tailscale VPN으로만 접근)
</code></pre><p>Nginx 리버스 프록시로 외부에 노출하지 않는다. 누군가 Portainer에 접근하면 서버의 모든 컨테이너를 제어할 수 있기 때문이다.</p>
<hr>
<h2 id="정리">정리</h2>
<ul>
<li>Docker Compose 파일 하나로 설치가 끝난다</li>
<li><code>/var/run/docker.sock</code> 마운트가 핵심</li>
<li>컨테이너 실행 후 5분 안에 초기 계정 생성할 것</li>
<li>보안상 VPN 안에서만 접근하고 외부에는 절대 열지 말 것</li>
</ul>
<p>다음 편에서는 Prometheus + Grafana + Node Exporter로 홈서버와 OCI 서버를 동시에 모니터링하는 환경을 구성한다.</p>
]]></content:encoded>
    </item>
    <item>
      <title>노트북으로 홈서버 구축하기 - Vaultwarden으로 비밀번호 자체 호스팅하기 (5편)</title>
      <link>https://chanyeols.com/posts/part-05-vaultwarden/</link>
      <pubDate>Fri, 27 Mar 2026 00:00:00 +0000</pubDate>
      <guid>https://chanyeols.com/posts/part-05-vaultwarden/</guid>
      <description>Bitwarden 호환 서버인 Vaultwarden을 Docker로 설치하고, 구글 비밀번호를 이전한 후 브라우저 확장 프로그램과 연동하는 과정을 정리합니다.</description>
      <content:encoded><![CDATA[<h2 id="vaultwarden이란">Vaultwarden이란?</h2>
<p>Bitwarden의 오픈소스 서버 구현체다. 공식 Bitwarden 앱, 브라우저 확장 프로그램과 100% 호환되면서, 내 서버에서 직접 운영할 수 있다.</p>
<p>구글 비밀번호 관리자를 쭉 써왔는데, 비밀번호를 외부 서비스에 맡기는 게 항상 마음에 걸렸다. 홈서버가 생겼으니 직접 호스팅하기로 했다.</p>
<hr>
<h2 id="설치">설치</h2>
<h3 id="1-디렉토리-생성">1. 디렉토리 생성</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir -p ~/vaultwarden <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> cd ~/vaultwarden
</span></span></code></pre></div><h3 id="2-docker-composeyml-작성">2. docker-compose.yml 작성</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">services</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">vaultwarden</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">image</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">vaultwarden/server:latest</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">container_name</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">vaultwarden</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">restart</span>:<span style="color:#6e7681"> </span><span style="color:#a5d6ff">always</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">ports</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">&#34;11000:80&#34;</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">vaultwarden_data:/data</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">    </span><span style="color:#7ee787">environment</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">DOMAIN=https://vault.yourdomain.com</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">      </span>- <span style="color:#a5d6ff">SIGNUPS_ALLOWED=true</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span><span style="color:#7ee787">vaultwarden_data</span>:<span style="color:#6e7681">
</span></span></span></code></pre></div><p><code>DOMAIN</code>에 실제 접근할 도메인을 설정해야 한다. Vaultwarden이 HTTPS 환경에서 동작한다고 인식해야 브라우저 확장 연동이 정상적으로 된다.</p>
<h3 id="3-실행">3. 실행</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose up -d
</span></span></code></pre></div><hr>
<h2 id="oci-nginx-리버스-프록시-설정">OCI Nginx 리버스 프록시 설정</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo vi /etc/nginx/sites-available/vault.yourdomain.com
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#ff7b72">server</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">listen</span> <span style="color:#a5d6ff">80</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">server_name</span> <span style="color:#a5d6ff">vault.yourdomain.com</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> <span style="color:#a5d6ff">301</span> <span style="color:#a5d6ff">https://</span><span style="color:#79c0ff">$host$request_uri</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">server</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">listen</span> <span style="color:#a5d6ff">443</span> <span style="color:#a5d6ff">ssl</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">server_name</span> <span style="color:#a5d6ff">vault.yourdomain.com</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">ssl_certificate</span>     <span style="color:#a5d6ff">/ssl/live/yourdomain.com/fullchain.pem</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">ssl_certificate_key</span> <span style="color:#a5d6ff">/ssl/live/yourdomain.com/privkey.pem</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">ssl_protocols</span> <span style="color:#a5d6ff">TLSv1.2</span> <span style="color:#a5d6ff">TLSv1.3</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">ssl_prefer_server_ciphers</span> <span style="color:#79c0ff;font-weight:bold">on</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">location</span> <span style="color:#a5d6ff">/</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_pass</span> <span style="color:#a5d6ff">http://100.109.108.36:11000</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">Host</span> <span style="color:#79c0ff">$host</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">X-Real-IP</span> <span style="color:#79c0ff">$remote_addr</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">X-Forwarded-For</span> <span style="color:#79c0ff">$proxy_add_x_forwarded_for</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">X-Forwarded-Proto</span> <span style="color:#79c0ff">$scheme</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo ln -s /etc/nginx/sites-available/vault.yourdomain.com /etc/nginx/sites-enabled/
</span></span><span style="display:flex;"><span>sudo nginx -t <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> sudo systemctl reload nginx
</span></span></code></pre></div><hr>
<h2 id="초기-계정-생성">초기 계정 생성</h2>
<p><code>https://vault.yourdomain.com</code> 접속 후 계정을 생성한다.</p>
<p><img alt="Vaultwarden 초기 화면" loading="lazy" src="/images/homeserver-05-vaultwarden.png"></p>
<blockquote>
<p>⚠️ <strong>마스터 비밀번호는 절대 잊으면 안 된다.</strong> 서버 관리자도 복구가 불가능하고, 분실하면 저장된 비밀번호 전부 날아간다.</p>
</blockquote>
<p>계정 생성 완료 후 신규 가입을 막는다. 혼자 쓰는 서버라 추가 가입이 필요 없다.</p>
<p><code>docker-compose.yml</code> 수정:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">environment</span>:<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">  </span>- <span style="color:#a5d6ff">SIGNUPS_ALLOWED=false</span><span style="color:#6e7681">
</span></span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose up -d
</span></span></code></pre></div><hr>
<h2 id="구글-비밀번호-가져오기">구글 비밀번호 가져오기</h2>
<h3 id="1-구글-비밀번호-내보내기">1. 구글 비밀번호 내보내기</h3>
<p><code>passwords.google.com</code> 접속 → 우측 상단 설정(⚙️) → <strong>비밀번호 내보내기</strong> → CSV 다운로드</p>
<h3 id="2-vaultwarden으로-가져오기">2. Vaultwarden으로 가져오기</h3>
<p><code>vault.yourdomain.com</code> 로그인 → <strong>Tools</strong> → <strong>Import data</strong></p>
<ul>
<li>형식: <code>Google Chrome (csv)</code> 선택</li>
<li>다운로드한 CSV 파일 업로드</li>
<li><strong>Import</strong> 클릭</li>
</ul>
<blockquote>
<p>⚠️ CSV 파일에 비밀번호가 평문으로 들어있다. 가져오기 완료 후 즉시 삭제할 것.</p>
</blockquote>
<hr>
<h2 id="브라우저-확장-연동">브라우저 확장 연동</h2>
<p>크롬 웹스토어에서 <strong>Bitwarden</strong> 확장 프로그램을 설치한다.</p>
<p>확장 아이콘 클릭 → 로그인 화면 좌측 상단 <strong>지구본 아이콘</strong> 클릭 → <strong>Self-hosted</strong> 선택</p>
<p>Server URL에 <code>https://vault.yourdomain.com</code> 입력 후 Save.</p>
<p>이후 Vaultwarden 계정으로 로그인하면 기존 구글 자동완성과 동일하게 사이트마다 비밀번호를 자동완성해준다.</p>
<hr>
<h2 id="정리">정리</h2>
<ul>
<li>Vaultwarden은 Bitwarden과 완전 호환이라 앱, 확장 프로그램 그대로 쓸 수 있다</li>
<li><code>DOMAIN</code> 환경변수를 HTTPS 도메인으로 설정해야 브라우저 확장이 정상 동작한다</li>
<li>계정 생성 후 <code>SIGNUPS_ALLOWED=false</code>로 꼭 닫아두자</li>
<li>마스터 비밀번호 분실 시 복구 방법이 없으므로 안전하게 보관할 것</li>
</ul>
<p>다음 편에서는 Portainer CE를 설치해서 Docker 컨테이너를 GUI로 관리하는 환경을 구성한다.</p>
]]></content:encoded>
    </item>
    <item>
      <title>노트북으로 홈서버 구축하기 - Immich로 구글 포토 대체하기 (4편)</title>
      <link>https://chanyeols.com/posts/part-04-immich/</link>
      <pubDate>Thu, 26 Mar 2026 00:00:00 +0000</pubDate>
      <guid>https://chanyeols.com/posts/part-04-immich/</guid>
      <description>자체 호스팅 사진 관리 서비스 Immich를 Docker Compose로 설치하는 과정과 겪었던 4가지 트러블슈팅을 정리합니다.</description>
      <content:encoded><![CDATA[<h2 id="immich란">Immich란?</h2>
<p>구글 포토와 거의 동일한 UX를 제공하는 자체 호스팅 사진 관리 서비스다. 얼굴 인식, 지도 뷰, 앨범, 공유 기능까지 있고 모바일 앱도 있어서 자동 백업이 된다. 구글 포토 유료 요금제를 쓰고 있었는데 이걸로 완전히 대체했다.</p>
<hr>
<h2 id="설치-과정">설치 과정</h2>
<h3 id="1-디렉토리-생성">1. 디렉토리 생성</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/immich <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> cd ~/immich
</span></span></code></pre></div><h3 id="2-docker-composeyml-및-env-다운로드">2. docker-compose.yml 및 .env 다운로드</h3>
<p>Immich 공식에서 제공하는 파일을 그대로 받아서 쓴다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>wget -O docker-compose.yml https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
</span></span><span style="display:flex;"><span>wget -O .env https://github.com/immich-app/immich/releases/latest/download/example.env
</span></span></code></pre></div><h3 id="3-env-파일-수정">3. .env 파일 수정</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>vi .env
</span></span></code></pre></div><p>아래 두 경로를 수정한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>UPLOAD_LOCATION=/mnt/data/immich/photos
</span></span><span style="display:flex;"><span>DB_DATA_LOCATION=/home/your-username/immich-db
</span></span></code></pre></div><blockquote>
<p><code>DB_DATA_LOCATION</code>을 <code>/mnt/data</code>(NTFS) 아래로 잡으면 안 된다. 이유는 아래 트러블슈팅에서 설명한다.</p>
</blockquote>
<h3 id="4-실행">4. 실행</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose up -d
</span></span></code></pre></div><hr>
<h2 id="트러블슈팅">트러블슈팅</h2>
<p>설치 과정에서 꽤 여러 번 막혔다. 겪은 순서대로 정리한다.</p>
<h3 id="1-docker-compose-up--d--unknown-shorthand-flag-오류">1. <code>docker compose up -d</code> — unknown shorthand flag 오류</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>unknown shorthand flag: &#39;d&#39; in -d
</span></span></code></pre></div><p><code>apt install docker.io</code>로 설치한 패키지가 구버전이라 <code>docker compose</code> 플러그인을 지원하지 않아서 생기는 문제다.</p>
<p><strong>해결:</strong> Docker 공식 레포에서 설치한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo apt install ca-certificates curl gnupg -y
</span></span><span style="display:flex;"><span>sudo install -m <span style="color:#a5d6ff">0755</span> -d /etc/apt/keyrings
</span></span><span style="display:flex;"><span>curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
</span></span><span style="display:flex;"><span>echo <span style="color:#a5d6ff">&#34;deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu noble stable&#34;</span> | sudo tee /etc/apt/sources.list.d/docker.list
</span></span><span style="display:flex;"><span>sudo apt update
</span></span><span style="display:flex;"><span>sudo apt install docker-compose-plugin -y
</span></span></code></pre></div><h3 id="2-compose-파일-버전-오류">2. Compose 파일 버전 오류</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>ERROR: The Compose file &#39;./docker-compose.yml&#39; is invalid because:
</span></span><span style="display:flex;"><span>&#39;name&#39; does not match any of the regexes: &#39;^x-&#39;
</span></span></code></pre></div><p><code>apt install docker-compose</code>로 설치한 구버전(1.29.x)은 최신 Compose 파일 형식을 지원하지 않는다.</p>
<p><strong>해결:</strong> 구버전 제거 후 플러그인 버전으로 교체한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo apt remove docker-compose -y
</span></span><span style="display:flex;"><span>sudo apt install docker-compose-plugin -y
</span></span></code></pre></div><h3 id="3-docker-compose-plugin-패키지를-찾을-수-없음">3. <code>docker-compose-plugin</code> 패키지를 찾을 수 없음</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>E: Unable to locate package docker-compose-plugin
</span></span></code></pre></div><p>Ubuntu 기본 레포에는 <code>docker-compose-plugin</code>이 없다.</p>
<p><strong>해결:</strong> 1번 해결 방법에서 Docker 공식 레포를 먼저 추가하면 된다.</p>
<h3 id="4-immich_postgres-컨테이너가-계속-재시작됨">4. immich_postgres 컨테이너가 계속 재시작됨</h3>
<p>컨테이너 상태를 확인해보면 <code>Restarting</code> 상태로 계속 재시작만 반복한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker logs immich_postgres
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>FATAL:  data directory &#34;/var/lib/postgresql/data&#34; has wrong ownership
</span></span><span style="display:flex;"><span>HINT:  The server must be started by the user that owns the data directory.
</span></span></code></pre></div><p><strong>원인:</strong> <code>DB_DATA_LOCATION</code>을 <code>/mnt/data</code>(NTFS) 경로로 설정했기 때문이다. NTFS는 Linux 파일 권한 시스템을 지원하지 않아서 PostgreSQL이 요구하는 소유권을 설정할 수 없다.</p>
<p><strong>해결:</strong> DB 경로를 ext4 파일시스템(Ubuntu 기본 디스크)으로 바꾼다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose down
</span></span><span style="display:flex;"><span>mkdir -p ~/immich-db
</span></span><span style="display:flex;"><span>vi .env
</span></span></code></pre></div><p><code>.env</code> 수정:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>DB_DATA_LOCATION=/home/your-username/immich-db
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose up -d
</span></span></code></pre></div><p>사진 원본(<code>UPLOAD_LOCATION</code>)은 용량이 크니까 NTFS 드라이브에 둬도 되지만, DB는 반드시 ext4에 둬야 한다.</p>
<hr>
<h2 id="정상-실행-확인">정상 실행 확인</h2>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose ps
</span></span></code></pre></div><p><img alt="docker compose ps 결과 - 모든 컨테이너 running 상태" loading="lazy" src="/images/homeserver-04-compose-ps.png"></p>
<p>모든 컨테이너가 <code>running</code> 또는 <code>healthy</code> 상태면 성공이다.</p>
<p>브라우저에서 <code>http://100.109.108.36:2283</code> 접속 후 계정을 생성하면 된다.</p>
<hr>
<h2 id="oci-nginx-리버스-프록시-연결">OCI Nginx 리버스 프록시 연결</h2>
<p>3편에서 Filebrowser에 했던 것과 동일하게 Nginx 설정을 추가한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#ff7b72">server</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">listen</span> <span style="color:#a5d6ff">80</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">server_name</span> <span style="color:#a5d6ff">photo.yourdomain.com</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> <span style="color:#a5d6ff">301</span> <span style="color:#a5d6ff">https://</span><span style="color:#79c0ff">$host$request_uri</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">server</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">listen</span> <span style="color:#a5d6ff">443</span> <span style="color:#a5d6ff">ssl</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">server_name</span> <span style="color:#a5d6ff">photo.yourdomain.com</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">ssl_certificate</span>     <span style="color:#a5d6ff">/ssl/live/yourdomain.com/fullchain.pem</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">ssl_certificate_key</span> <span style="color:#a5d6ff">/ssl/live/yourdomain.com/privkey.pem</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">ssl_protocols</span> <span style="color:#a5d6ff">TLSv1.2</span> <span style="color:#a5d6ff">TLSv1.3</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">ssl_prefer_server_ciphers</span> <span style="color:#79c0ff;font-weight:bold">on</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#8b949e;font-style:italic"># Immich는 대용량 파일 업로드가 있으므로 크기 제한 해제
</span></span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">client_max_body_size</span> <span style="color:#a5d6ff">0</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">location</span> <span style="color:#a5d6ff">/</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_pass</span> <span style="color:#a5d6ff">http://100.109.108.36:2283</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">Host</span> <span style="color:#79c0ff">$host</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">X-Real-IP</span> <span style="color:#79c0ff">$remote_addr</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">X-Forwarded-For</span> <span style="color:#79c0ff">$proxy_add_x_forwarded_for</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">X-Forwarded-Proto</span> <span style="color:#79c0ff">$scheme</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#8b949e;font-style:italic"># 웹소켓 지원 (Immich 실시간 업로드에 필요)
</span></span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_http_version</span> <span style="color:#a5d6ff">1</span><span style="color:#a5d6ff">.1</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">Upgrade</span> <span style="color:#79c0ff">$http_upgrade</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">Connection</span> <span style="color:#a5d6ff">&#34;upgrade&#34;</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Cloudflare에서 <code>photo.yourdomain.com</code> DNS 추가하고 Nginx 적용하면 외부에서 접근 가능하다.</p>
<p><img alt="Immich 외부 접근 성공" loading="lazy" src="/images/homeserver-04-immich-external.png"></p>
<hr>
<h2 id="정리">정리</h2>
<ul>
<li><code>docker.io</code>는 구버전이라 최신 Compose 파일을 못 읽는다. Docker 공식 레포에서 설치하자</li>
<li>PostgreSQL 데이터 디렉토리는 NTFS가 아닌 ext4에 둬야 한다</li>
<li>사진 원본은 NTFS 외장 드라이브, DB는 OS 디스크로 분리하면 된다</li>
<li>Immich Nginx 설정 시 <code>client_max_body_size 0</code>과 웹소켓 설정을 빠뜨리지 말 것</li>
</ul>
<p>다음 편에서는 Vaultwarden으로 비밀번호를 자체 호스팅하는 과정을 다룬다.</p>
]]></content:encoded>
    </item>
    <item>
      <title>노트북으로 홈서버 구축하기 - 외장 SSD 마운트 &#43; Filebrowser 원격 파일 관리 (3편)</title>
      <link>https://chanyeols.com/posts/part-03-ssd-filebrowser/</link>
      <pubDate>Wed, 25 Mar 2026 00:00:00 +0000</pubDate>
      <guid>https://chanyeols.com/posts/part-03-ssd-filebrowser/</guid>
      <description>NTFS 외장 SSD를 Ubuntu에 마운트하고, Docker로 Filebrowser를 설치해서 브라우저에서 파일을 관리하는 환경을 구성합니다. OCI Nginx 리버스 프록시와 SSL 설정까지 다룹니다.</description>
      <content:encoded><![CDATA[<h2 id="외장-ssd-마운트">외장 SSD 마운트</h2>
<p>집에 1TB SSD가 남아있어서 홈서버 스토리지로 활용하기로 했다. 기존에 Windows에서 쓰던 드라이브라 NTFS 포맷이다.</p>
<h3 id="디스크-확인">디스크 확인</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>lsblk
</span></span></code></pre></div><p>어떤 디바이스명으로 잡혔는지 확인한다.</p>
<p><img alt="lsblk 결과" loading="lazy" src="/images/homeserver-03-lsblk.png"></p>
<p>파티션 포맷 확인:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo blkid /dev/nvme1n1p2
</span></span></code></pre></div><p>NTFS로 확인됐으니 마운트를 진행한다.</p>
<h3 id="마운트">마운트</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo apt install ntfs-3g -y
</span></span><span style="display:flex;"><span>sudo mkdir /mnt/data
</span></span><span style="display:flex;"><span>sudo mount /dev/nvme1n1p2 /mnt/data
</span></span></code></pre></div><p><img alt="마운트 결과" loading="lazy" src="/images/homeserver-03-mount.png"></p>
<p>마운트 후 확인해보니 <code>Could not mount read-write, trying read-only</code> 메시지가 떴다. 읽기 전용으로 마운트된 것이다. Windows에서 쓰던 드라이브라 더티 플래그가 남아있어서 발생하는 문제다.</p>
<h3 id="읽기쓰기-가능하도록-재마운트">읽기/쓰기 가능하도록 재마운트</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo umount /mnt/data
</span></span><span style="display:flex;"><span>sudo ntfsfix /dev/nvme1n1p2
</span></span><span style="display:flex;"><span>sudo mount /dev/nvme1n1p2 /mnt/data
</span></span></code></pre></div><p><code>ntfsfix</code>로 더티 플래그를 제거하고 다시 마운트하면 읽기/쓰기가 모두 가능해진다.</p>
<h3 id="심볼릭-링크-생성">심볼릭 링크 생성</h3>
<p>홈 디렉토리에서 편하게 접근하기 위해 심볼릭 링크를 만들어둔다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ln -s /mnt/data ~/data
</span></span></code></pre></div><hr>
<h2 id="samba-설정">Samba 설정</h2>
<p>파일 탐색기에서 네트워크 드라이브처럼 접근하고 싶다면 Samba를 설치하면 된다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo apt install samba -y
</span></span></code></pre></div><p>Samba 사용자 등록:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo smbpasswd -a your-username
</span></span></code></pre></div><p><img alt="Samba 사용자 등록" loading="lazy" src="/images/homeserver-03-samba-user.png"></p>
<p>설정 파일에 공유 디렉토리 추가:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo vi /etc/samba/smb.conf
</span></span></code></pre></div><p>파일 맨 아래에 아래 내용을 추가한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>[data]
</span></span><span style="display:flex;"><span>   path = /mnt/data
</span></span><span style="display:flex;"><span>   browseable = yes
</span></span><span style="display:flex;"><span>   read only = no
</span></span><span style="display:flex;"><span>   valid users = your-username
</span></span></code></pre></div><p>Samba 서비스 재시작:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo systemctl restart smbd
</span></span></code></pre></div><p>이제 Windows 파일 탐색기 주소창에 <code>\\100.109.108.36\data</code>를 입력하면 마운트된 SSD에 접근할 수 있다.</p>
<p><img alt="파일 탐색기에서 Samba 접근" loading="lazy" src="/images/homeserver-03-samba.png"></p>
<p>다만 이건 파일 탐색기 전용이라 브라우저에서 접근하려면 Filebrowser를 써야 한다.</p>
<hr>
<h2 id="docker--filebrowser-설치">Docker + Filebrowser 설치</h2>
<h3 id="docker-설치">Docker 설치</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># Docker 공식 레포 추가</span>
</span></span><span style="display:flex;"><span>sudo apt install ca-certificates curl gnupg -y
</span></span><span style="display:flex;"><span>sudo install -m <span style="color:#a5d6ff">0755</span> -d /etc/apt/keyrings
</span></span><span style="display:flex;"><span>curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
</span></span><span style="display:flex;"><span>echo <span style="color:#a5d6ff">&#34;deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu noble stable&#34;</span> | sudo tee /etc/apt/sources.list.d/docker.list
</span></span><span style="display:flex;"><span>sudo apt update
</span></span><span style="display:flex;"><span>sudo apt install docker-ce docker-ce-cli containerd.io docker-compose-plugin -y
</span></span></code></pre></div><p>root가 아닌 일반 계정으로 Docker를 사용하려면:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo usermod -aG docker <span style="color:#79c0ff">$USER</span>
</span></span><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># 재로그인 후 적용</span>
</span></span></code></pre></div><h3 id="filebrowser-실행">Filebrowser 실행</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker run -d <span style="color:#79c0ff">\
</span></span></span><span style="display:flex;"><span>  --name filebrowser <span style="color:#79c0ff">\
</span></span></span><span style="display:flex;"><span>  --restart always <span style="color:#79c0ff">\
</span></span></span><span style="display:flex;"><span>  -v /mnt/data:/srv <span style="color:#79c0ff">\
</span></span></span><span style="display:flex;"><span>  -p 9090:80 <span style="color:#79c0ff">\
</span></span></span><span style="display:flex;"><span>  filebrowser/filebrowser
</span></span></code></pre></div><p><code>/mnt/data</code>를 컨테이너 안의 <code>/srv</code>로 마운트해서 Filebrowser가 SSD 전체를 서빙하도록 했다.</p>
<h3 id="초기-로그인">초기 로그인</h3>
<p>브라우저에서 <code>http://100.109.108.36:9090</code> 접속. 초기 계정은 <code>admin / admin</code>이라고 알려져 있는데 접속이 안 됐다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker logs filebrowser
</span></span></code></pre></div><p>로그를 확인해보니 랜덤으로 생성된 비밀번호가 출력돼 있었다. 그 비밀번호로 접속하면 된다.</p>
<p><img alt="Filebrowser 로그인 화면" loading="lazy" src="/images/homeserver-03-filebrowser-login.png"></p>
<p><img alt="Filebrowser 접속 성공" loading="lazy" src="/images/homeserver-03-filebrowser.png"></p>
<hr>
<h2 id="oci-nginx-리버스-프록시--ssl">OCI Nginx 리버스 프록시 + SSL</h2>
<p>VPN 없이도 외부에서 브라우저로 접근하려면 OCI Nginx에 리버스 프록시를 설정해야 한다.</p>
<h3 id="cloudflare-dns-추가">Cloudflare DNS 추가</h3>
<p>Cloudflare에서 <code>files.yourdomain.com</code> 서브도메인을 OCI 공인 IP로 추가한다.</p>
<p><img alt="Cloudflare DNS 설정" loading="lazy" src="/images/homeserver-03-cloudflare.png"></p>
<h3 id="nginx-설정">Nginx 설정</h3>
<p>OCI 서버에서 <code>/etc/nginx/sites-available/files.yourdomain.com</code> 파일을 생성한다.</p>
<p>처음엔 HTTP만 설정했는데, <code>files.yourdomain.com</code>으로 접속하면 계속 메인 도메인으로 리다이렉트되는 문제가 있었다. SSL 인증서를 안 걸어줘서 생기는 문제였다.</p>
<p>최종 설정:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nginx" data-lang="nginx"><span style="display:flex;"><span><span style="color:#ff7b72">server</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">listen</span> <span style="color:#a5d6ff">80</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">server_name</span> <span style="color:#a5d6ff">files.yourdomain.com</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">return</span> <span style="color:#a5d6ff">301</span> <span style="color:#a5d6ff">https://</span><span style="color:#79c0ff">$host$request_uri</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff7b72">server</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">listen</span> <span style="color:#a5d6ff">443</span> <span style="color:#a5d6ff">ssl</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">server_name</span> <span style="color:#a5d6ff">files.yourdomain.com</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">ssl_certificate</span>     <span style="color:#a5d6ff">/ssl/live/yourdomain.com/fullchain.pem</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">ssl_certificate_key</span> <span style="color:#a5d6ff">/ssl/live/yourdomain.com/privkey.pem</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">ssl_protocols</span> <span style="color:#a5d6ff">TLSv1.2</span> <span style="color:#a5d6ff">TLSv1.3</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">ssl_prefer_server_ciphers</span> <span style="color:#79c0ff;font-weight:bold">on</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">location</span> <span style="color:#a5d6ff">/</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_pass</span> <span style="color:#a5d6ff">http://100.109.108.36:9090</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">Host</span> <span style="color:#79c0ff">$host</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">X-Real-IP</span> <span style="color:#79c0ff">$remote_addr</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">X-Forwarded-For</span> <span style="color:#79c0ff">$proxy_add_x_forwarded_for</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">proxy_set_header</span> <span style="color:#a5d6ff">X-Forwarded-Proto</span> <span style="color:#79c0ff">$scheme</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>심볼릭 링크 생성 후 적용:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo ln -s /etc/nginx/sites-available/files.yourdomain.com /etc/nginx/sites-enabled/
</span></span><span style="display:flex;"><span>sudo nginx -t <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> sudo systemctl reload nginx
</span></span></code></pre></div><p><code>proxy_pass</code>에 Tailscale VPN IP를 쓰는 게 핵심이다. OCI와 홈서버가 같은 Tailscale 네트워크 안에 있기 때문에 가능한 구성이다.</p>
<p><img alt="Filebrowser 외부 접근 성공" loading="lazy" src="/images/homeserver-03-filebrowser-external.png"></p>
<p>이제 VPN 없이 <code>files.yourdomain.com</code>으로 접속해서 홈서버의 파일을 관리할 수 있다.</p>
<hr>
<h2 id="정리">정리</h2>
<ul>
<li>NTFS 드라이브는 <code>ntfsfix</code>로 더티 플래그 제거 후 마운트해야 읽기/쓰기가 된다</li>
<li>Samba는 파일 탐색기 접근용, Filebrowser는 브라우저 접근용</li>
<li>OCI Nginx 리버스 프록시 + Tailscale VPN 조합으로 홈서버를 외부에 안전하게 노출할 수 있다</li>
</ul>
<p>다음 편에서는 Immich를 설치해서 구글 포토를 대체하는 과정을 다룬다.</p>
]]></content:encoded>
    </item>
    <item>
      <title>노트북으로 홈서버 구축하기 - Ubuntu Server 설치 &#43; Tailscale VPN (2편)</title>
      <link>https://chanyeols.com/posts/part-02-ubuntu-tailscale/</link>
      <pubDate>Tue, 24 Mar 2026 16:00:00 +0900</pubDate>
      <guid>https://chanyeols.com/posts/part-02-ubuntu-tailscale/</guid>
      <description>Ventoy로 Ubuntu Server 24.04를 설치하고, Tailscale VPN으로 홈서버와 OCI 인스턴스를 하나의 사설 네트워크로 묶는 과정을 정리합니다.</description>
      <content:encoded><![CDATA[<h2 id="os-선택">OS 선택</h2>
<p>Windows를 그대로 쓸까, Linux native로 갈까 고민했다. Docker 운영이 메인이고 서버답게 쓰려면 Linux가 맞다. Ubuntu Server 24.04 LTS로 결정했다.</p>
<hr>
<h2 id="ubuntu-server-설치">Ubuntu Server 설치</h2>
<h3 id="준비물">준비물</h3>
<p><strong>Ventoy</strong> — USB를 부팅 드라이브로 만들어주는 도구다. 일반적인 방식은 ISO를 USB에 굽는 방식인데, Ventoy는 USB 하나에 여러 ISO를 넣고 부팅 시 선택할 수 있어서 훨씬 편하다.</p>
<ul>
<li>Ventoy 다운로드: <a href="https://github.com/ventoy/Ventoy/releases">https://github.com/ventoy/Ventoy/releases</a></li>
<li>Ubuntu Server 24.04 LTS ISO 다운로드: <a href="https://ubuntu.com/download/server">https://ubuntu.com/download/server</a></li>
</ul>
<h3 id="설치-순서">설치 순서</h3>
<p><strong>1. Ventoy2Disk.exe 실행</strong></p>
<p>USB를 꽂고 Ventoy2Disk.exe를 실행한다. 사용할 USB를 선택하고 Install을 누른다.</p>
<blockquote>
<p>⚠️ USB에 있는 데이터는 전부 지워지므로 먼저 백업할 것</p>
</blockquote>
<p><img alt="Ventoy2Disk 실행 화면" loading="lazy" src="/images/homeserver-02-ventoy.png"></p>
<p><strong>2. ISO 파일 복사</strong></p>
<p>Install 완료 후 다운로드한 Ubuntu ISO 파일을 USB에 그냥 복사하면 된다.</p>
<p><strong>3. 서버에 USB 꽂고 부팅</strong></p>
<p>Ubuntu를 설치할 노트북에 USB를 꽂고 전원을 켠다. F12를 연타해서 바이오스 부팅 메뉴 진입 후 Ventoy가 설치된 USB를 선택한다.</p>
<p>이후 Ubuntu Server 설치 과정은 안내에 따라 진행하면 된다. 특별히 복잡한 부분은 없다. 파티션은 기본값으로 잡고, SSH 서버 설치 옵션은 체크해두는 게 좋다.</p>
<hr>
<h2 id="tailscale-vpn-연결">Tailscale VPN 연결</h2>
<p>홈서버는 공인 IP가 없어서 외부에서 직접 접근이 안 된다. Tailscale을 사용하면 홈서버와 OCI 인스턴스를 같은 사설 네트워크로 묶을 수 있다.</p>
<h3 id="설치">설치</h3>
<p>Ubuntu 설치 후 터미널에서 아래 명령어를 실행한다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl -fsSL https://tailscale.com/install.sh | sh
</span></span></code></pre></div><p>설치 후 활성화:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo tailscale up
</span></span></code></pre></div><p>실행하면 아래처럼 인증 URL이 출력된다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-plain" data-lang="plain"><span style="display:flex;"><span>To authenticate, visit:
</span></span><span style="display:flex;"><span>    https://login.tailscale.com/a/xxxxxxxxxxxxxxx
</span></span></code></pre></div><p>URL을 브라우저에서 열어서 Tailscale 계정으로 로그인하면 홈서버가 네트워크에 등록된다.</p>
<h3 id="홈서버-ip-확인">홈서버 IP 확인</h3>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>tailscale ip -4
</span></span></code></pre></div><p>이 IP(예: <code>100.109.108.36</code>)로 이제 어디서든 홈서버에 SSH 접속이 가능하다.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ssh username@100.109.108.36
</span></span></code></pre></div><h3 id="oci-서버에도-동일하게-설치">OCI 서버에도 동일하게 설치</h3>
<p>OCI 인스턴스에도 위 과정을 그대로 반복해서 Tailscale을 설치하면 두 서버가 같은 사설 네트워크로 묶인다.</p>
<p><img alt="Tailscale 관리 콘솔 - 두 서버가 연결된 화면" loading="lazy" src="/images/homeserver-02-tailscale.png"></p>
<p>설정 완료 후 Tailscale 관리 콘솔에서 홈서버와 OCI 서버가 모두 연결된 것을 확인할 수 있다. 이제 OCI에서 홈서버로, 홈서버에서 OCI로 자유롭게 통신이 가능하다.</p>
<hr>
<h2 id="여기까지의-구성">여기까지의 구성</h2>
<pre tabindex="0"><code>[내 PC / 스마트폰]
       │  Tailscale VPN
       ▼
[홈서버 ThinkPad] ─── Tailscale VPN ─── [OCI 인스턴스]
  100.109.108.36                           100.x.x.x
</code></pre><p>Tailscale이 연결된 것만으로도 이미 꽤 쓸만한 환경이 됐다. 이제 어디서든 홈서버에 SSH로 접속해서 작업할 수 있다.</p>
<p>다음 편에서는 외장 SSD를 마운트하고 Filebrowser로 브라우저에서 파일을 관리하는 환경을 구성한다.</p>
]]></content:encoded>
    </item>
    <item>
      <title>노트북으로 홈서버 구축하기 - 왜 홈서버인가? (1편)</title>
      <link>https://chanyeols.com/posts/part-01-intro/</link>
      <pubDate>Tue, 24 Mar 2026 09:00:00 +0900</pubDate>
      <guid>https://chanyeols.com/posts/part-01-intro/</guid>
      <description>클라우드 대신 집에 남는 노트북으로 홈서버를 구축한 이유와 Tailscale &#43; OCI &#43; Docker로 구성한 전체 아키텍처를 소개합니다.</description>
      <content:encoded><![CDATA[<h2 id="왜-홈서버를-만들게-됐나">왜 홈서버를 만들게 됐나</h2>
<p>개인 블로그를 운영하면서 OCI(Oracle Cloud) 무료 인스턴스 하나를 쭉 써왔다. 1 vCPU, 1GB RAM짜리라 Hugo 정적 블로그 서빙에는 충분했는데, 문제는 점점 하고 싶은 게 늘어난다는 것이다.</p>
<ul>
<li>사진 파일이 쌓이면서 개인 NAS가 필요해졌다</li>
<li>사이드 프로젝트 돌릴 서버가 필요했다</li>
<li>비밀번호 관리도 외부 서비스 말고 직접 하고 싶었다</li>
</ul>
<p>클라우드로 커버하면 되지 않냐 싶지만, 스토리지가 좀 붙으면 요금이 눈에 띄게 올라간다. 마침 집에 안 쓰는 노트북이 하나 있었고, 거기서부터 홈서버 구축기가 시작됐다.</p>
<hr>
<h2 id="하드웨어-선택-데스크탑-대신-노트북">하드웨어 선택: 데스크탑 대신 노트북</h2>
<p>처음엔 집에 있는 데스크탑을 홈서버로 쓸까 생각했다. 그런데 데스크탑을 24시간 켜두면 전기세가 만만치 않다.</p>
<p>마침 Ryzen 5 5600U, RAM 16GB짜리 <strong>ThinkPad E15 Gen3</strong>가 있었다. 노트북이라 전력 소비가 훨씬 낮고, 배터리가 있어서 정전 시 UPS 역할도 된다. 성능도 홈서버 용도로는 충분하다.</p>
<table>
  <thead>
      <tr>
          <th>항목</th>
          <th>사양</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CPU</td>
          <td>Ryzen 5 5600U</td>
      </tr>
      <tr>
          <td>RAM</td>
          <td>16GB</td>
      </tr>
      <tr>
          <td>저장장치</td>
          <td>256GB SSD (OS/Docker) + 1TB SSD (/mnt/data)</td>
      </tr>
      <tr>
          <td>OS</td>
          <td>Ubuntu Server 24.04 LTS</td>
      </tr>
  </tbody>
</table>
<p>유선 랜 포트를 따로 뚫기가 애매해서 일단 와이파이로 연결해서 운영 중이다.</p>
<hr>
<h2 id="전체-아키텍처">전체 아키텍처</h2>
<p>핵심은 세 가지다.</p>
<p><strong>Tailscale VPN</strong> — 홈서버는 공인 IP가 없어서 외부에서 직접 접근이 안 된다. Tailscale로 홈서버와 OCI 인스턴스를 같은 사설 네트워크로 묶었다. 이렇게 하면 어디서든 VPN으로 홈서버에 접근할 수 있다.</p>
<p><strong>OCI Nginx 리버스 프록시</strong> — 기존에 Hugo 블로그를 서빙하던 OCI 인스턴스의 Nginx를 리버스 프록시로 활용한다. 외부에서 <code>photo.yourdomain.com</code>으로 접근하면 Nginx가 Tailscale VPN을 통해 홈서버의 Immich로 연결해주는 구조다.</p>
<p><strong>Cloudflare DNS</strong> — 도메인 관리는 Cloudflare에서 한다. 서브도메인 추가할 때마다 Cloudflare에서 레코드 하나 추가하고 Nginx 설정 추가하면 끝이다.</p>
<p><img alt="전체 아키텍처 구성도" loading="lazy" src="/images/homeserver-01-architecture.png"></p>
<hr>
<h2 id="운영-중인-서비스">운영 중인 서비스</h2>
<p>현재 홈서버에서 돌아가는 서비스 목록이다.</p>
<table>
  <thead>
      <tr>
          <th>포트</th>
          <th>서비스</th>
          <th>접근 방식</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>2283</td>
          <td>Immich (사진 관리)</td>
          <td>도메인 (OCI 프록시)</td>
      </tr>
      <tr>
          <td>9090</td>
          <td>Filebrowser (파일 관리)</td>
          <td>도메인 (OCI 프록시)</td>
      </tr>
      <tr>
          <td>11000</td>
          <td>Vaultwarden (비밀번호)</td>
          <td>도메인 (OCI 프록시)</td>
      </tr>
      <tr>
          <td>13000</td>
          <td>Grafana (모니터링)</td>
          <td>Tailscale VPN</td>
      </tr>
      <tr>
          <td>19000</td>
          <td>Portainer (Docker GUI)</td>
          <td>Tailscale VPN</td>
      </tr>
      <tr>
          <td>19090</td>
          <td>Prometheus</td>
          <td>Tailscale VPN</td>
      </tr>
      <tr>
          <td>19100</td>
          <td>Node Exporter</td>
          <td>내부 수집용</td>
      </tr>
      <tr>
          <td>23000</td>
          <td>컨테이너 대시보드 (React)</td>
          <td>Tailscale VPN</td>
      </tr>
      <tr>
          <td>28080</td>
          <td>컨테이너 대시보드 (Spring)</td>
          <td>Tailscale VPN</td>
      </tr>
  </tbody>
</table>
<p>Grafana, Portainer처럼 민감한 도구는 VPN 안에서만 접근하고 외부에는 열지 않았다. Docker 소켓에 직접 접근하는 툴을 외부에 노출하면 보안상 위험하기 때문이다.</p>
<hr>
<h2 id="시리즈-구성">시리즈 구성</h2>
<p>이 구축기는 총 12편으로 구성된다.</p>
<ol>
<li><strong>왜 홈서버인가? + 전체 아키텍처</strong> ← 지금 여기</li>
<li><a href="/posts/part-02-ubuntu-tailscale">Ubuntu Server 설치 + Tailscale VPN</a></li>
<li><a href="/posts/part-03-ssd-filebrowser">외장 SSD 마운트 + Filebrowser 원격 파일 관리</a></li>
<li><a href="/posts/part-04-immich">Immich로 구글 포토 대체하기</a></li>
<li><a href="/posts/part-05-vaultwarden">Vaultwarden으로 비밀번호 자체 호스팅</a></li>
<li><a href="/posts/part-06-portainer">Portainer CE로 Docker GUI 관리</a></li>
<li><a href="/posts/part-07-monitoring">Grafana + Prometheus로 홈서버 모니터링</a></li>
<li><a href="/posts/part-08-fail2ban">Fail2ban으로 SSH 브루트포스 차단</a></li>
<li><a href="/posts/part-09-ssl">certbot &ndash;expand로 SSL 서브도메인 추가</a></li>
<li><a href="/posts/part-10-postgresql-backup">PostgreSQL 자동 백업 (pg_dump + cron)</a></li>
<li><a href="/posts/part-11-tuning">TLP + thinkfan + Swap 튜닝으로 운영 최적화</a></li>
<li><a href="/posts/part-12-dashboard">직접 만든 컨테이너 대시보드 (Spring Boot + React + SSE)</a></li>
</ol>
<p>다음 편에서는 Ubuntu Server 설치부터 Tailscale VPN 연결까지 다룬다.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
