diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3b2ffcd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +.git +data +*.pdf +*.sqlite +*.sqlite-journal +*.sqlite-wal +server/dist +web/dist diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0401101 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# ---- Stage 1: Build the React frontend ---- +FROM node:18-alpine AS web-build +WORKDIR /app/web +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + +# ---- Stage 2: Build the Node.js server ---- +FROM node:18-alpine AS server-build +WORKDIR /app/server +COPY server/package.json server/package-lock.json ./ +RUN npm ci +COPY server/ ./ +RUN npm run build + +# ---- Stage 3: Production image ---- +FROM node:18-alpine AS production +WORKDIR /app + +# Copy server package files and install production deps only +COPY server/package.json server/package-lock.json ./ +RUN npm ci --omit=dev + +# Copy compiled server +COPY --from=server-build /app/server/dist ./dist + +# Copy server migration files (needed at runtime) +COPY --from=server-build /app/server/src/db/migrations ./dist/db/migrations + +# Copy frontend build output +COPY --from=web-build /app/web/dist ./web-dist + +# Create data directory for SQLite +RUN mkdir -p /data + +ENV NODE_ENV=production +ENV PORT=3000 +ENV DB_PATH=/data/spelljammer.sqlite + +EXPOSE 3000 + +CMD ["node", "dist/index.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d9042e2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: "3.8" + +services: + spelljammer: + build: + context: . + dockerfile: Dockerfile + container_name: spelljammer-ships + ports: + - "3000:3000" + volumes: + - spelljammer-data:/data + environment: + - NODE_ENV=production + - PORT=3000 + - DB_PATH=/data/spelljammer.sqlite + restart: unless-stopped + +volumes: + spelljammer-data: + driver: local diff --git a/server/src/index.ts b/server/src/index.ts index e1faf6e..768ef9c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,7 +1,10 @@ import { createServer } from 'node:http'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; import Fastify from 'fastify'; import cors from '@fastify/cors'; -import { PORT } from './config.js'; +import fastifyStatic from '@fastify/static'; +import { PORT, NODE_ENV } from './config.js'; import { runMigrations } from './db/migrate.js'; import { healthRoutes } from './routes/health.js'; import { shipRoutes } from './routes/ships.js'; @@ -27,8 +30,26 @@ const start = async () => { await app.register(shipRoutes); await app.register(weaponRoutes); + // In production, serve the React build as static files + const webDistPath = join(process.cwd(), 'web-dist'); + if (NODE_ENV === 'production' && existsSync(webDistPath)) { + await app.register(fastifyStatic, { + root: webDistPath, + prefix: '/', + wildcard: false, + }); + + // SPA fallback: non-API GET requests return index.html + app.setNotFoundHandler((request, reply) => { + if (request.method === 'GET' && !request.url.startsWith('/api')) { + return reply.sendFile('index.html'); + } + reply.code(404).send({ error: 'Not found' }); + }); + } + await app.listen({ port: PORT, host: '0.0.0.0' }); - console.log(`Server running on port ${PORT}`); + console.log(`Server running on port ${PORT} (${NODE_ENV})`); } catch (err) { app.log.error(err); process.exit(1);