Add Docker setup: one script to set up/start/stop the full stack
A new developer needs only Docker Desktop — no local Go, Node, or PostgreSQL. - docker-compose.yml: PostgreSQL + Go API + Angular (nginx) with healthcheck, migrations auto-applied via docker-entrypoint-initdb.d, data in a volume - backend/Dockerfile: static Go binary on distroless (nonroot) - frontend/Dockerfile: Angular prod build served by nginx, which proxies /api to the api service (same origin, no CORS) - docker.ps1: setup / start / stop / restart / reset / rebuild / status / logs; reads DB creds from backend/.env (single source, nothing duplicated) - .dockerignore keeps .env and local artifacts out of image build contexts - README: "Run with Docker" quick start Not yet run end to end here (Docker is not installed on this machine); the compose and script are validated for syntax and structure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
17a7e8ffd5
commit
a9dc273e09
@@ -20,6 +20,33 @@ Authentication is HTTP **Basic auth**, as requested, scoped per tenant.
|
||||
|
||||
---
|
||||
|
||||
## Run with Docker (quickest — no local toolchain)
|
||||
|
||||
A new developer needs **only Docker Desktop** — no local Go, Node, or
|
||||
PostgreSQL. One script sets up, starts and stops the whole stack:
|
||||
|
||||
```powershell
|
||||
# one-time: set the DB password the containers will use
|
||||
cp backend\.env.example backend\.env # then edit DB_PASSWORD
|
||||
|
||||
.\docker.ps1 setup # build images, start everything, seed the database
|
||||
.\docker.ps1 stop # stop (keeps data)
|
||||
.\docker.ps1 start # start again
|
||||
.\docker.ps1 reset # wipe data and re-seed with fresh flight dates
|
||||
.\docker.ps1 logs -Follow
|
||||
```
|
||||
|
||||
Then open **http://localhost:4200** (nginx serves the SPA and proxies `/api` to
|
||||
the Go API on `:8080`). Sign in with any demo account, password `demo1234`.
|
||||
|
||||
The database is created and seeded on first `setup` via the migrations mounted
|
||||
into PostgreSQL. Don't run the native `app.ps1` at the same time — it uses the
|
||||
same ports.
|
||||
|
||||
For local development without containers, use the native setup below.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Tool | Version used here |
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Keep secrets and local artifacts out of the build context and image layers.
|
||||
.env
|
||||
*.env
|
||||
!.env.example
|
||||
bin/
|
||||
coverage
|
||||
coverage.out
|
||||
*.exe
|
||||
*.test
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,20 @@
|
||||
# --- build -----------------------------------------------------------------
|
||||
# Static Go binary so the runtime image can be distroless (no shell, tiny,
|
||||
# minimal attack surface). pgx is pure Go, so CGO can be disabled.
|
||||
FROM golang:1.26 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Cache modules separately from source for faster rebuilds.
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
|
||||
|
||||
# --- runtime ---------------------------------------------------------------
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
COPY --from=build /out/api /api
|
||||
EXPOSE 8080
|
||||
# Config comes from the environment (DB_HOST, DB_PASSWORD, PORT, …), set by
|
||||
# docker-compose. No .env file is baked into the image.
|
||||
ENTRYPOINT ["/api"]
|
||||
@@ -0,0 +1,57 @@
|
||||
# TravelHub full stack on Docker. Drive it with docker.ps1 (which supplies the
|
||||
# DB_* values from backend/.env), or run docker compose directly after exporting
|
||||
# DB_USER / DB_PASSWORD / DB_NAME yourself.
|
||||
#
|
||||
# PostgreSQL internal only (data in the pgdata volume)
|
||||
# api Go API -> http://localhost:8080
|
||||
# web Angular via nginx -> http://localhost:4200 (proxies /api to api)
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD (docker.ps1 reads it from backend/.env)}
|
||||
POSTGRES_DB: ${DB_NAME:-travelhub}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
# Migrations run once, on first creation of an empty data directory, in
|
||||
# order (001_init, 002_seed, 003_demo_bookings). The seed generates
|
||||
# flights relative to now(), so a fresh volume always has future-dated
|
||||
# inventory. `docker.ps1 reset` wipes the volume to re-seed.
|
||||
- ./backend/migrations:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-travelhub}']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
restart: unless-stopped
|
||||
|
||||
api:
|
||||
build: ./backend
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: '5432'
|
||||
DB_USER: ${DB_USER:-postgres}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
DB_NAME: ${DB_NAME:-travelhub}
|
||||
DB_SSLMODE: disable
|
||||
PORT: '8080'
|
||||
CORS_ORIGINS: http://localhost:4200
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- '8080:8080'
|
||||
restart: unless-stopped
|
||||
|
||||
web:
|
||||
build: ./frontend
|
||||
depends_on:
|
||||
- api
|
||||
ports:
|
||||
- '4200:80'
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Set up, start and stop the entire TravelHub stack on Docker. A new developer
|
||||
needs only Docker Desktop -- no local Go, Node, or PostgreSQL.
|
||||
|
||||
.DESCRIPTION
|
||||
Wraps docker compose for the three services (PostgreSQL + Go API + Angular
|
||||
via nginx). Database credentials are read from backend/.env, so there is one
|
||||
source of truth and no secret is duplicated.
|
||||
|
||||
URLs once up:
|
||||
App -> http://localhost:4200 (nginx serves the SPA and proxies /api)
|
||||
API -> http://localhost:8080
|
||||
|
||||
Demo sign-in: any seeded account, password demo1234
|
||||
(e.g. agent@skyline.test, agency "skyline").
|
||||
|
||||
.PARAMETER Action
|
||||
setup Build images and bring everything up, waiting until healthy. The
|
||||
database is created and seeded on this first run. One command to
|
||||
onboard.
|
||||
start Bring the stack up (images already built).
|
||||
stop Stop and remove the containers; the database volume is kept.
|
||||
restart stop then start.
|
||||
reset Stop and DELETE the database volume, then start fresh (re-seeds).
|
||||
rebuild Rebuild images (use after pulling code changes) and start.
|
||||
status Show container state.
|
||||
logs Tail logs (-Follow to stream; -Service to pick one).
|
||||
|
||||
.PARAMETER Service
|
||||
logs: which service (postgres | api | web). Default: all.
|
||||
|
||||
.PARAMETER Follow
|
||||
logs: stream instead of printing the tail once.
|
||||
|
||||
.EXAMPLE
|
||||
.\docker.ps1 setup
|
||||
.EXAMPLE
|
||||
.\docker.ps1 logs -Service api -Follow
|
||||
.EXAMPLE
|
||||
.\docker.ps1 reset # wipe data and re-seed with fresh flight dates
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[ValidateSet('setup', 'start', 'stop', 'restart', 'reset', 'rebuild', 'status', 'logs', 'help')]
|
||||
[string]$Action = 'help',
|
||||
|
||||
[ValidateSet('postgres', 'api', 'web')]
|
||||
[string]$Service,
|
||||
|
||||
[switch]$Follow
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Root = $PSScriptRoot
|
||||
|
||||
function Write-Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
|
||||
function Write-Ok($m) { Write-Host " + $m" -ForegroundColor Green }
|
||||
function Write-Warn2($m){ Write-Host " ! $m" -ForegroundColor Yellow }
|
||||
|
||||
# --- docker availability ---------------------------------------------------
|
||||
|
||||
function Require-Docker {
|
||||
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
|
||||
throw "Docker not found. Install Docker Desktop and ensure it is running: https://www.docker.com/products/docker-desktop/"
|
||||
}
|
||||
# `docker info` fails fast if the engine/daemon is not running.
|
||||
docker info *> $null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw 'Docker is installed but the engine is not running. Start Docker Desktop and retry.'
|
||||
}
|
||||
# Compose v2 (the `docker compose` subcommand) is required.
|
||||
docker compose version *> $null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "This script needs Docker Compose v2 (the 'docker compose' subcommand). Update Docker Desktop."
|
||||
}
|
||||
}
|
||||
|
||||
# --- credentials from backend/.env -----------------------------------------
|
||||
|
||||
# Load DB_USER / DB_PASSWORD / DB_NAME from backend/.env into this process so
|
||||
# docker compose can substitute them. Nothing is printed or copied to disk.
|
||||
function Import-DbEnv {
|
||||
$envFile = Join-Path $Root 'backend\.env'
|
||||
if (-not (Test-Path $envFile)) {
|
||||
throw "backend\.env not found. Copy backend\.env.example to backend\.env and set DB_PASSWORD first."
|
||||
}
|
||||
foreach ($line in Get-Content $envFile) {
|
||||
$t = $line.Trim()
|
||||
if ($t -eq '' -or $t.StartsWith('#')) { continue }
|
||||
$i = $t.IndexOf('=')
|
||||
if ($i -lt 1) { continue }
|
||||
$key = $t.Substring(0, $i).Trim()
|
||||
$val = $t.Substring($i + 1).Trim().Trim('"').Trim("'")
|
||||
if ($key -in @('DB_USER', 'DB_PASSWORD', 'DB_NAME')) {
|
||||
Set-Item -Path "Env:$key" -Value $val
|
||||
}
|
||||
}
|
||||
if (-not $env:DB_PASSWORD) {
|
||||
throw 'DB_PASSWORD is not set in backend\.env.'
|
||||
}
|
||||
if (-not $env:DB_USER) { $env:DB_USER = 'postgres' }
|
||||
if (-not $env:DB_NAME) { $env:DB_NAME = 'travelhub' }
|
||||
}
|
||||
|
||||
# --- compose helpers -------------------------------------------------------
|
||||
|
||||
function Compose {
|
||||
param([Parameter(ValueFromRemainingArguments = $true)] $ComposeArgs)
|
||||
Push-Location $Root
|
||||
try { & docker compose @ComposeArgs } finally { Pop-Location }
|
||||
}
|
||||
|
||||
function Wait-ForUrl($url, $timeoutSec) {
|
||||
$deadline = (Get-Date).AddSeconds($timeoutSec)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
try { Invoke-WebRequest $url -UseBasicParsing -TimeoutSec 3 | Out-Null; return $true }
|
||||
catch { Start-Sleep -Seconds 2 }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Show-Ready {
|
||||
Write-Host ''
|
||||
if (Wait-ForUrl 'http://localhost:8080/api/health' 90) { Write-Ok 'API healthy -> http://localhost:8080' }
|
||||
else { Write-Warn2 'API not healthy yet -- check: .\docker.ps1 logs -Service api' }
|
||||
if (Wait-ForUrl 'http://localhost:4200/' 60) { Write-Ok 'App ready -> http://localhost:4200' }
|
||||
else { Write-Warn2 'Web not ready yet -- check: .\docker.ps1 logs -Service web' }
|
||||
Write-Host ''
|
||||
Write-Host 'Sign in with any demo account, password demo1234 (e.g. agent@skyline.test / skyline).'
|
||||
Write-Host 'Note: stop any native .\app.ps1 first -- it uses the same ports.' -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# --- actions ---------------------------------------------------------------
|
||||
|
||||
switch ($Action) {
|
||||
'setup' {
|
||||
Require-Docker; Import-DbEnv
|
||||
Write-Step 'Building images (first run downloads base images)'
|
||||
Compose build
|
||||
Write-Step 'Starting stack and seeding the database'
|
||||
Compose up -d
|
||||
Show-Ready
|
||||
Write-Ok 'Setup complete.'
|
||||
}
|
||||
'start' {
|
||||
Require-Docker; Import-DbEnv
|
||||
Write-Step 'Starting stack'
|
||||
Compose up -d
|
||||
Show-Ready
|
||||
}
|
||||
'stop' {
|
||||
Require-Docker; Import-DbEnv
|
||||
Write-Step 'Stopping stack (database volume kept)'
|
||||
Compose down
|
||||
Write-Ok 'Stopped.'
|
||||
}
|
||||
'restart' {
|
||||
Require-Docker; Import-DbEnv
|
||||
Compose down
|
||||
Compose up -d
|
||||
Show-Ready
|
||||
}
|
||||
'reset' {
|
||||
Require-Docker; Import-DbEnv
|
||||
Write-Warn2 'This DELETES the database volume. All data will be lost.'
|
||||
Write-Step 'Removing containers and the data volume'
|
||||
Compose down -v
|
||||
Write-Step 'Starting fresh (re-seeds with current flight dates)'
|
||||
Compose up -d
|
||||
Show-Ready
|
||||
Write-Ok 'Reset complete.'
|
||||
}
|
||||
'rebuild' {
|
||||
Require-Docker; Import-DbEnv
|
||||
Write-Step 'Rebuilding images'
|
||||
Compose build --no-cache
|
||||
Compose up -d
|
||||
Show-Ready
|
||||
}
|
||||
'status' {
|
||||
Require-Docker; Import-DbEnv
|
||||
Compose ps
|
||||
}
|
||||
'logs' {
|
||||
Require-Docker; Import-DbEnv
|
||||
$logArgs = @('logs')
|
||||
if ($Follow) { $logArgs += '-f' } else { $logArgs += @('--tail', '80') }
|
||||
if ($Service) { $logArgs += $Service }
|
||||
Compose @logArgs
|
||||
}
|
||||
default {
|
||||
Get-Help $PSCommandPath -Detailed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.angular/
|
||||
.env
|
||||
*.env
|
||||
coverage/
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,17 @@
|
||||
# --- build -----------------------------------------------------------------
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
# Angular's application builder emits the browser bundle to dist/frontend/browser.
|
||||
|
||||
# --- runtime ---------------------------------------------------------------
|
||||
FROM nginx:1.27-alpine
|
||||
# Serve the SPA and proxy /api to the api service (same origin, so no CORS).
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist/frontend/browser /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,26 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# API calls go to the Go service over the compose network. Because the
|
||||
# browser reaches both the app and /api on the same origin (localhost:4200),
|
||||
# there is no CORS to configure.
|
||||
location /api/ {
|
||||
proxy_pass http://api:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# Pass through the auth and tenant headers the SPA sends.
|
||||
proxy_pass_request_headers on;
|
||||
}
|
||||
|
||||
# Angular is a single-page app: unknown paths fall back to index.html so
|
||||
# client-side routing works on reload / deep link.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user