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 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 07:40:20 +01:00
parent d8fba41ea5
commit 5db020132e
8 changed files with 70 additions and 24 deletions

View File

@@ -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 VITE_FOOTBALL_API_KEY=your_api_key_here

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules/ node_modules/
dist/ dist/
.env .env
.env.local

22
Dockerfile Normal file
View File

@@ -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

8
docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
football-app:
build: .
restart: unless-stopped
ports:
- "3000:80"
environment:
- FOOTBALL_API_KEY=${FOOTBALL_API_KEY}

View File

@@ -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;
}
}

View File

@@ -2,10 +2,9 @@ import { useState, useEffect, useCallback } from 'react'
import StandingsTable from './components/StandingsTable' import StandingsTable from './components/StandingsTable'
import TeamMatchesModal from './components/TeamMatchesModal' import TeamMatchesModal from './components/TeamMatchesModal'
import CompetitionPicker from './components/CompetitionPicker' import CompetitionPicker from './components/CompetitionPicker'
import { apiFetch } from './api'
import styles from './App.module.css' import styles from './App.module.css'
const API_KEY = import.meta.env.VITE_FOOTBALL_API_KEY
export default function App() { export default function App() {
const [selectedCode, setSelectedCode] = useState('DED') const [selectedCode, setSelectedCode] = useState('DED')
const [standings, setStandings] = useState(null) const [standings, setStandings] = useState(null)
@@ -20,24 +19,12 @@ export default function App() {
const closeModal = useCallback(() => setSelectedTeam(null), []) const closeModal = useCallback(() => setSelectedTeam(null), [])
useEffect(() => { 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) setLoading(true)
setError(null) setError(null)
setStandings(null) setStandings(null)
setSelectedTeam(null) setSelectedTeam(null)
fetch(`/api/competitions/${selectedCode}/standings`, { apiFetch(`/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()
})
.then((data) => { .then((data) => {
const total = data.standings?.find((s) => s.type === 'TOTAL') const total = data.standings?.find((s) => s.type === 'TOTAL')
setStandings(total?.table ?? []) setStandings(total?.table ?? [])

11
src/api.js Normal file
View File

@@ -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()
})
}

View File

@@ -1,8 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { apiFetch } from '../api'
import styles from './TeamMatchesModal.module.css' import styles from './TeamMatchesModal.module.css'
const API_KEY = import.meta.env.VITE_FOOTBALL_API_KEY
export default function TeamMatchesModal({ team, season, competitionCode, onClose }) { export default function TeamMatchesModal({ team, season, competitionCode, onClose }) {
const [matches, setMatches] = useState(null) const [matches, setMatches] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -11,13 +10,7 @@ export default function TeamMatchesModal({ team, season, competitionCode, onClos
useEffect(() => { useEffect(() => {
const seasonYear = new Date(season.startDate).getFullYear() const seasonYear = new Date(season.startDate).getFullYear()
fetch(`/api/teams/${team.id}/matches?competitions=${competitionCode}&season=${seasonYear}`, { apiFetch(`/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()
})
.then((data) => { .then((data) => {
setMatches(data.matches ?? []) setMatches(data.matches ?? [])
setLoading(false) setLoading(false)