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:
nilesh.p
2026-10-10 13:39:15 +05:30
co-authored by Claude Opus 4.8
parent 17a7e8ffd5
commit a9dc273e09
8 changed files with 363 additions and 0 deletions
+27
View File
@@ -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 |
+11
View File
@@ -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
+20
View File
@@ -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"]
+57
View File
@@ -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
View File
@@ -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
}
}
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
.angular/
.env
*.env
coverage/
Dockerfile
.dockerignore
+17
View File
@@ -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
+26
View File
@@ -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;
}
}