Ubuntuのサーバ環境を整理して構築

複数のサービスを入れているとごちゃごちゃしてきたので、ここで一度整理します。
ベースは出来れば稼働サービスの少ないUbuntu Serverにし、VMやLXD、Dockerで管理するのが環境を綺麗に保てそうです。VMはメモリをガッツリ確保しますが、LXDやDockerは動的にリソースを使うので、、「GUIが必要なOSはVM」「ミドルウェアはLXD」「アプリはDocker」と分けるのが良さそうですね。
LXD-UIはVMも扱えますが、VM操作ならCockpitのほうが多機能で分かりやすいので、それぞれ次のように使い分けるのが扱いやすそうでしょうか。

ツール名主な管理対象強み・特徴主なメリット
CockpitVM (KVM/QEMU) / システム全体仮想マシンの作成、ISOマウント、コンソール操作が非常に直感的。リソース監視も可能で、サーバー全体の「総合ダッシュボード」に最適。
LXD-UILXD / LXC (コンテナ)Canonical公式に近い操作感。軽量なLinuxコンテナの素早い展開に特化。VMより低リソース。隔離されたCUI実験環境などの構築に最適。
DockhandDockerDocker特化型。Composeスタックの管理やイメージ更新が容易。Portainerよりも軽量な選択肢。Docker管理に特化したい場合に好相性。

Docker関連の設定ファイルも、ホームフォルダに配置すると扱いやすい反面、ごちゃごちゃしてしまうので、すべて/opt/docker/以下にフォルダで分けて配置することにします。そうすればバックアップも行いやすくなるはず。

/opt/docker/
├── web-app/                 # アプリA(例: Next.js / Rails など)
│   ├── compose.yaml         # Docker Compose 定義
│   └── data/                # ホスト側マウント用永続データ
│       ├── postgres/        # 例
│       └── uploads/
├── database/                # アプリB(例: PostgreSQL / MySQL)
│   ├── compose.yaml
│   └── conf/                # カスタム設定ファイル置き場
│       └── postgresql.conf
└── proxy/                   # アプリC(リバースプロキシ)
    ├── compose.yaml
    └── nginx.conf           # または conf.d/*.conf

あとはUbuntu Serverセットアップ直後のイメージで、各サービスをセットアップしていきます。

Tailscale

sudo apt update && sudo apt upgrade -y

sudo apt install curl 
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up

SSH

sudo apt install -y openssh-server

Cockpit

sudo apt install -y cockpit

# Ubuntu25.10で管理者権限変更出来るように
sudo update-alternatives --set sudo /usr/bin/sudo.ws

# 仮想マシンプラグイン
sudo apt install -y cockpit-machines

# ファイラーNavigaterプラグイン
sudo apt install -y git
git clone https://github.com/45Drives/cockpit-navigator.git
cd cockpit-navigator
git checkout v0.5.8
sudo apt install -y make
sudo make install
cd ..
rm -rf cockpit-navigator

# SambaをCockpitで
sudo apt install -y samba
wget https://github.com/45Drives/cockpit-file-sharing/releases/download/v4.5.3-4/cockpit-file-sharing_4.5.3-4jammy_all.deb
sudo apt install -y ./cockpit-file-sharing_4.5.3-4jammy_all.deb
rm cockpit-file-sharing_4.5.3-4jammy_all.deb

echo "完了!http://localhost:9090 にアクセスしてください"

LXD-UI

# LXDインストール
sudo snap install lxd --channel=latest/stable

# 初期化
sudo lxd init --minimal

# HTTPS APIを有効化
sudo lxc config set core.https_address :8443

# UI有効化
sudo snap set lxd ui.enable=true
sudo systemctl reload snap.lxd.daemon

# ユーザーをlxdグループに追加
sudo usermod -aG lxd $USER

echo "完了!ログアウト・再ログイン後に https://localhost:8443 にアクセスしてください"

Docker

# 必要パッケージのインストール
sudo apt update
sudo apt install -y ca-certificates gnupg

# GPGキー
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg

# aptリポジトリの追加(Ubuntu 25.x は noble を明示指定)
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  noble stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt update

# Docker Engineや関連ツールのインストール
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

# 起動確認
sudo systemctl status docker --no-pager

# Docker関連用ディレクトリを作成
sudo mkdir -p /opt/docker

# 所有者を「自分」に、グループを「docker」に変更
sudo chown -R $USER:docker /opt/docker

# 権限を設定(自分とグループが読み書きできるようにする)
sudo chmod -R 775 /opt/docker

# SGIDを設定(新しく作る子ディレクトリのグループを強制的に「docker」にする)
sudo chmod -R g+s /opt/docker

# 一般ユーザーへ権限追加
sudo usermod -aG docker $USER

# 動作確認(newgrp でグループを反映した上で実行)
echo "ログアウト・再ログイン後に以下を実行してください:"
echo "  docker run --rm hello-world"

Immich

# ディレクトリ作成
sudo mkdir -p /opt/docker/immich
sudo chown -R $USER:docker /opt/docker/immich
sudo chmod -R 775 /opt/docker/immich
cd /opt/docker/immich

# docker-compose.yml 作成
cat > docker-compose.yml << 'EOF'
name: immich

services:
  immich-server:
    container_name: immich_server
    image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
    volumes:
      - ${UPLOAD_LOCATION}:/data
      - /etc/localtime:/etc/localtime:ro
    env_file:
      - .env
    ports:
      - '2283:2283'
    depends_on:
      - redis
      - database
    restart: always
    healthcheck:
      disable: false

  immich-machine-learning:
    container_name: immich_machine_learning
    image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
    volumes:
      - model-cache:/cache
    env_file:
      - .env
    restart: always
    healthcheck:
      disable: false

  redis:
    container_name: immich_redis
    image: docker.io/redis:6.2-alpine
    healthcheck:
      test: redis-cli ping || exit 1
    restart: always

  database:
    container_name: immich_postgres
    image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_USER: ${DB_USERNAME}
      POSTGRES_DB: ${DB_DATABASE_NAME}
    volumes:
      - ${DB_DATA_LOCATION}:/var/lib/postgresql/data
    healthcheck:
      test: pg_isready --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1
      interval: 5m
      start_interval: 30s
      start_period: 5m
    restart: always

volumes:
  model-cache:
EOF

# .env 作成
cat > .env << 'EOF'
UPLOAD_LOCATION=./library
DB_DATA_LOCATION=./postgres
IMMICH_VERSION=release
DB_PASSWORD=postgres
DB_USERNAME=postgres
DB_DATABASE_NAME=immich
EOF

# コンテナ起動
docker compose up -d

echo "完了!http://localhost:2283 にアクセスしてセットアップしてください"

Trilium Notes

mkdir -p /opt/docker/trilium && cat > /opt/docker/trilium/docker-compose.yml << 'EOF'
services:
  trilium:
    image: triliumnext/trilium:latest
    container_name: trilium
    restart: unless-stopped
    ports:
      - "7070:8080"
    volumes:
      - /opt/docker/trilium/trilium-data:/home/node/trilium-data
EOF

cd /opt/docker/trilium && docker compose up -d

echo "完了!http://localhost:7070 にアクセスしてセットアップしてください"

Nextcloud & Onlyoffice

#!/bin/bash
set -e

# === ホスト名を入力 ===
read -p "ホスト名またはIPアドレスを入力してください: " HOST_NAME

# === JWT シークレット(変更推奨) ===
JWT_SECRET="my_stable_secret_2026"

# === 共有ネットワーク作成(既存なら再利用) ===
docker network inspect onlyoffice_net >/dev/null 2>&1 \
  || docker network create onlyoffice_net
echo "[1/2] ネットワーク onlyoffice_net を確認しました"

# ============================================================
# Nextcloud
# ============================================================
sudo mkdir -p /opt/docker/nextcloud/{data,db}
sudo chown -R $USER:$USER /opt/docker/nextcloud
cd /opt/docker/nextcloud

# container_name を固定 → OnlyOffice から "nextcloud-app" で名前解決できる
cat > docker-compose.yaml << EOF
services:

  db:
    image: mariadb:10.6
    container_name: nextcloud-db
    restart: always
    command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW
    volumes:
      - ./db:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_PASSWORD: nextcloudpass
      MYSQL_DATABASE: nextcloud
      MYSQL_USER: nextcloud
    networks:
      - default

  app:
    image: nextcloud:latest
    container_name: nextcloud-app
    restart: always
    ports:
      - "8080:80"
    depends_on:
      - db
    volumes:
      - ./data:/var/www/html
    environment:
      MYSQL_PASSWORD: nextcloudpass
      MYSQL_DATABASE: nextcloud
      MYSQL_USER: nextcloud
      MYSQL_HOST: nextcloud-db
      NEXTCLOUD_TRUSTED_DOMAINS: "${HOST_NAME}:8080 nextcloud-app localhost"
      OVERWRITEHOST: "${HOST_NAME}:8080"
      OVERWRITEPROTOCOL: http
    networks:
      - default
      - onlyoffice_net

networks:
  default:
  onlyoffice_net:
    external: true
EOF

docker compose up -d
echo "[1/2] Nextcloud 起動完了"

# ============================================================
# OnlyOffice
# ============================================================
sudo mkdir -p /opt/docker/onlyoffice/{logs,data,lib,db}
sudo chown -R $USER:$USER /opt/docker/onlyoffice
cd /opt/docker/onlyoffice

# .env に JWT_SECRET を書き出す
# → ヒアドキュメントを 'EOF' にしても変数が届かない問題を回避
cat > .env << EOF
JWT_SECRET=${JWT_SECRET}
EOF

# compose 本体はシングルクォートヒアドキュメント(変数展開なし)
cat > docker-compose.yaml << 'EOF'
services:

  onlyoffice-docs:
    image: onlyoffice/documentserver:latest
    container_name: onlyoffice-docs
    restart: always
    stdin_open: true
    tty: true
    ports:
      - "9000:80"
    environment:
      JWT_ENABLED: "true"
      JWT_SECRET: "${JWT_SECRET}"   # .env から自動読み込み
    volumes:
      - /opt/docker/onlyoffice/logs:/var/log/onlyoffice
      - /opt/docker/onlyoffice/data:/var/www/onlyoffice/Data
      - /opt/docker/onlyoffice/lib:/var/lib/onlyoffice
      - /opt/docker/onlyoffice/db:/var/lib/postgresql
    networks:
      - onlyoffice_net

networks:
  onlyoffice_net:
    external: true
EOF

docker compose up -d
echo "[2/2] OnlyOffice 起動完了"

# ============================================================
# 完了メッセージ
# ============================================================
echo ""
echo "======================================"
echo "  起動完了"
echo "======================================"
echo ""
echo "  Nextcloud  : http://${HOST_NAME}:8080"
echo "  OnlyOffice : http://${HOST_NAME}:9000"
echo ""
echo "======================================"
echo "  Nextcloud 管理画面 → ONLYOFFICE 設定"
echo "======================================"
echo ""
echo "  ドキュメントサービスのアドレス(ブラウザ用):"
echo "    http://${HOST_NAME}:9000"
echo ""
echo "  認証ヘッダー:(空白のまま)"
echo ""
echo "  JWT シークレット: ${JWT_SECRET}"
echo ""
echo "  ドキュメントサービスの内部アドレス(サーバー間):"
echo "    http://onlyoffice-docs"
echo ""
echo "  Nextcloud サーバーの内部アドレス(コールバック用):"
echo "    http://nextcloud-app"
echo ""
echo "======================================"
echo "  個別アップデート手順"
echo "======================================"
echo ""
echo "  Nextcloud のみ更新:"
echo "    cd /opt/docker/nextcloud && docker compose pull && docker compose up -d"
echo ""
echo "  OnlyOffice のみ更新:"
echo "    cd /opt/docker/onlyoffice && docker compose pull && docker compose up -d"
echo ""

FleshRSS

mkdir -p /opt/docker/freshrss && cat > /opt/docker/docker-compose.yml << 'EOF'
version: '3'
services:
  freshrss:
    image: freshrss/freshrss:latest
    container_name: freshrss
    restart: unless-stopped
    ports:
      - "6060:80"
    volumes:
      - freshrss_data:/var/www/FreshRSS/data
      - freshrss_extensions:/var/www/FreshRSS/extensions
    environment:
      TZ: Asia/Tokyo
      CRON_MIN: '*/15'

volumes:
  freshrss_data:
  freshrss_extensions:
EOF
cd /opt/docker/freshrss && docker compose up -d && sleep 10 && \
sudo apt install -y unzip && \
curl -L "https://framagit.org/nicofrand/xextension-threepanesview/-/archive/master/xextension-threepanesview-master.zip" -o tpv.zip && \
unzip tpv.zip && \
docker cp xextension-threepanesview-master freshrss:/var/www/FreshRSS/extensions/xExtension-ThreePanesView && \
rm -rf tpv.zip xextension-threepanesview-master && \
curl -L https://github.com/Niehztog/freshrss-af-readability/archive/refs/heads/master.zip -o af.zip && \
unzip af.zip && \
docker cp freshrss-af-readability-master freshrss:/var/www/FreshRSS/extensions/xExtension-af_readability && \
rm -rf af.zip freshrss-af-readability-master && \
echo "完了!http://localhost:6060 にアクセスしてセットアップしてください"

CSS

#stream {
  width: 280px !important;
  min-width: 280px !important;
}

#aside_feed {
  width: 200px !important;
  min-width: 200px !important;
}

#current-article {
  flex: 1 !important;
  width: auto !important;
}

#stream .flux .flux_header .item .date,
#stream .flux .flux_header .date {
  display: none !important;
}

#stream .flux .flux_header .item .title,
#stream .flux .flux_header .item .title:has(~ .date) {
  position: relative !important;
  padding-right: 0 !important;
  max-width: 100% !important;
  width: 100% !important;
}

#stream .flux .websitename .item.website,
#stream .flux .item.website {
  width: 32px !important;
  min-width: 32px !important;
  max-width: 32px !important;
  overflow: hidden !important;
  padding: 0 !important;
}

#stream .flux .flux_header .item.website .item-element {
  padding: 0 6px !important;
}

#stream .flux .flux_header .websiteName {
  display: none !important;
}

#stream .flux .flux_header .item.titleAuthorSummaryDate {
  width: auto !important;
  max-width: 100% !important;
}

Dockhand

# ディレクトリ作成&yml編集
mkdir -p /opt/docker/dockhand && cat > /opt/docker/dockhand/docker-compose.yml << 'EOF'
services:
  dockhand:
    image: fnsys/dockhand:latest
    container_name: dockhand
    restart: unless-stopped
    ports:
      - 3000:3000
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - dockhand_data:/app/data
volumes:
  dockhand_data:
EOF

# 起動
cd /opt/docker/dockhand && docker compose up -d

echo "完了!http://localhost:3000 にアクセスしてセットアップしてください"

Nginx Proxy Manager (NPM)

mkdir -p /opt/docker/npm && cat > /opt/docker/npm/docker-compose.yml << 'EOF'
services:
  app:
    image: 'jc21/nginx-proxy-manager:latest'
    restart: unless-stopped
    ports:
      - '80:80'
      - '443:443'
      - '81:81'
    volumes:
      - ./data:/data
      - ./letsencrypt:/etc/letsencrypt
EOF

cd /opt/docker/npm/ && docker compose up -d

ポータルHTML生成

多数のサービスを入れているとアクセスが分散するので、ポータルのようなHTMLも作成してみました。
パスワードはソースで見えるので気休め程度です。あくまでもLAN内やTailscale内限定のホームサーバという位置付けで。もし外部に公開する場合は修正が必要です。

sudo mkdir -p /opt/docker/npm/data/html
sudo nano /opt/docker/npm/data/html/index.html

HTML

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Home Server Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Noto+Sans+JP:wght@300;400;500&display=swap" rel="stylesheet">
<style>
  /* =====================================================
     CSS変数 - ここを編集してテーマを変更
     ===================================================== */
  :root {
    --bg:        #0d0f14;
    --surface:   #151820;
    --border:    #252a35;
    --accent:    #4af0a0;
    --accent2:   #4ab4f0;
    --text:      #c8d0e0;
    --text-dim:  #5a6278;
    --text-head: #e8edf8;
    --danger:    #f04a6a;
    --font-mono: 'Space Mono', monospace;
    --font-body: 'Noto Sans JP', sans-serif;
    --radius:    6px;
    --card-w:    280px;
  }

  /* =====================================================
     リセット&ベース
     ===================================================== */
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  html { scroll-behavior: smooth; }

  body {
    background: var(--bg);
    color: var(--text);
    font-family: var(--font-body);
    min-height: 100vh;
    overflow-x: hidden;
  }

  /* =====================================================
     ロック画面
     ===================================================== */
  #lockscreen {
    position: fixed; inset: 0;
    background: var(--bg);
    display: flex; align-items: center; justify-content: center;
    z-index: 9999;
    transition: opacity .5s, visibility .5s;
  }
  #lockscreen.hidden { opacity: 0; visibility: hidden; pointer-events: none; }

  .lock-box {
    border: 1px solid var(--border);
    background: var(--surface);
    padding: 3rem 2.5rem;
    width: min(400px, 90vw);
    position: relative;
    overflow: hidden;
  }
  .lock-box::before {
    content: '';
    position: absolute; top: 0; left: 0; right: 0; height: 2px;
    background: linear-gradient(90deg, transparent, var(--accent), transparent);
  }

  .lock-title {
    font-family: var(--font-mono);
    font-size: .75rem;
    letter-spacing: .2em;
    color: var(--accent);
    text-transform: uppercase;
    margin-bottom: 1.5rem;
  }
  .lock-sub {
    font-size: .85rem;
    color: var(--text-dim);
    margin-bottom: 2rem;
    line-height: 1.6;
  }

  .lock-input-wrap { position: relative; margin-bottom: 1rem; }
  .lock-input-wrap input {
    width: 100%;
    background: var(--bg);
    border: 1px solid var(--border);
    color: var(--text-head);
    font-family: var(--font-mono);
    font-size: 1rem;
    padding: .75rem 1rem;
    outline: none;
    transition: border-color .2s;
    border-radius: var(--radius);
    letter-spacing: .1em;
  }
  .lock-input-wrap input:focus { border-color: var(--accent); }

  .lock-btn {
    width: 100%;
    background: transparent;
    border: 1px solid var(--accent);
    color: var(--accent);
    font-family: var(--font-mono);
    font-size: .85rem;
    letter-spacing: .15em;
    padding: .75rem;
    cursor: pointer;
    text-transform: uppercase;
    transition: background .2s, color .2s;
    border-radius: var(--radius);
  }
  .lock-btn:hover { background: var(--accent); color: var(--bg); }

  .lock-err {
    color: var(--danger);
    font-size: .8rem;
    font-family: var(--font-mono);
    margin-top: .75rem;
    min-height: 1.2em;
  }

  /* =====================================================
     グリッド背景
     ===================================================== */
  body::before {
    content: '';
    position: fixed; inset: 0;
    background-image:
      linear-gradient(var(--border) 1px, transparent 1px),
      linear-gradient(90deg, var(--border) 1px, transparent 1px);
    background-size: 40px 40px;
    opacity: .3;
    pointer-events: none;
    z-index: 0;
  }

  /* =====================================================
     ヘッダー
     ===================================================== */
  header {
    position: relative; z-index: 1;
    padding: 2.5rem 2rem 1.5rem;
    border-bottom: 1px solid var(--border);
    display: flex; align-items: flex-end; justify-content: space-between;
    flex-wrap: wrap; gap: 1rem;
    background: linear-gradient(180deg, rgba(74,240,160,.04) 0%, transparent 100%);
  }

  .header-left {}
  .logo-line {
    display: flex; align-items: center; gap: .75rem;
    margin-bottom: .4rem;
  }
  .logo-dot {
    width: 8px; height: 8px;
    background: var(--accent);
    border-radius: 50%;
    box-shadow: 0 0 12px var(--accent);
    animation: pulse 2s infinite;
  }
  @keyframes pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: .4; }
  }

  h1 {
    font-family: var(--font-mono);
    font-size: 1.5rem;
    color: var(--text-head);
    letter-spacing: .05em;
  }
  .header-host {
    font-family: var(--font-mono);
    font-size: .8rem;
    color: var(--text-dim);
    letter-spacing: .1em;
  }
  .header-host span { color: var(--accent2); }

  .header-right {
    display: flex; align-items: center; gap: 1rem;
  }
  .header-time {
    font-family: var(--font-mono);
    font-size: .75rem;
    color: var(--text-dim);
    text-align: right;
    line-height: 1.6;
  }
  .header-time .time { color: var(--text-head); font-size: 1.1rem; }

  /* =====================================================
     セクション
     ===================================================== */
  main {
    position: relative; z-index: 1;
    padding: 2rem;
    max-width: 1400px;
    margin: 0 auto;
  }

  .section { margin-bottom: 2.5rem; }
  .section-label {
    font-family: var(--font-mono);
    font-size: .7rem;
    letter-spacing: .25em;
    color: var(--text-dim);
    text-transform: uppercase;
    margin-bottom: 1rem;
    display: flex; align-items: center; gap: .75rem;
  }
  .section-label::after {
    content: '';
    flex: 1;
    height: 1px;
    background: var(--border);
  }

  /* =====================================================
     カードグリッド
     ===================================================== */
  .cards {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(var(--card-w), 1fr));
    gap: 1rem;
  }

  /* =====================================================
     カード
     ===================================================== */
  .card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    overflow: hidden;
    text-decoration: none;
    display: block;
    transition: border-color .2s, transform .2s, box-shadow .2s;
    position: relative;
    cursor: pointer;
  }
  .card::before {
    content: '';
    position: absolute; top: 0; left: 0; right: 0; height: 2px;
    background: var(--card-accent, var(--accent));
    opacity: 0;
    transition: opacity .2s;
  }
  .card:hover {
    border-color: var(--card-accent, var(--accent));
    transform: translateY(-2px);
    box-shadow: 0 8px 24px rgba(0,0,0,.4);
  }
  .card:hover::before { opacity: 1; }

  .card-inner { padding: 1.25rem 1.25rem 1rem; }

  .card-header {
    display: flex; align-items: center; gap: .75rem;
    margin-bottom: .75rem;
  }
  .card-icon {
    width: 36px; height: 36px;
    border-radius: 8px;
    display: flex; align-items: center; justify-content: center;
    font-size: 1.1rem;
    background: rgba(255,255,255,.04);
    border: 1px solid var(--border);
    flex-shrink: 0;
  }
  .card-name {
    font-weight: 500;
    color: var(--text-head);
    font-size: .95rem;
  }
  .card-badge {
    margin-left: auto;
    font-family: var(--font-mono);
    font-size: .6rem;
    padding: .2em .5em;
    border-radius: 3px;
    letter-spacing: .05em;
    background: rgba(255,255,255,.05);
    color: var(--text-dim);
    border: 1px solid var(--border);
    flex-shrink: 0;
  }

  .card-desc {
    font-size: .8rem;
    color: var(--text-dim);
    line-height: 1.5;
    margin-bottom: .75rem;
  }

  .card-url {
    font-family: var(--font-mono);
    font-size: .7rem;
    color: var(--card-accent, var(--accent));
    opacity: .7;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    display: block;
    padding-top: .75rem;
    border-top: 1px solid var(--border);
  }

  /* カラーバリエーション */
  .c-green  { --card-accent: #4af0a0; }
  .c-blue   { --card-accent: #4ab4f0; }
  .c-purple { --card-accent: #a07af0; }
  .c-orange { --card-accent: #f0a04a; }
  .c-pink   { --card-accent: #f04ab4; }
  .c-cyan   { --card-accent: #4af0f0; }
  .c-yellow { --card-accent: #f0e04a; }
  .c-red    { --card-accent: #f04a6a; }

  /* =====================================================
     フッター
     ===================================================== */
  footer {
    position: relative; z-index: 1;
    text-align: center;
    padding: 1.5rem;
    border-top: 1px solid var(--border);
    font-family: var(--font-mono);
    font-size: .7rem;
    color: var(--text-dim);
    letter-spacing: .1em;
  }

  /* =====================================================
     レスポンシブ
     ===================================================== */
  @media (max-width: 600px) {
    h1 { font-size: 1.1rem; }
    main { padding: 1rem; }
  }
</style>
</head>
<body>

<!-- =====================================================
     ロック画面
     ===================================================== -->
<div id="lockscreen">
  <div class="lock-box">
    <div class="lock-title">// authentication required</div>
    <div class="lock-sub">このポータルはパスワードで保護されています。<br>一度認証すると、次回からは不要です。</div>
    <div class="lock-input-wrap">
      <input type="password" id="pw-input" placeholder="パスワードを入力..." autocomplete="current-password">
    </div>
    <button class="lock-btn" onclick="tryUnlock()">→ アクセス</button>
    <div class="lock-err" id="pw-err"></div>
  </div>
</div>

<!-- =====================================================
     ヘッダー
     ===================================================== -->
<header>
  <div class="header-left">
    <div class="logo-line">
      <div class="logo-dot"></div>
      <h1>HOME SERVER</h1>
    </div>
    <div class="header-host">HOST <span id="disp-host">検出中...</span></div>
  </div>
  <div class="header-right">
    <div class="header-time">
      <div class="time" id="clock">--:--:--</div>
      <div id="datestr">----/--/--</div>
    </div>
  </div>
</header>

<!-- =====================================================
     メインコンテンツ
     ===================================================== -->
<main>

  <!-- ── ファイル / ナレッジ ── -->
  <section class="section">
    <div class="section-label">ファイル &amp; ナレッジ</div>
    <div class="cards">

      <a class="card c-green" id="card-trilium" href="#" target="_blank" rel="noopener">
        <div class="card-inner">
          <div class="card-header">
            <div class="card-icon">📝</div>
            <span class="card-name">Trilium Notes</span>
            <span class="card-badge">HTTPS</span>
          </div>
          <div class="card-desc">階層型ノート管理。個人ウィキ・メモ・日記に。</div>
          <span class="card-url" id="url-trilium">https://hostname:7070</span>
        </div>
      </a>

      <a class="card c-blue" id="card-nextcloud" href="#" target="_blank" rel="noopener">
        <div class="card-inner">
          <div class="card-header">
            <div class="card-icon">☁️</div>
            <span class="card-name">Nextcloud</span>
            <span class="card-badge">HTTPS</span>
          </div>
          <div class="card-desc">セルフホスト型クラウドストレージ。ファイル共有・カレンダー・連絡先。</div>
          <span class="card-url" id="url-nextcloud">https://hostname:8080</span>
        </div>
      </a>

      <a class="card c-cyan" id="card-immich" href="#" target="_blank" rel="noopener">
        <div class="card-inner">
          <div class="card-header">
            <div class="card-icon">📷</div>
            <span class="card-name">Immich</span>
            <span class="card-badge">HTTPS</span>
          </div>
          <div class="card-desc">高性能フォト管理。Google Photos代替。AI顔認識・自動分類。</div>
          <span class="card-url" id="url-immich">https://hostname:2283</span>
        </div>
      </a>

      <a class="card c-orange" id="card-freshrss" href="#" target="_blank" rel="noopener">
        <div class="card-inner">
          <div class="card-header">
            <div class="card-icon">📡</div>
            <span class="card-name">FreshRSS</span>
            <span class="card-badge">HTTPS</span>
          </div>
          <div class="card-desc">セルフホストRSSリーダー。複数フィード一括管理。</div>
          <span class="card-url" id="url-freshrss">https://hostname:5050</span>
        </div>
      </a>

    </div>
  </section>

  <!-- ── テレビ / メディア ── -->
  <section class="section">
    <div class="section-label">テレビ &amp; メディア</div>
    <div class="cards">

      <a class="card c-purple" href="https://my.local.konomi.tv:7000/tv/" target="_blank" rel="noopener">
        <div class="card-inner">
          <div class="card-header">
            <div class="card-icon">📺</div>
            <span class="card-name">KonomiTV</span>
            <span class="card-badge">HTTPS</span>
          </div>
          <div class="card-desc">地上波・BS・CSをブラウザでライブ視聴。録画再生にも対応。</div>
          <span class="card-url">https://my.local.konomi.tv:7000/tv/</span>
        </div>
      </a>

      <a class="card c-pink" id="card-edcb" href="#" target="_blank" rel="noopener">
        <div class="card-inner">
          <div class="card-header">
            <div class="card-icon">⏺️</div>
            <span class="card-name">EDCB</span>
            <span class="card-badge">HTTP</span>
          </div>
          <div class="card-desc">EPGベース録画予約システム。番組表から直接予約。</div>
          <span class="card-url" id="url-edcb">http://hostname:5510/</span>
        </div>
      </a>

      <a class="card c-pink" id="card-edcb-epg" href="#" target="_blank" rel="noopener">
        <div class="card-inner">
          <div class="card-header">
            <div class="card-icon">📅</div>
            <span class="card-name">EDCB-EPG</span>
            <span class="card-badge">HTTP</span>
          </div>
          <div class="card-desc">EDCB番組表ビュー。週間EPGを見やすく表示。</div>
          <span class="card-url" id="url-edcb-epg">http://hostname:5510/EMWUI/epg.html</span>
        </div>
      </a>

    </div>
  </section>

  <!-- ── インフラ管理 ── -->
  <section class="section">
    <div class="section-label">インフラ管理</div>
    <div class="cards">

      <a class="card c-yellow" id="card-cockpit" href="#" target="_blank" rel="noopener">
        <div class="card-inner">
          <div class="card-header">
            <div class="card-icon">🖥️</div>
            <span class="card-name">Cockpit</span>
            <span class="card-badge">HTTP</span>
          </div>
          <div class="card-desc">Linux系統管理WebUI。CPU・メモリ・ストレージをリアルタイム監視。</div>
          <span class="card-url" id="url-cockpit">http://hostname:9090</span>
        </div>
      </a>

      <a class="card c-blue" id="card-lxd" href="#" target="_blank" rel="noopener">
        <div class="card-inner">
          <div class="card-header">
            <div class="card-icon">📦</div>
            <span class="card-name">LXD-UI</span>
            <span class="card-badge">HTTPS</span>
          </div>
          <div class="card-desc">LXDコンテナ・VM管理WebUI。インスタンスの作成・管理。</div>
          <span class="card-url" id="url-lxd">https://hostname:8443</span>
        </div>
      </a>

      <a class="card c-green" id="card-dockhand" href="#" target="_blank" rel="noopener">
        <div class="card-inner">
          <div class="card-header">
            <div class="card-icon">🐳</div>
            <span class="card-name">Dockhand</span>
            <span class="card-badge">HTTPS</span>
          </div>
          <div class="card-desc">Dockerコンテナ管理UI。コンテナの起動・停止・ログ確認。</div>
          <span class="card-url" id="url-dockhand">https://hostname:3000</span>
        </div>
      </a>

      <a class="card c-orange" id="card-npm" href="#" target="_blank" rel="noopener">
        <div class="card-inner">
          <div class="card-header">
            <div class="card-icon">🔀</div>
            <span class="card-name">Nginx Proxy Manager</span>
            <span class="card-badge">HTTP</span>
          </div>
          <div class="card-desc">リバースプロキシ管理。SSL証明書の自動取得・ホスト設定。</div>
          <span class="card-url" id="url-npm">http://hostname:81</span>
        </div>
      </a>

    </div>
  </section>

</main>

<footer>
  HOME SERVER PORTAL &nbsp;·&nbsp; powered by Nginx Proxy Manager
  &nbsp;·&nbsp; <span id="footer-host"></span>
</footer>

<!-- =====================================================
     JavaScript
     ===================================================== -->
<script>
/* =====================================================
   設定 - ここを編集してカスタマイズ
   ===================================================== */
const CONFIG = {
  // ポータルのパスワード(変更してください)
  password: "homeserver2026",

  // localStorageのキー(変更不要)
  storageKey: "portal_auth_v1",

  // ポートが空なら自動検出したホスト名を使う
  // falseにすると window.location.hostname を使う
  autoDetectHost: true,

  // ホスト名を手動で上書きしたい場合はここに書く(空文字で自動)
  manualHost: "",
};

/* =====================================================
   ホスト名検出
   ===================================================== */
const hostname = CONFIG.manualHost || window.location.hostname;

// 表示
document.getElementById("disp-host").textContent = hostname;
document.getElementById("footer-host").textContent = hostname;

// URL書き換え関数
function buildUrl(proto, port, path = "/") {
  return `${proto}://${hostname}:${port}${path}`;
}

// 各カードのURLを差し込む
const urlMap = {
  "card-trilium":   { el: "url-trilium",   href: buildUrl("http", 7070) },
  "card-immich":    { el: "url-immich",     href: buildUrl("http", 2283) },
  "card-nextcloud": { el: "url-nextcloud",  href: buildUrl("http", 8080) },
  "card-freshrss":  { el: "url-freshrss",   href: buildUrl("http", 6060) },
  "card-edcb":      { el: "url-edcb",       href: buildUrl("http",  5510) },
  "card-edcb-epg":  { el: "url-edcb-epg",  href: buildUrl("http",  5510, "/EMWUI/epg.html") },
  "card-cockpit":   { el: "url-cockpit",    href: buildUrl("http",  9090) },
  "card-lxd":       { el: "url-lxd",        href: buildUrl("https", 8443) },
  "card-dockhand":  { el: "url-dockhand",   href: buildUrl("http", 3000) },
  "card-npm":       { el: "url-npm",        href: buildUrl("http",  81) },
};

Object.entries(urlMap).forEach(([cardId, { el, href }]) => {
  const card = document.getElementById(cardId);
  const urlEl = document.getElementById(el);
  if (card) card.href = href;
  if (urlEl) urlEl.textContent = href;
});

/* =====================================================
   時計
   ===================================================== */
function updateClock() {
  const now = new Date();
  document.getElementById("clock").textContent =
    now.toLocaleTimeString("ja-JP");
  document.getElementById("datestr").textContent =
    now.toLocaleDateString("ja-JP", { year: "numeric", month: "2-digit", day: "2-digit", weekday: "short" });
}
updateClock();
setInterval(updateClock, 1000);

/* =====================================================
   ロック画面
   ===================================================== */
function tryUnlock() {
  const val = document.getElementById("pw-input").value;
  if (val === CONFIG.password) {
    localStorage.setItem(CONFIG.storageKey, "1");
    document.getElementById("lockscreen").classList.add("hidden");
  } else {
    const err = document.getElementById("pw-err");
    err.textContent = "× パスワードが違います";
    setTimeout(() => err.textContent = "", 2000);
    document.getElementById("pw-input").value = "";
    document.getElementById("pw-input").focus();
  }
}

// Enterキー対応
document.getElementById("pw-input").addEventListener("keydown", e => {
  if (e.key === "Enter") tryUnlock();
});

// 認証済みチェック
if (localStorage.getItem(CONFIG.storageKey) === "1") {
  document.getElementById("lockscreen").classList.add("hidden");
}
</script>

</body>
</html>

HTMLを保存したらNPMでHTMLを表示する設定を追加。

http://サーバーIP:81 にアクセス → Proxy Hosts → Add Proxy Host

項目
Domain Namesサーバーのホスト名
Schemehttp
Forward Hostname127.0.0.1
Forward Port80

歯車マークを押し下記を貼り付けて保存すれば完了です。

location / {
    root /data/html;
    index index.html;
}

HTMLの修正箇所は見ると分かりますが、このあたりやカードURLなど。

const CONFIG = {
  password: "homeserver2026",  // ← パスワード変更
  manualHost: "",              // ← 空=自動検出 / "192.168.1.10"で固定も可
};