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:
@@ -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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal 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
8
docker-compose.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
football-app:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:80"
|
||||
environment:
|
||||
- FOOTBALL_API_KEY=${FOOTBALL_API_KEY}
|
||||
20
nginx/default.conf.template
Normal file
20
nginx/default.conf.template
Normal 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;
|
||||
}
|
||||
}
|
||||
17
src/App.jsx
17
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 ?? [])
|
||||
|
||||
11
src/api.js
Normal file
11
src/api.js
Normal 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()
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user