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
|
VITE_FOOTBALL_API_KEY=your_api_key_here
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.env
|
.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 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
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 { 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user