<?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>Selfhosted on Chanyeol Dev</title>
    <link>https://chanyeols.com/tags/selfhosted/</link>
    <description>Recent content in Selfhosted 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/selfhosted/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>노트북으로 홈서버 구축하기 - 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>
  </channel>
</rss>
