
SvelteKitの環境変数でハマる理由:
devでは読めるのにbuild後のクライアントで反映されない原因と対策
SvelteKitの環境変数について、static / dynamic / public / private の違い、ビルド時に値が埋め込まれる仕組み、Docker Composeのenv_fileでクライアント側に反映されない理由を実践例つきで解説します。
はじめに:ローカルでは動いたのに
SvelteKitで開発していると、必ずといっていいほど一度はこの壁にぶつかります。 .env ファイルに値を書いてローカルで動作確認。問題なし。そのままvite build してDockerにデプロイしたら、 サーバー側では値が読めているのにクライアント(ブラウザ)側だけ反映されない。
⚠️ こんな症状が出たら、この記事が役に立ちます
・ローカル開発(bun run dev )では値が読めるのに、vite build 後に変わらない
・Docker Compose のenv_file に書いたのにブラウザ側で反映されない
・import.meta.env.VITE_XXX を使っていたら怒られた
原因は「環境変数がいつ・どこで読まれるか」の仕組みを理解していないことにあります。この記事では、SvelteKitの環境変数の仕組みを整理しながら、よくあるハマりポイントと正しい使い分けを解説します。
SvelteKitの環境変数は4種類ある
SvelteKitの環境変数モジュールは、 「いつ読むか(static / dynamic)」 と 「どこで使えるか(private / public)」 の2軸で4種類に分かれています。
$env/static/private
ビルド時
ビルド時に値が確定し、サーバー側のコードにのみ埋め込まれます。DBパスワードや秘密鍵などに使います。クライアントには公開されません。
$env/static/public
ビルド時
ビルド時に値が確定し、クライアント側にも公開できます。サイトのURLやAPIのベースURLなど「公開されても問題ない」値に使います。
$env/dynamic/private
実行時
コンテナ起動時など実行時のprocess.env を参照します。Dockerのenv_file を使いたいサーバー側の値はここ。クライアントには公開されません。
$env/dynamic/public
実行時
実行時の値をクライアントにも渡せます。ただしサーバーからクライアントへ値を送る処理が走るため、通常は$env/static/public で代替可能な場面がほとんどです。
| モジュール | 参照タイミング | クライアントで使える? | 主な用途 |
|---|---|---|---|
$env/static/private | ビルド時 | ✗ サーバーのみ | DB接続情報、秘密鍵 |
$env/static/public | ビルド時 | ✓ 公開可 | サイトURL、公開APIのURL |
$env/dynamic/private | 実行時 | ✗ サーバーのみ | Docker / 本番環境のシークレット |
$env/dynamic/public | 実行時 | △ 限定的に使用 | サイトURL、公開APIのURL |
✨ まず覚えること
static = ビルド時に値が確定する。dynamic = 実行時(起動時)に読み込む。 この違いが「Dockerで変えても反映されない」問題の根本原因になります。
public と private の違い:プレフィックスで判断する
SvelteKitは、.env に書かれた変数のプレフィックスを見て、公開するかどうかを自動的に判断します。
# PUBLIC_ が付いた変数 → ブラウザに公開される可能性がある
PUBLIC_SITE_URL=https://www.example.com
PUBLIC_API_BASE_URL=https://api.example.com
# PUBLIC_ がない変数 → サーバーのみで使える(クライアントからアクセス不可)
DB_HOST=localhost
DB_PASSWORD=super_secret
MYSQL_SOCKET=/run/mysqld/mysqld.sock クライアント側で使いたい値は$env/static/public から取得します。
import { PUBLIC_SITE_URL, PUBLIC_API_BASE_URL } from '$env/static/public';
// クライアントでもサーバーでも使える
export const siteUrl = PUBLIC_SITE_URL;
export const apiBaseUrl = PUBLIC_API_BASE_URL;⚠️ ️ 要注意:PUBLIC_ に入れてよいのは公開されても問題ない値だけ
PUBLIC_ が付いた変数は最終的にブラウザから見える可能性があります。DBパスワードや認証シークレットをPUBLIC_DB_PASSWORD のように書くのは厳禁です。
static の正体:値はビルド時にJSへ焼き込まれる
この記事のいちばん大事な話です。
$env/static/* の「static」とは、 「ビルド時(vite build の瞬間)に値が確定し、生成済みのJavaScriptファイルの中に直接埋め込まれる」 という意味です。
💡 何が起きているか
たとえばPUBLIC_SITE_URL=https://staging.example.com と書いてビルドすると、生成されたクライアントJSの中に"https://staging.example.com" という文字列が直接書き込まれます。もはや「環境変数を参照している」わけではなく、ただの文字列定数になっているのです。
これが、次のような問題を引き起こします。
🧪 ハマりパターン
# 1. ローカルで staging 向けにビルド
$ bun run build
→ この時点で .env の PUBLIC_SITE_URL が JS に埋め込まれる
# 2. build/ フォルダをステージングサーバーに配置
# 3. Docker Compose で env_file を指定して起動
$ docker compose up
→ env_file の値はサーバー(Node.js プロセス)には届く
→ しかし build/client/*.js はビルド済み = 値は変わらない
# クライアント側では古い値のまま... クライアントとサーバーで何が違うのか
ビルド〜実行の流れ
クライアント側(ブラウザ)
.env の値
→
vite build 時に埋め込み
→
build/client/*.js
→
ブラウザへ配信
⚠️ Docker 起動後に env_file を変えても、生成済み JS の中身は変わらない
サーバー側(Node.js)
Docker env_file
→
コンテナ起動時に読み込み
→
process.env
→
サーバー処理で使用
✅ 起動のたびに最新の env_file の値を参照できる
「サーバー側では読めるのにクライアントで反映されない」の正体は、まさにこの構造の違いです。
dynamic を使うと実行時に参照できる
では、DockerのコンテナをビルドせずにサーバーサイドのDB接続先などを切り替えたい場合はどうすればいいでしょうか。そこで使うのが$env/dynamic/private です。
import { env } from '$env/dynamic/private';
// Docker の env_file に書かれた値がここで読める
export const dbConfig = {
host: env.DB_HOST,
socket: env.MYSQL_SOCKET,
password: env.DB_PASSWORD,
};💡 dynamic/private はサーバー専用
$env/dynamic/private はサーバー側でのみインポートできます。クライアントコンポーネント(.svelte ファイルの script タグや+page.ts )でインポートしようとするとビルドエラーになります。+page.server.ts や+server.ts など、サーバー専用ファイルで使いましょう。
dynamic/public は使い所を選ぶ
$env/dynamic/public は実行時の値をクライアントにも渡せますが、SvelteKit公式ドキュメントでも「可能な限り$env/static/public を使うよう推奨する」と説明されています。実行時にクライアントへ値を送るためにリクエストごとに追加処理が走るからです。「デプロイ後に環境変数を変えてブラウザ側にも反映したい」という要件が出てきたときに初めて検討する選択肢です。
Docker Compose の env_file はどこまで効くか
services:
app:
image: my-sveltekit-app
env_file:
- ./app/.env.staging # ← コンテナ起動時の環境変数として適用 Docker Compose のenv_file は、 コンテナ起動時のprocess.env として効きます。 つまり:
| コード | env_file の値を読める? | 理由 |
|---|---|---|
$env/dynamic/private | ✓ 読める | 実行時の process.env を参照するため |
$env/dynamic/public | ✓ 読める(サーバー側) | 実行時の process.env を参照するため |
$env/static/private | ✗ 反映されない | ビルド時に埋め込まれているため |
$env/static/public (クライアント側) | ✗ 反映されない | ビルド時にJSへ焼き込まれているため |
⚠️ ️ よくある誤解
「env_file にPUBLIC_SITE_URL を書いておけばブラウザでも反映される」は誤りです。クライアント側のJSは、コンテナが起動するはるか前(ビルド時)にすでに値が決まっています。
よくあるハマりパターンと対処法
❓ ビルド後にクライアント側の PUBLIC_XXX が反映されない
原因:$env/static/public の値はビルド時に確定するため、ビルド後に.env を変更しても反映されません。 対処:値を変えたら再ビルドが必要です。 ステージングと本番で値が異なる場合は、.env.staging /.env.production を用意し、ビルドコマンドで切り替えます。
PUBLIC_SITE_URL=https://staging.example.com
PUBLIC_API_BASE_URL=https://api.staging.example.com
DB_HOST=staging-db.internal# --mode を指定すると .env.staging を読み込む
$ vite build --mode staging
# bun を使っている場合
$ bun run build --mode staging❌ $env/dynamic/private をクライアントコンポーネントでインポートしてエラーになる
原因:private 系のモジュールはサーバー側専用です。+page.svelte や+page.ts など、クライアントでも実行されるファイルではインポートできません。 対処:+page.server.ts や+server.ts (サーバー専用ファイル)でインポートし、load 関数の戻り値としてクライアントに必要な値だけ渡します。
import { env } from '$env/dynamic/private'; // ← サーバー専用ファイルならOK
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
// DB から取得した値など、クライアントに渡してよいものだけを返す
return {
origin: env.ORIGIN,
};
};❓ vite dev ではすべての変数が読めてしまう
ホットリロード中(vite dev )は SvelteKit が実行時に.env を読み込むため、static /dynamic の区別が曖昧になります。 「dev で動くからbuildでも動く」と思い込まないことが大事です。 ビルド後の挙動はvite preview や実際のDocker環境で必ず確認しましょう。
❌ PUBLIC_ をつけ忘れて $env/static/public でインポートしようとするとエラーになる
$env/static/public からインポートできるのはPUBLIC_ プレフィックスが付いた変数だけです。プレフィックスなしの変数は$env/static/private 側に属するため、クライアントコードからはアクセスできません。.env のキー名にPUBLIC_ を付け足してから再ビルドしてください。
実践例:ステージング環境でサイトURLを使う
ブログやECサイトでよくある「OGPのURL」「canonicalタグ」「APIの呼び出し先」を環境ごとに切り替えたいケースを例に、どのモジュールを使えばいいかを整理します。
# ブラウザに公開される値(PUBLIC_ 付き)
PUBLIC_SITE_URL=https://staging.example.com
PUBLIC_API_BASE_URL=https://api.staging.example.com
# サーバー側でのみ使う値
ORIGIN=https://staging.example.com
MYSQL_SOCKET=/run/mysqld/mysqld.sock
DB_PASSWORD=staging_secretクライアントでも使う公開値
import { PUBLIC_SITE_URL, PUBLIC_API_BASE_URL } from '$env/static/public';
// ビルド時に確定。クライアントでもサーバーでも使える。
export const siteUrl = PUBLIC_SITE_URL;
export const apiBaseUrl = PUBLIC_API_BASE_URL;<script>
import { siteUrl } from '$lib/config';
</script>
<svelte:head>
<link rel="canonical" href={siteUrl} />
<meta property="og:url" content={siteUrl} />
</svelte:head>サーバー側のDB接続・シークレット
import { env } from '$env/dynamic/private';
// Docker の env_file → コンテナ起動時に process.env 経由で読まれる
export const dbConfig = {
socket: env.MYSQL_SOCKET,
password: env.DB_PASSWORD,
};✨ ポイントまとめ
「ビルド時に決まってよい公開値」は$env/static/public 、「実行時に変えたいサーバー側の秘密値」は$env/dynamic/private 。 これが基本の組み合わせです。$env/static/public の値を変えたときは必ず再ビルドを忘れずに。
使い分けの結論:迷ったらこの表を見る
| やりたいこと | 使うモジュール | 再ビルド |
|---|---|---|
| ブラウザでサイトURLやOGPのURLを使いたい | $env/static/public | 値を変えたら必要 |
| ブラウザでAPIのベースURLを使いたい | $env/static/public | 値を変えたら必要 |
| サーバーでDB接続情報を使いたい | $env/dynamic/private | 不要(起動時に読む) |
| Docker の env_file で渡した値をサーバーで使いたい | $env/dynamic/private | 不要(起動時に読む) |
| APIキー・シークレットをサーバー側で使いたい | $env/dynamic/private | 不要(起動時に読む) |
| ビルド時に決まるサーバー専用の設定 | $env/static/private | 値を変えたら必要 |
| ビルド後にクライアント側の値を変えたい | 基本は再ビルドが正道。どうしても避けたいならAPIエンドポイントから公開設定を返す設計を検討する |
✨ ビルド後に値を変えたいときの代替パターン
クライアント側で実行時に変わる値が必要な場面では、サーバーAPIから公開設定を返す方法があります。ただし、このエンドポイントに絶対に秘密情報を含めないよう注意が必要です。
import { json } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export const GET = () => {
// 公開してよい値だけを返す。DB パスワードなどは絶対に含めない。
return json({
siteUrl: env.ORIGIN,
});
}; まとめ
今回学んだこと
- SvelteKitの環境変数は「static(ビルド時)/ dynamic(実行時)」×「private(サーバーのみ)/ public(クライアント可)」の4種類
$env/static/*の値はビルド時にJSへ焼き込まれる。Docker起動後にenv_file/.env.productionを分けて管理する$env/dynamic/privateはコンテナ起動時のprocess.envを参照する。DockerやCI/CDの設定を実行時に読みたいサーバー側の値はここを使う- クライアントで使う公開値の変更は「再ビルド」が基本。
.env.staging/.env.productionを分けて管理する vite devでは区別が曖昧になる。ビルド後の挙動はvite previewか実際のDocker環境で確認するPUBLIC_プレフィックスはブラウザから見える。秘密情報は絶対に入れない
💡
「いつ読むか」と「どこに公開されるか」が判断の軸
SvelteKitの環境変数でハマるほとんどの原因は、.env に書いたかどうかではなく、 「ビルド時に値を決めているのか」「実行時に値を読んでいるのか」 という認識のズレにあります。
この軸を意識するだけで、「devでは動くのにbuild後に反映されない」という謎が一瞬で解けるようになります。
static = ビルド時に確定
dynamic = 実行時に読む
private = サーバーのみ
public = ブラウザにも届く
再ビルド忘れに注意