From 5db020132e2b00f03e0c850a1d64da79cc7f87c7 Mon Sep 17 00:00:00 2001 From: henry Date: Sat, 14 Mar 2026 07:40:20 +0100 Subject: [PATCH] Add Docker + nginx deployment for homelab - Multi-stage Dockerfile: Node builds static assets, nginx serves them - nginx proxies /api/* to football-data.org and injects X-Auth-Token server-side via FOOTBALL_API_KEY env var (key never in browser bundle) - docker-compose.yml exposes the app on port 3000 - Extract apiFetch() helper: sends key in dev (Vite proxy), skips in prod - .env.example updated with both dev and production key vars Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 4 ++++ .gitignore | 1 + Dockerfile | 22 ++++++++++++++++++++++ docker-compose.yml | 8 ++++++++ nginx/default.conf.template | 20 ++++++++++++++++++++ src/App.jsx | 17 ++--------------- src/api.js | 11 +++++++++++ src/components/TeamMatchesModal.jsx | 11 ++--------- 8 files changed, 70 insertions(+), 24 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 nginx/default.conf.template create mode 100644 src/api.js diff --git a/.env.example b/.env.example index e2fed63..b731cba 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,5 @@ +# Used by docker-compose (nginx injects it server-side — never exposed to browser) +FOOTBALL_API_KEY=your_api_key_here + +# Only needed for local development (npm run dev) VITE_FOOTBALL_API_KEY=your_api_key_here diff --git a/.gitignore b/.gitignore index deed335..dafa699 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ .env +.env.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bc4ea9c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# ── Stage 1: build ────────────────────────────────────────────────────────── +FROM node:22-alpine AS builder +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +# No VITE_FOOTBALL_API_KEY needed at build time — nginx handles auth +RUN npm run build + +# ── Stage 2: serve ────────────────────────────────────────────────────────── +FROM nginx:alpine + +# Copy built assets +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx template — the official nginx image runs envsubst on *.template +# files at startup, producing /etc/nginx/conf.d/default.conf +COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template + +EXPOSE 80 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..94142fa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + football-app: + build: . + restart: unless-stopped + ports: + - "3000:80" + environment: + - FOOTBALL_API_KEY=${FOOTBALL_API_KEY} diff --git a/nginx/default.conf.template b/nginx/default.conf.template new file mode 100644 index 0000000..fb0f40e --- /dev/null +++ b/nginx/default.conf.template @@ -0,0 +1,20 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + # Proxy /api/* → football-data.org, injecting the API key server-side + location /api/ { + proxy_pass https://api.football-data.org/v4/; + proxy_set_header Host api.football-data.org; + proxy_set_header X-Auth-Token ${FOOTBALL_API_KEY}; + proxy_ssl_server_name on; + proxy_hide_header Access-Control-Allow-Origin; + add_header Access-Control-Allow-Origin $http_origin always; + } + + # SPA fallback: all unmatched routes serve index.html + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/src/App.jsx b/src/App.jsx index 2851b6c..09fd08a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,10 +2,9 @@ import { useState, useEffect, useCallback } from 'react' import StandingsTable from './components/StandingsTable' import TeamMatchesModal from './components/TeamMatchesModal' import CompetitionPicker from './components/CompetitionPicker' +import { apiFetch } from './api' import styles from './App.module.css' -const API_KEY = import.meta.env.VITE_FOOTBALL_API_KEY - export default function App() { const [selectedCode, setSelectedCode] = useState('DED') const [standings, setStandings] = useState(null) @@ -20,24 +19,12 @@ export default function App() { const closeModal = useCallback(() => setSelectedTeam(null), []) useEffect(() => { - if (!API_KEY) { - setError('No API key found. Create a .env file with VITE_FOOTBALL_API_KEY=your_key') - setLoading(false) - return - } - setLoading(true) setError(null) setStandings(null) setSelectedTeam(null) - fetch(`/api/competitions/${selectedCode}/standings`, { - headers: { 'X-Auth-Token': API_KEY }, - }) - .then((res) => { - if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`) - return res.json() - }) + apiFetch(`/competitions/${selectedCode}/standings`) .then((data) => { const total = data.standings?.find((s) => s.type === 'TOTAL') setStandings(total?.table ?? []) diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..57a7b8a --- /dev/null +++ b/src/api.js @@ -0,0 +1,11 @@ +// In dev, Vite proxies /api/* but doesn't inject the key, so we send it +// from the browser. In production the nginx proxy injects it server-side. +const DEV_KEY = import.meta.env.VITE_FOOTBALL_API_KEY + +export function apiFetch(path) { + const headers = DEV_KEY ? { 'X-Auth-Token': DEV_KEY } : {} + return fetch(`/api${path}`, { headers }).then((res) => { + if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`) + return res.json() + }) +} diff --git a/src/components/TeamMatchesModal.jsx b/src/components/TeamMatchesModal.jsx index eb73ece..bf346cb 100644 --- a/src/components/TeamMatchesModal.jsx +++ b/src/components/TeamMatchesModal.jsx @@ -1,8 +1,7 @@ import { useState, useEffect } from 'react' +import { apiFetch } from '../api' import styles from './TeamMatchesModal.module.css' -const API_KEY = import.meta.env.VITE_FOOTBALL_API_KEY - export default function TeamMatchesModal({ team, season, competitionCode, onClose }) { const [matches, setMatches] = useState(null) const [loading, setLoading] = useState(true) @@ -11,13 +10,7 @@ export default function TeamMatchesModal({ team, season, competitionCode, onClos useEffect(() => { const seasonYear = new Date(season.startDate).getFullYear() - fetch(`/api/teams/${team.id}/matches?competitions=${competitionCode}&season=${seasonYear}`, { - headers: { 'X-Auth-Token': API_KEY }, - }) - .then((res) => { - if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`) - return res.json() - }) + apiFetch(`/teams/${team.id}/matches?competitions=${competitionCode}&season=${seasonYear}`) .then((data) => { setMatches(data.matches ?? []) setLoading(false)