はじめに
ローカル開発や検証環境で「Nginxを入口にして、バックエンド(GoやNode)を裏で動かす」構成を、毎回手作業で用意するのは地味に面倒です。しかも、環境差(OS、パッケージ、ポート競合、設定ファイルの置き場所)で再現性が崩れると、検証そのものが目的化してしまいます。
そこで役に立つのが Docker Compose です。Nginxとアプリ(Go/Node)をそれぞれコンテナとして分離し、ネットワーク越しに接続するだけで、「入口 → ルーティング → アプリ」の流れをいつでも同じ形で再現できます。これができると、個人開発でもチーム開発でも次のメリットが大きいです。
- 誰が実行しても同じ挙動(設定や依存がコンテナに閉じる)
- Nginx設定を触ってすぐ確認(ホストの環境を汚さない)
- Go/Nodeの差し替えが簡単(裏側を入れ替えて検証できる)
- “入口の設計”を学びやすい(実務の構成に近い)
この記事では、Compose一発で起動できるように「静的配信 + APIプロキシ」を最小構成で作り、さらに「Go版」「Node版」を切り替えられる形にします。入門記事の延長としても、実務の雰囲気を掴む入口としても使える構成です。
座学
1) “Nginxを入口にする”構成の考え方
アプリを直接外部公開すると、静的配信(HTML/CSS/JS)とAPI(JSON)を同一プロセスで捌くことになりがちです。小規模ならそれでも動きますが、現場では次の理由で入口を分けることが多いです。
- 静的ファイルは高速に配る役(Nginxが得意)
- APIはアプリが集中して処理する役(Go/Nodeなど)
- 設定で振り分けたい(
/apiはアプリ、/は静的) - 将来の拡張に強い(APIだけスケール、入口でTLSなど)
今回の構成はこの考え方をそのままDocker上で再現します。
2) Docker Composeで重要なのは“名前解決”と“責務分離”
Composeでは、同じ docker compose プロジェクト内のサービスは サービス名がDNS名 になります。たとえば api サービスを作ると、Nginxコンテナから http://api:8080 へ到達できます。
この“名前でつながる”性質が、Nginxの proxy_pass と非常に相性が良いです。ホストのIPに依存しないので、どのPCでも同じ設定で動きます。
また、責務分離の基本はこうです。
- nginx:外部公開(80番)、静的配信、
/apiの中継 - api(Go/Node):APIレスポンスを返すだけ
これだけでも、入口とアプリの境界がくっきりして学びやすくなります。
3) “最小だけど実戦的”にするための小技
入門用でも、次の要素を最初から入れておくと後で効きます。
- 静的配信に
try_files(SPAのページ更新で404を防ぐ) - プロキシに最低限のヘッダ付与(ログやURL生成に効く)
- ホットリロード的な体験(設定やソースをマウントする)
今回は、重くしない範囲でこの3つを入れます。
ハンズオン
ここからは “コピペでそのまま動く” を目標に、フォルダ構成→ファイル→起動→動作確認まで一気に作ります。Go/Nodeの両方を用意して、Composeのプロファイルで切り替える形にします。
1) ディレクトリ構成
プロジェクト直下を次のようにします。
compose-nginx-app/
docker-compose.yml
nginx/
nginx.conf
web/
index.html
go-api/
Dockerfile
main.go
node-api/
Dockerfile
package.json
server.js
2) 静的ファイル(web/index.html)
web/index.html を作ります。最低限、画面に「静的配信できた」とAPI呼び出し結果を表示する要素を置くと検証が楽です。
例:
- ページロード時に
/api/healthを叩くボタン - 結果表示エリア
(HTMLは簡素でOK。後述のAPIが返すJSONが見えれば成功です。)
3) Nginx設定(nginx/nginx.conf)
ポイントは2つです。
/はweb/を静的配信/api/はapiサービスにプロキシ
nginx/nginx.conf(例)
events {}http {
server {
listen 80; # 静的配信用
root /usr/share/nginx/html;
index index.html; location / {
# SPA想定:存在しないパスはindex.htmlへ
try_files $uri $uri/ /index.html;
} # APIプロキシ
location /api/ {
proxy_pass http://api:8080/; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
proxy_pass http://api:8080/; の api が Composeのサービス名 です。ここが“再現性の核”になります。
4) Go API(go-api/main.go)
/health を返すだけの最小APIにします。/api/health に中継されるので、Go側は /health で待ちます。
package mainimport (
"encoding/json"
"log"
"net/http"
"os"
)func main() {
mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"service": "go-api",
"message": "Hello from Go",
"host": os.Getenv("HOSTNAME"),
})
}) addr := ":8080"
log.Println("listening on", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
Go用Dockerfile(go-api/Dockerfile):
FROM golang:1.22-alpine AS build
WORKDIR /app
COPY main.go .
RUN go build -o server main.goFROM alpine:3.20
WORKDIR /app
COPY --from=build /app/server /app/server
EXPOSE 8080
CMD ["/app/server"]
5) Node API(node-api/server.js)
Nodeも同じく /health を返すだけにします。
node-api/package.json:
{
"name": "node-api",
"version": "1.0.0",
"type": "module",
"dependencies": {
"express": "^4.19.2"
}
}
node-api/server.js:
import express from "express";const app = express();app.get("/health", (req, res) => {
res.json({
ok: true,
service: "node-api",
message: "Hello from Node",
host: process.env.HOSTNAME
});
});app.listen(8080, () => {
console.log("listening on :8080");
});
Node用Dockerfile(node-api/Dockerfile):
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY server.js ./
EXPOSE 8080
CMD ["node", "server.js"]
6) Docker Compose(docker-compose.yml)
ここが“一発再現”の本体です。
nginxは 80番を公開apiは Go または Node をプロファイルで切り替えnginxからapi:8080で到達可能
services:
nginx:
image: nginx:1.27-alpine
ports:
- "8088:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./web:/usr/share/nginx/html:ro
depends_on:
- api # Go版
api:
profiles: ["go"]
build:
context: ./go-api
expose:
- "8080" # Node版(同名サービスにするため、別ファイルに分ける方法もあるが、
# ここでは "override" ではなくプロファイル切替を採用する)
api-node:
profiles: ["node"]
build:
context: ./node-api
expose:
- "8080"
ただし、このままだと nginx が参照するのは api 固定です。そこで プロファイルに応じてNginxが見る宛先を揃えるために、構成を少し整理します。おすすめは次のどちらかです。
A案(最も分かりやすい):Composeファイルを2つに分割
docker-compose.yml(共通:nginx + web)docker-compose.go.yml(api=go)docker-compose.node.yml(api=node)
B案(1ファイルで済ませる):サービス名を合わせるためにextends/overrideを使う
入門記事としては A案 の方が読みやすく事故りません。ここではA案でいきます。
共通(docker-compose.yml)
services:
nginx:
image: nginx:1.27-alpine
ports:
- "8088:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./web:/usr/share/nginx/html:ro
depends_on:
- api
Go用(docker-compose.go.yml)
services:
api:
build:
context: ./go-api
expose:
- "8080"
Node用(docker-compose.node.yml)
services:
api:
build:
context: ./node-api
expose:
- "8080"
これで Nginxは常に api を見ればよい になり、切り替えが綺麗に決まります。
7) 起動コマンド(Go版 / Node版)
Go版
docker compose -f docker-compose.yml -f docker-compose.go.yml up --build
Node版
docker compose -f docker-compose.yml -f docker-compose.node.yml up --build
アクセスは共通で:
- 静的:
http://localhost:8088/ - API:
http://localhost:8088/api/health
8) 動作確認のポイント
うまくいくと、/api/health のレスポンスが以下のように変わります。
- Go版:
"service": "go-api" - Node版:
"service": "node-api"
つまり「入口(Nginx)は同じ、裏側(実装言語)だけ差し替え可能」が実現できています。これがCompose再現の強さです。
9) よくある詰まりどころ
- 502 Bad Gateway
→ アプリが起動してない /proxy_passの宛先が違う / APIが8080で待ってない - 静的が404
→web/のマウント先が違う /rootが違う - 設定を変えたのに反映されない
→nginx.confをマウントしているか確認、必要ならdocker compose restart nginx
この3つは、入門段階でも最短で切り分けられるようにしておくと安心です。
まとめ
Docker Composeを使うと、Nginx + Go/Nodeの「入口→中継→アプリ」を、環境差なしで一発再現できます。今回の構成は最小ながらも、現場の考え方に近い要素をちゃんと含んでいます。
- Nginxは 静的配信 と APIプロキシ を担当
- Go/Nodeは APIの実装だけ を担当
- Composeは サービス名で名前解決 し、設定の再現性を担保
- 2つのComposeファイルを重ねる方式で 実装の差し替え が簡単
このテンプレを持っておくと、Nginx設定の学習、APIの置き換え、検証環境の共有が一気に楽になります。
コメントを残す