Architecture2026.08.10 · 10 min read

ミニ PC 1台の自宅サーバーに何を載せているか — 層の切り方と、状態の置き場所

NCP認証基盤・クラウドインフラ

自宅のミニ PC 1台に、個人プロダクトの実行環境と LAN の DNS とファイル共有をまとめて載せている。この記事は自宅サーバー3部作の第1部で、設計思想ではなく地図を書く。何のハードに何が乗っていて、リクエストが1本届くまでに何を通り、状態がディスクのどこに置かれているか。同じものを組む人が、どこから手をつければいいか分かる粒度にする。

1台に載せたもの、最初に踏んだ制約

ハードは Beelink EQ14。Intel N150(Alder Lake-N、4コア)、16GB DDR5、500GB の NVMe SSD(LVM で 462GB 使用可能)に Ubuntu 24.04.4 LTS を入れてある。IP は 192.168.0.200 固定、ホスト名は nishioka にした。常時通電させる前提で選んだ小型機なので、4コア・16GB という上限がそのまま設計の制約になる。この「余裕が無い」という前提が、後述する PostgreSQL を1つに寄せる判断まで効いてくる。

セットアップの過程で確定して、いまも前提として残っているものが3つある。

  • BIOS が efibootmgr の設定を無視するため、UEFI Shell から startup.nsh 経由で起動している。ブートエントリを書き換える方式が使えない。
  • NIC の PXE スタックが動かず、PXE ブートは非対応。ネットワーク越しの再インストールという選択肢は最初から無い。
  • 有線 LAN が2口(enp1s0 / enp2s0)と Wi-Fi(wlo1)があるが、固定 IP を持たせているのは enp1s0 だけ。もう1口は DHCP のままにしてある。

どれも致命的ではないが、共通しているのは「壊れたときにリモートから復旧する経路が細い」ということだ。ミニ PC を常時稼働させる構成では、物理的に前に座れることが前提になる場面が必ず来る。ここは最初に飲んだ。

ホスト常駐と Docker の線をどこで引いたか

全体はこうなっている。

CLIENT192.168.0.xDNS はサーバーを指す:53:443192.168.0.200Beelink EQ14 / Intel N150 / 16GB / Ubuntu 24.04NATIVE — systemddnsmasq :53名前解決caddy :80 :443TLS 終端・振り分けsmbd :445/srv/samba/shareapi :3000ダッシュボード APIreverse_proxyDOCKERdocker apps:3001-3999(自動採番)postgres :5432共通・1コンテナSTATE — ディスク上/srvレジストリ・証明書・共有~/gitアプリの実体/etc/dnsmasq.dDNS エントリ
ミニ PC 1台の中身。ポートを持つ基盤はホストの systemd、アプリは Docker、状態はディスク上の3か所に分かれている。

線引きは単純で、LAN 全体が依存するポートを占有するものはホストの systemd、それ以外は Docker に入れた。ホスト側に常駐しているのは4つだけ。名前解決の dnsmasq (:53)、HTTPS を終端する Caddy(:80 / :443)、ファイル共有の Samba(:445)、それに管理ダッシュボードの API(:3000)。

dnsmasq を入れる時点で1つ潰す作業がある。Ubuntu は systemd-resolved が :53 を握っているので、これを停止・無効化しないと dnsmasq が起動しない。初期セットアップのスクリプトでは、停止したあと /etc/resolv.conf を上流 DNS 直指しに書き換えてから dnsmasq を入れている。ここを飛ばすと、名前解決が止まった状態で apt を叩くことになって詰む。

dnsmasq の設定(実体は git 管理、/etc/dnsmasq.d へ symlink)
# services/dns/dnsmasq.conf(抜粋)

# *.home はすべてこのサーバーへ
address=/home/192.168.0.200

# .home 以外は上流へ転送する
server=8.8.8.8
server=8.8.4.4

# /etc/resolv.conf を読まない(ループ防止)
no-resolv
no-poll

cache-size=1000

no-resolv は「/etc/resolv.conf を読まない」という指定で、上流をこの設定ファイルに書いた2つに固定する。ループを避けるための指定だが、副作用としてLAN の端末の外部名前解決も全部このマシンを通ることになる。ここは後で効いてくる。

Caddy 側は、設定と状態の置き場所を最初に /srv へ寄せてある。/etc/caddy/Caddyfile は生成物を import する1行だけで、実体は /srv/caddy/Caddyfile。証明書などのランタイム状態も systemd の drop-in で /srv/caddy/data に向けている。

Caddy を /srv 配下に寄せる
# /etc/caddy/Caddyfile は生成物を import するだけ
import /srv/caddy/Caddyfile

# /etc/systemd/system/caddy.service.d/override.conf
# 証明書とランタイム状態を /srv 配下へ寄せる
[Service]
Environment=XDG_DATA_HOME=/srv/caddy/data
Environment=XDG_CONFIG_HOME=/srv/caddy/config

生成された Caddyfile の所有権は <設定を書くユーザー>:caddy664 にしてある。書き手と読み手を別ユーザーにして、グループ経由で読ませる形だ。設定を再生成するたびに root 権限が要る構成にすると、自動化のどこかで必ず手が止まる。

アプリ側は Docker に入れ、ポートは 3000-3999 の帯から自動採番する。1アプリ1 compose で、リバースプロキシから見れば全部「ローカルホストの何番か」でしかない。新規アプリ用の PostgreSQL は各アプリに持たせず、1コンテナだけ常駐させて相乗りさせている。16GB の箱で DB コンテナをアプリ本数ぶん立てると、そこが先に効くからだ。

ホスト常駐と Docker の線は、機能ではなく「それが落ちたときに自分がその箱へ入れなくなるか」で引くと迷わない。名前解決とリバースプロキシは、壊れた瞬間に復旧作業そのものの足場を失う側にある。

リクエスト1本が通る経路

ブラウザにホスト名を打ってからページが返るまでに通るものは、これで全部だ。

browserhttps://myapp.devdnsmasq :53→ 192.168.0.200caddy :443TLS 終端(内部 CA)名前解決SNI 付きドメイン一致で分岐file_server静的サイトreverse_proxylocalhost:3001
LAN の端末からアプリ1本に届くまで。応答は同じ経路を Caddy 経由で戻る。

端末側でやる設定は、DNS を 192.168.0.200 に向けることだけ。あとは名前解決がこのサーバーを返し、同じサーバーの :443 に SNI 付きで HTTPS が来て、Caddy が TLS を終端し、生成済みの Caddyfile でドメインが一致したブロックへ入る。静的サイトなら file_server、Docker アプリなら reverse_proxy localhost:<port> に落ちる。応答は同じ経路を戻る。

名前の付き方は2系統ある。.home は dnsmasq にワイルドカードを1本(address=/home/192.168.0.200)張ってあるので、何を引いてもこのサーバーに解決する。既定で使う <app>.dev のほうはワイルドカードを張らず、デプロイのたびに /etc/dnsmasq.d/nishioka-custom.conf へ1件ずつ登録し、削除時に1件消す。実在する TLD にワイルドカードを張らない理由は「LAN の中だけで動く HTTPS を組む」で書いたので、ここでは繰り返さない。

証明書は Caddy の内部 CA(local_certs)が発行する自己署名で、公的な CA は経由しない。つまり各端末はそのルート証明書を信頼する必要がある。その配布に使っているのが Samba の共有フォルダで、エクスポート用のスクリプトが証明書を /srv/samba/share/root-ca.crt にも置くようにしてある。ファイル共有が「ついでに置いてあるサービス」ではなく、HTTPS を成立させるための配布経路として構成に組み込まれているのは、この一点による。

状態がどこに置かれているか

1台構成でいちばん効くのは、この地図だと思っている。ディスク上の状態は3か所に分かれている。

サーバー側のディレクトリ
/srv/
├── nishioka/registry.json     # アプリのレジストリ(一覧・ダッシュボードの情報源)
├── caddy/Caddyfile            # 生成された設定
├── caddy/data/                # 内部 CA と発行済み証明書
├── caddy/config/
├── samba/share/               # 共有フォルダ
├── postgres/data/             # 共通 PostgreSQL のデータ
├── postgres/.env              # スーパーユーザーのパスワード(0600)
└── backups/

/etc/dnsmasq.d/nishioka-custom.conf   # 個別ドメインの DNS エントリ

~/git/<app>/                          # アプリの実体(リポジトリそのもの)
/opt/minipc -> ~/git/minipc           # systemd 等が参照するシンボリックリンク

/srv がサーバーとしての状態、~/git がアプリの実体(リポジトリをそのまま置いてある)、/etc/dnsmasq.d が LAN の名前解決。インフラ管理リポジトリだけは /opt/minipc からシンボリックリンクを張ってあり、systemd のユニットや設定ファイルの symlink は全部そちらを指している。ホームディレクトリのパスがユニットファイルに散らないようにするための1本だ。

/srv/nishioka/registry.json だけは性質が違う。どのアプリがどのパスにあり、どのポートとドメインを使っているかを持ったレジストリで、アプリ一覧も管理ダッシュボードも Caddyfile の生成もここから導出される。設定ファイルではなく導出元なので、これが実態とずれると、ずれた内容で他が作り直される。

作り直せる状態と、作り直せない状態

バックアップ対象を決める作業は、実際には状態を「作り直せるもの」と「作り直せないもの」に分類する作業だった。分類の結果がそのまま除外リストになっている。

バックアップが取るものと、取らないもの
# scripts/backup.sh(抜粋)

# 共通 PostgreSQL は物理ファイルではなく論理ダンプで取る
docker exec <pg-container> pg_dumpall -U "$PG_SUPERUSER" | gzip > "$PG_DUMP"
# ローカルは直近7世代だけ残す
ls -1t /srv/postgres/backups/pg_dumpall-*.sql.gz | tail -n +8 | xargs -r rm

tar czf "$BACKUP_FILE" \
    --exclude='/srv/backups' \
    --exclude='/srv/caddy/data' \
    --exclude='/srv/postgres/data' \
    /srv/nishioka \
    /srv/samba/share \
    /srv/postgres/registry.json \
    /srv/postgres/.env

除外している3つには、それぞれ別の理由がある。/srv/postgres/data は物理ファイルを固めても整合が取れないので、代わりに pg_dumpall の論理ダンプを取っている。/srv/backups はバックアップ自身なので入れると入れ子になる。そして /srv/caddy/data は、証明書は失っても内部 CA が再発行するから、という判断で外してある。

ただしこの3つ目には、はっきりした代償がある。除外している以上、復元後は内部 CA そのものが作り直される。各端末にインストールして信頼させたルート証明書は、そこで無効になる。復旧手順に「全端末に証明書を入れ直す」が含まれる、ということだ。復旧時間の見積もりに入れておかないと、サーバーが戻ったのにどの端末からも警告が出る、という状態から始めることになる。

もう1つ、ダンプの扱いで踏んだ小さな学びがコメントとして残っている。ローカルには直近7世代を残しているが、S3 には当日分だけを個別にアップロードしている。tarball に7日分を同梱すると、毎日同じダンプを7重に再アップロードすることになるからだ。世代管理とアップロード単位は別に考える必要がある。

アプリのコード(~/git 配下)はバックアップ対象に入れていない。正本は git の remote にある、という前提で構成しているからで、逆に言えばremote に push されていない変更はこのサーバーが飛んだ時点で消える。1台構成では、この線引きを自分で意識的に引いておくしかない。

1台に寄せた代償

ここまでを地図として見ると、代償がどこに集まっているかも見える。

いちばん大きいのは DNS だ。LAN の端末はこのサーバーを DNS に指定していて、しかも no-resolv で上流をこのマシンが持っている。つまりこの箱が落ちると、自宅のアプリが見えなくなるだけでなく、LAN 全体で外部ドメインの名前解決も止まる。アプリが落ちるのと家のネットが落ちるのが同じ事象になる、という設計を選んでいることになる。復旧のためにドキュメントを検索したい場面で、その検索ができない。

次に、リバースプロキシ・DNS・ファイル共有・全アプリが同じ電源と同じ SSD の上にある。LVM は入っているがディスクは1本なので、冗長性は無い。だから前節の「作り直せないものは何か」が実質的な保険のすべてになる。

1台構成に対して、例外を2つだけ作ってある。1つはビルドの実行ノードで、モバイルアプリのビルドのように別 OS が要るものは別マシンへ振っている。もう1つは外部公開で、トンネル経由で公開できるのは同時に1アプリまでという制約を仕組み側に入れた。1台に全部載っている以上、外へ開く口は数えられる状態に保っておきたい。

1台に集約する構成の怖さは、障害の確率ではなく障害の相関にある。落ちる確率は普通のサーバー1台と変わらないが、落ちたときに同時に失うものの数が違う。

まとめ

ミニ PC 1台の自宅サーバーは、要素そのものは平凡だ。dnsmasq と Caddy と Docker と Samba しか使っていない。効いているのは要素の選定ではなく、層をどこで切ったか状態をどこに集めたかのほうだった。

整理すると3つになる。ポートを占有して復旧の足場になるものはホストの systemd に置き、アプリはコンテナに入れる。状態は /srv に集め、そのうち何が作り直せて何が作り直せないかを先に分類しておく。そして1台に寄せた代償 —— この箱が落ちると LAN の名前解決ごと止まること —— を、把握したうえで受け入れる。

この地図が描けていると、個別の判断はほとんど自動的に決まる。どこにバックアップを向けるか、新しいアプリをどの層に置くか、何を CLI から触れるようにするか。設計の議論に見えているものの多くは、実際には地図がまだ描けていないだけのことが多い。

UbuntuDockerCaddydnsmasq自宅サーバー