HH-439: harden production artifact pipeline (#86)

* HH-439: harden production artifact pipeline

* fix(HH-439): address production compose review

* fix(HH-439): preserve previous JWT secrets in production

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-21 20:20:00 +08:00
committed by GitHub
co-authored by rogee
parent 7e3872170f
commit 7a9fec33c5
11 changed files with 436 additions and 153 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.agent_context
.multica
**/node_modules
frontend/dist
backend/bin
backend/coverage.out
.env
.env.*
+21 -62
View File
@@ -1,69 +1,28 @@
# GoChat Environment Variables
# Copy to .env and fill in your values. NEVER commit .env to git.
# GoChat production variables. Copy to .env and replace every CHANGE_ME value.
# ---- Application ----
GOCHAT_ENV=prod
# Keep disabled in production. Enable only for controlled QA/UAT environments.
GOCHAT_IMAGE_REF=ghcr.io/rogeecn/gochat@sha256:CHANGE_ME
GOCHAT_ENV=production
GOCHAT_PORT=3000
GOCHAT_SERVER_MODE=release
GOCHAT_SERVER_CORS_ALLOWED_ORIGINS=https://chat.CHANGE_ME.example.com
# ---- Server ----
GOCHAT_SERVER_HOST=0.0.0.0
GOCHAT_SERVER_PORT=3000
GOCHAT_SERVER_MODE=release # debug, release, test
POSTGRES_DB=gochat_production
POSTGRES_USER=gochat
POSTGRES_PASSWORD=CHANGE_ME
# The built-in PostgreSQL service is non-TLS. For an external database, set a
# complete GOCHAT_DATABASE_DSN with sslmode=require, verify-ca, or verify-full.
# ---- Database ----
GOCHAT_DATABASE_DSN=postgres://gochat:CHANGE_ME_TO_A_STRONG_PASSWORD@localhost:5432/gochat_production?sslmode=disable
GOCHAT_DATABASE_MAX_IDLE_CONNS=10
GOCHAT_DATABASE_MAX_OPEN_CONNS=100
GOCHAT_DATABASE_CONN_MAX_LIFETIME=3600
POSTGRES_IMAGE_REF=pgvector/pgvector:pg16@sha256:ccc6e83d6e35e931dc7c5def2022729d5a6c370318d099181995567ff1fb4d6b
REDIS_IMAGE_REF=redis:7-alpine@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf
MEILI_IMAGE_REF=getmeili/meilisearch:v1.13@sha256:bed3fb650e62da53145777204891159242f6ea4ce69e215b36223af4aa64a0ae
# ---- Redis ----
GOCHAT_REDIS_DSN=redis://localhost:6379
GOCHAT_REDIS_POOL_SIZE=50
# ---- JWT ----
GOCHAT_JWT_SECRET=CHANGE_ME_TO_A_RANDOM_64_CHAR_STRING
REDIS_PASSWORD=CHANGE_ME
MEILI_MASTER_KEY=CHANGE_ME
GOCHAT_JWT_SECRET=CHANGE_ME_WITH_AT_LEAST_32_RANDOM_CHARACTERS
# Optional during a bounded rotation window; comma-separated old 32+ byte secrets.
GOCHAT_JWT_PREVIOUS_SECRETS=
GOCHAT_JWT_EXPIRY_HOURS=72
GOCHAT_JWT_ACCESS_EXPIRY_MINUTES=15
GOCHAT_JWT_REFRESH_EXPIRY_HOURS=168
GOCHAT_JWT_ALLOW_INSECURE_HEADER_AUTH=false
# ---- Logging ----
GOCHAT_LOG_LEVEL=info # debug, info, warn, error
GOCHAT_LOG_FORMAT=json # json, text
# ---- SMTP / Email ----
SMTP_ADDRESS=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=your-email@example.com
SMTP_PASSWORD=your-email-password
# ---- Worker ----
GOCHAT_WORKER_CONCURRENCY=10
# ---- Frontend URL ----
FRONTEND_URL=https://your-domain.com
# ---- Search ----
GOCHAT_SEARCH_ENGINE=meilisearch
GOCHAT_SEARCH_HOST=http://localhost:7700
GOCHAT_SEARCH_API_KEY=
GOCHAT_SEARCH_INDEX_PREFIX=gochat_
GOCHAT_SEARCH_TIMEOUT_SECONDS=5
# ---- Storage ----
GOCHAT_STORAGE_PROVIDER=local
GOCHAT_STORAGE_LOCAL_PATH=./uploads
GOCHAT_STORAGE_MAX_FILE_SIZE=20971520 # 20MB
# ---- OIDC (Enterprise OAuth/OIDC SSO, disabled by default) ----
GOCHAT_OIDC_ENABLED=false
GOCHAT_OIDC_DEFAULT_CLIENT_ID=
GOCHAT_OIDC_DEFAULT_CLIENT_SECRET=
GOCHAT_OIDC_DEFAULT_REDIRECT_URL=https://your-domain.com/api/v1/oidc/callback
GOCHAT_OIDC_DEFAULT_ISSUER_URL=https://accounts.google.com
GOCHAT_OIDC_DEFAULT_AUTHORIZATION_URL=
GOCHAT_OIDC_DEFAULT_TOKEN_URL=
GOCHAT_OIDC_DEFAULT_USER_INFO_URL=
GOCHAT_OIDC_DEFAULT_JWKS_URL=
GOCHAT_OIDC_DEFAULT_SCOPES=openid,profile,email
# Optional connector. Supply the digest published by its release pipeline.
SHANGWUTONG_IMAGE_REF=ghcr.io/rogeecn/shangwutong@sha256:CHANGE_ME
GOCHAT_CONNECTOR_SERVICE_TOKEN=
+112
View File
@@ -5,10 +5,15 @@ on:
branches: [main]
push:
branches: [main]
tags: ['v*.*.*']
permissions:
contents: read
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
backend:
name: Backend (SQLite)
@@ -153,3 +158,110 @@ jobs:
- name: Prettier representative JS/TS/Vue files
run: pnpm --dir frontend exec prettier --check app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.spec.js app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.vue vite.config.ts app/javascript/histoire.setup.ts
- run: pnpm --dir frontend build
production-smoke:
name: Production image smoke
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
env:
GOCHAT_IMAGE_REF: gochat:production-smoke
GOCHAT_PORT: "38080"
GOCHAT_SERVER_CORS_ALLOWED_ORIGINS: https://chat.example.test
POSTGRES_PASSWORD: ci-postgres-secret
REDIS_PASSWORD: ci-redis-secret
MEILI_MASTER_KEY: ci-meili-secret-16
GOCHAT_JWT_SECRET: ci-smoke-jwt-secret-at-least-32-characters
GOCHAT_JWT_PREVIOUS_SECRETS: ci-previous-jwt-secret-at-least-32-characters
steps:
- uses: actions/checkout@v4
- name: Build production images from repository root
run: |
docker build -t "$GOCHAT_IMAGE_REF" -f deploy/docker/Dockerfile .
docker build -t shangwutong:production-smoke -f channels/shangwutong/Dockerfile .
docker run -d --name gochat-ci-registry -p 127.0.0.1:5000:5000 registry:2
timeout 30 sh -c 'until curl -fsS http://127.0.0.1:5000/v2/; do sleep 1; done'
docker tag "$GOCHAT_IMAGE_REF" localhost:5000/gochat:production-smoke
docker tag shangwutong:production-smoke localhost:5000/shangwutong:production-smoke
docker push localhost:5000/gochat:production-smoke
docker push localhost:5000/shangwutong:production-smoke
echo "GOCHAT_IMAGE_REF=$(docker inspect --format '{{index .RepoDigests 0}}' localhost:5000/gochat:production-smoke)" >> "$GITHUB_ENV"
echo "SHANGWUTONG_IMAGE_REF=$(docker inspect --format '{{index .RepoDigests 0}}' localhost:5000/shangwutong:production-smoke)" >> "$GITHUB_ENV"
- name: Verify artifact contents
run: >-
docker run --rm --entrypoint sh "$GOCHAT_IMAGE_REF" -c
'test -s /app/configs/config.production.yaml &&
test -d /app/migrations &&
test -s /app/frontend/dist/index.html'
- name: Start production Compose and smoke core pages
run: |
if MEILI_MASTER_KEY=too-short deploy/docker/preflight.sh; then
echo "preflight accepted a short Meilisearch key" >&2
exit 1
fi
if POSTGRES_IMAGE_REF=pgvector/pgvector:pg16 deploy/docker/preflight.sh; then
echo "preflight accepted a mutable PostgreSQL image" >&2
exit 1
fi
deploy/docker/preflight.sh
docker compose -f deploy/docker/docker-compose.prod.yml config --format json | python3 -c 'import json, os, sys; config = json.load(sys.stdin); assert all(config["services"][service]["environment"]["GOCHAT_JWT_PREVIOUS_SECRETS"] == os.environ["GOCHAT_JWT_PREVIOUS_SECRETS"] for service in ("gochat", "worker"))'
docker compose -f deploy/docker/docker-compose.prod.yml up -d --wait gochat
curl -fsS "http://127.0.0.1:$GOCHAT_PORT/health" | grep -q '"status":"ok"'
curl -fsS "http://127.0.0.1:$GOCHAT_PORT/app" | grep -q '/assets/'
- name: Stop production Compose
if: always()
run: |
docker compose -f deploy/docker/docker-compose.prod.yml down -v
docker rm -f gochat-ci-registry || true
release:
name: Publish immutable image
if: startsWith(github.ref, 'refs/tags/v')
needs: [backend, backend-postgres, frontend]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image_ref: ${{ steps.ref.outputs.image_ref }}
image_digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract immutable tags
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: latest=false
tags: |
type=semver,pattern={{version}}
type=sha
- name: Capture build date
id: date
run: echo "value=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
file: deploy/docker/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
COMMIT_SHA=${{ github.sha }}
BUILD_DATE=${{ steps.date.outputs.value }}
cache-from: type=gha,scope=gochat
cache-to: type=gha,mode=max,scope=gochat
- name: Record digest reference
id: ref
run: |
image_ref="${REGISTRY}/${IMAGE_NAME}@${{ steps.build.outputs.digest }}"
echo "image_ref=$image_ref" >> "$GITHUB_OUTPUT"
echo "GoChat image: \`$image_ref\`" >> "$GITHUB_STEP_SUMMARY"
-36
View File
@@ -1,36 +0,0 @@
# GoChat Production Environment Overrides
# Reference: Chatwoot config/environments/production.rb
server:
mode: "release"
cors:
# PRODUCTION: Must specify exact origins or wildcard patterns.
# NEVER leave empty in production — empty + non-debug = no CORS allowed.
allowed_origins:
- "https://app.yourdomain.com"
- "https://admin.yourdomain.com"
- "*.yourdomain.com" # matches any subdomain
allow_credentials: true # needed for JWT cookie-based auth
max_age: 86400
jwt:
allow_insecure_header_auth: false
database:
dsn: "postgres://gochat:CHANGE_ME@localhost:5432/gochat_production?sslmode=require"
pool_max: 20
log_level: "warn"
log:
level: "info"
format: "json"
worker:
concurrency: 10
redis_stream_prefix: "gochat:jobs"
redis_consumer_group: "gochat-workers"
redis_block_timeout_s: 5
redis_sweep_interval_s: 30
redis:
channel_prefix: "gochat_production"
+38
View File
@@ -0,0 +1,38 @@
# GoChat production overrides. Secrets and public origins must come from the environment.
server:
mode: "release"
cors:
allowed_origins: ["https://CHANGE_ME.example.com"]
allow_credentials: true
database:
dsn: "postgres://gochat:CHANGE_ME@postgres:5432/gochat_production?sslmode=disable"
run_migrations: true
migrations_path: "/app/migrations"
redis:
dsn: "redis://:CHANGE_ME@redis:6379"
jwt:
secret: "CHANGE_ME"
allow_insecure_header_auth: false
search:
engine: "meilisearch"
host: "http://meilisearch:7700"
api_key: "CHANGE_ME"
log:
level: "info"
format: "json"
worker:
concurrency: 10
redis_stream_prefix: "gochat:jobs"
redis_consumer_group: "gochat-workers"
redis_block_timeout_s: 5
redis_sweep_interval_s: 30
storage:
provider: "local"
local_path: "/app/storage/uploads"
+10 -7
View File
@@ -157,9 +157,9 @@ type LogConfig struct {
Format string `mapstructure:"format"` // json, text
}
// SAMLConfig and LDAPConfig removed — only OIDC is supported for enterprise SSO.
// SAMLConfig and LDAPConfig removed — only OIDC is supported for enterprise SSO.
// OIDCConfig holds OIDC/OAuth2 enterprise authentication configuration.
// OIDCConfig holds OIDC/OAuth2 enterprise authentication configuration.
// Reference: M13 §4.3 — OIDC (OpenID Connect) provider integration.
// Supports Google Workspace, Auth0, Keycloak, Azure AD and any OIDC-compliant IdP.
// Per-account OIDC settings override these defaults (stored in DB).
@@ -460,6 +460,10 @@ func (r *ConfigReloader) Stop() {
//
// config/environments/development.rb overrides config/application.rb defaults.
func LoadWithEnv(env string) (*Config, error) {
if env == "prod" {
return nil, fmt.Errorf("GOCHAT_ENV=prod is unsupported; use GOCHAT_ENV=production")
}
v := viper.New()
// Env key replacer: GOCHAT_DATABASE_DSN → database.dsn
@@ -473,6 +477,7 @@ func LoadWithEnv(env string) (*Config, error) {
"GOCHAT_SERVER_HOST": "server.host",
"GOCHAT_SERVER_PORT": "server.port",
"GOCHAT_SERVER_MODE": "server.mode",
"GOCHAT_SERVER_CORS_ALLOWED_ORIGINS": "server.cors.allowed_origins",
"GOCHAT_DATABASE_DSN": "database.dsn",
"GOCHAT_DATABASE_MAX_IDLE_CONNS": "database.max_idle_conns",
"GOCHAT_DATABASE_MAX_OPEN_CONNS": "database.max_open_conns",
@@ -547,11 +552,7 @@ func LoadWithEnv(env string) (*Config, error) {
// Overlay environment-specific config: config.{env}.yaml
if env != "" && env != "default" {
overlayEnv := env
if env == "production" {
overlayEnv = "prod"
}
envFile := fmt.Sprintf("config.%s.yaml", overlayEnv)
envFile := fmt.Sprintf("config.%s.yaml", env)
// Search in the same directory as the base config
baseConfigPath := v.ConfigFileUsed()
if baseConfigPath != "" {
@@ -562,6 +563,8 @@ func LoadWithEnv(env string) (*Config, error) {
return nil, fmt.Errorf("env config merge failed (%s): %w", env, err)
}
applogger.L().Infof("Merged env config overlay: %s", envConfigPath)
} else if env == "production" {
return nil, fmt.Errorf("production config overlay not found: %s", envConfigPath)
} else {
applogger.L().Warnf("Env config file not found: %s (continuing with base config)", envConfigPath)
}
+91 -4
View File
@@ -2,6 +2,8 @@ package config
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
@@ -127,13 +129,13 @@ func TestValidate_ReleaseJWTSecurity(t *testing.T) {
validSecret := "6vG3uP9qL2mR8xK5nD7sF4hJ1cB0wZyE"
base := func() *Config {
return &Config{
Server: ServerConfig{Port: 8080, Mode: "release"},
Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db"},
Redis: RedisConfig{DSN: "redis://localhost:6379"},
Server: ServerConfig{Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}},
Database: DatabaseConfig{DSN: "postgres://user:database-secret@postgres:5432/db?sslmode=disable"},
Redis: RedisConfig{DSN: "redis://:redis-secret@redis:6379"},
JWT: JWTConfig{Secret: validSecret},
Log: LogConfig{Level: "info"},
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 1, SweepIntervalS: 1},
Search: SearchConfig{Engine: "meilisearch", Host: "http://localhost:7700"},
Search: SearchConfig{Engine: "meilisearch", Host: "http://localhost:7700", APIKey: "search-secret-123"},
}
}
@@ -241,6 +243,91 @@ func TestValidate_SearchDBFallbackRejectedInRelease(t *testing.T) {
assert.Contains(t, err.Error(), "release mode requires meilisearch")
}
func TestValidate_ReleaseRejectsPlaceholders(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}},
Database: DatabaseConfig{DSN: "postgres://gochat:CHANGE_ME@postgres:5432/gochat"},
Redis: RedisConfig{DSN: "redis://:secret@redis:6379"},
JWT: JWTConfig{Secret: "production-jwt-secret-at-least-32-characters"},
Log: LogConfig{Level: "info"},
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30},
Search: SearchConfig{Engine: "meilisearch", Host: "http://meilisearch:7700", APIKey: "search-secret", TimeoutSeconds: 5},
}
err := Validate(cfg)
assert.ErrorContains(t, err, "database password")
}
func TestValidate_ReleaseRejectsShortSearchKey(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}},
Database: DatabaseConfig{DSN: "postgres://gochat:database-secret@postgres:5432/gochat?sslmode=disable"},
Redis: RedisConfig{DSN: "redis://:redis-secret@redis:6379"},
JWT: JWTConfig{Secret: "production-jwt-secret-at-least-32-characters"},
Log: LogConfig{Level: "info"},
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30},
Search: SearchConfig{Engine: "meilisearch", Host: "http://meilisearch:7700", APIKey: "123456789012345", TimeoutSeconds: 5},
}
assert.ErrorContains(t, Validate(cfg), "search API key must be at least 16 bytes")
}
func TestValidate_ReleaseDatabaseTLS(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}},
Redis: RedisConfig{DSN: "redis://:redis-secret@redis:6379"},
JWT: JWTConfig{Secret: "6vG3uP9qL2mR8xK5nD7sF4hJ1cB0wZyE"},
Log: LogConfig{Level: "info"},
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30},
Search: SearchConfig{Engine: "meilisearch", Host: "http://meilisearch:7700", APIKey: "search-secret-123", TimeoutSeconds: 5},
}
for _, tt := range []struct {
name string
dsn string
wantErr bool
}{
{"external disable", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=disable", true},
{"external missing sslmode", "postgres://gochat:database-secret@db.example.test:5432/gochat", true},
{"external require", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=require", false},
{"built-in compose disable", "postgres://gochat:database-secret@postgres:5432/gochat?sslmode=disable", false},
} {
t.Run(tt.name, func(t *testing.T) {
cfg.Database.DSN = tt.dsn
if tt.wantErr {
assert.ErrorContains(t, Validate(cfg), "production database DSN must use sslmode")
} else {
assert.NoError(t, Validate(cfg))
}
})
}
}
func TestLoadWithEnv_ProductionRequiresOverlay(t *testing.T) {
tmpDir := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(tmpDir, "configs"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "configs", "config.yaml"), []byte("server:\n mode: debug\n"), 0o644))
t.Chdir(tmpDir)
_, err := LoadWithEnv("production")
assert.ErrorContains(t, err, "production config overlay not found")
}
func TestLoadWithEnv_ProductionOverlay(t *testing.T) {
t.Setenv("GOCHAT_DATABASE_DSN", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=require")
t.Setenv("GOCHAT_REDIS_DSN", "redis://:redis-secret@redis:6379")
t.Setenv("GOCHAT_JWT_SECRET", "production-jwt-secret-at-least-32-characters")
t.Setenv("GOCHAT_SEARCH_API_KEY", "search-secret-123")
t.Setenv("GOCHAT_SERVER_CORS_ALLOWED_ORIGINS", "https://chat.example.test")
t.Chdir("../..")
cfg, err := LoadWithEnv("production")
require.NoError(t, err)
assert.Equal(t, "release", cfg.Server.Mode)
assert.Equal(t, []string{"https://chat.example.test"}, cfg.Server.CORS.AllowedOrigins)
assert.NoError(t, Validate(cfg))
}
func TestDatabaseConfig_MigrateDSN(t *testing.T) {
cfg := DatabaseConfig{
DSN: "postgres://gochat:secret@localhost:5432/gochat_db?sslmode=disable",
+30
View File
@@ -111,9 +111,39 @@ func Validate(cfg *Config) error {
return fmt.Errorf("search.timeout_seconds must be >= 0")
}
if cfg.Server.Mode == "release" {
if len(cfg.Server.CORS.AllowedOrigins) == 0 || containsPlaceholder(strings.Join(cfg.Server.CORS.AllowedOrigins, ",")) {
return fmt.Errorf("production CORS origins are required and must not contain placeholders")
}
if dbURL.User == nil || dbURL.User.Username() == "" {
return fmt.Errorf("production database credentials are required")
}
if password, ok := dbURL.User.Password(); !ok || password == "" || containsPlaceholder(password) {
return fmt.Errorf("production database password is required and must not contain placeholders")
}
sslMode := dbURL.Query().Get("sslmode")
if !(dbURL.Hostname() == "postgres" && sslMode == "disable") && sslMode != "require" && sslMode != "verify-ca" && sslMode != "verify-full" {
return fmt.Errorf("production database DSN must use sslmode=require, verify-ca, or verify-full (sslmode=disable is only allowed for the built-in postgres service)")
}
if redisURL.User == nil {
return fmt.Errorf("production Redis credentials are required")
}
if password, ok := redisURL.User.Password(); !ok || password == "" || containsPlaceholder(password) {
return fmt.Errorf("production Redis password is required and must not contain placeholders")
}
if len(cfg.Search.APIKey) < 16 || containsPlaceholder(cfg.Search.APIKey) {
return fmt.Errorf("production search API key must be at least 16 bytes and must not contain placeholders")
}
}
return nil
}
func containsPlaceholder(value string) bool {
value = strings.ToLower(value)
return strings.Contains(value, "change_me") || strings.Contains(value, "change-me") || strings.Contains(value, "changeme")
}
func validateProductionJWTSecret(name, secret string) error {
secret = strings.TrimSpace(secret)
if len([]byte(secret)) < 32 {
+7
View File
@@ -53,6 +53,13 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
# ========== Production Stage ==========
FROM alpine:3.21
ARG VERSION=dev
ARG COMMIT_SHA=unknown
ARG BUILD_DATE=unknown
LABEL org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${COMMIT_SHA}" \
org.opencontainers.image.created="${BUILD_DATE}"
# Install runtime dependencies
RUN apk --no-cache add ca-certificates tzdata curl && addgroup -S gochat && adduser -S gochat -G gochat
+73 -44
View File
@@ -1,106 +1,133 @@
# GoChat Production Environment
# Reference: Chatwoot docker-compose.production.yaml — rails + sidekiq + postgres + redis, restart policies
# Enhanced with pgvector, resource limits, non-root user, health checks
name: gochat-production
version: '3.8'
x-gochat-image: &gochat-image ${GOCHAT_IMAGE_REF:?set GOCHAT_IMAGE_REF to an immutable image digest}
x-gochat-environment: &gochat-environment
GOCHAT_ENV: production
GOCHAT_SERVER_HOST: 0.0.0.0
GOCHAT_SERVER_PORT: 3000
GOCHAT_SERVER_MODE: release
GOCHAT_SERVER_CORS_ALLOWED_ORIGINS: ${GOCHAT_SERVER_CORS_ALLOWED_ORIGINS:?set production CORS origins}
GOCHAT_DATABASE_DSN: ${GOCHAT_DATABASE_DSN:-postgres://${POSTGRES_USER:-gochat}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-gochat_production}?sslmode=disable}
GOCHAT_DATABASE_RUN_MIGRATIONS: "true"
GOCHAT_DATABASE_MIGRATIONS_PATH: /app/migrations
GOCHAT_REDIS_DSN: redis://:${REDIS_PASSWORD:?set REDIS_PASSWORD}@redis:6379
GOCHAT_SEARCH_ENGINE: meilisearch
GOCHAT_SEARCH_HOST: http://meilisearch:7700
GOCHAT_SEARCH_API_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY}
GOCHAT_JWT_SECRET: ${GOCHAT_JWT_SECRET:?set GOCHAT_JWT_SECRET}
GOCHAT_JWT_PREVIOUS_SECRETS: ${GOCHAT_JWT_PREVIOUS_SECRETS:-}
GOCHAT_LOG_LEVEL: info
GOCHAT_LOG_FORMAT: json
GOCHAT_STORAGE_PROVIDER: local
GOCHAT_STORAGE_LOCAL_PATH: /app/storage/uploads
services:
postgres:
image: pgvector/pgvector:pg16
container_name: gochat-postgres
image: ${POSTGRES_IMAGE_REF:-pgvector/pgvector:pg16@sha256:ccc6e83d6e35e931dc7c5def2022729d5a6c370318d099181995567ff1fb4d6b}
restart: always
environment:
POSTGRES_DB: ${POSTGRES_DB:-gochat_production}
POSTGRES_USER: ${POSTGRES_USER:-gochat}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} # MUST be set in .env
ports:
- "127.0.0.1:5432:5432" # Only localhost access
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-gochat}"]
interval: 10s
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
retries: 20
deploy:
resources:
limits:
memory: 1G
redis:
image: redis:7-alpine
container_name: gochat-redis
image: ${REDIS_IMAGE_REF:-redis:7-alpine@sha256:ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf}
restart: always
command: redis-server --requirepass "${REDIS_PASSWORD}" --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
ports:
- "127.0.0.1:6379:6379" # Only localhost access
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:?set REDIS_PASSWORD}", "--appendonly", "yes", "--maxmemory", "512mb", "--maxmemory-policy", "allkeys-lru"]
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD:?set REDIS_PASSWORD}
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
test: ["CMD-SHELL", "redis-cli -a '$${REDIS_PASSWORD}' ping"]
interval: 5s
timeout: 5s
retries: 5
retries: 20
deploy:
resources:
limits:
memory: 512M
meilisearch:
image: ${MEILI_IMAGE_REF:-getmeili/meilisearch:v1.13@sha256:bed3fb650e62da53145777204891159242f6ea4ce69e215b36223af4aa64a0ae}
restart: always
environment:
MEILI_ENV: production
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY}
MEILI_NO_ANALYTICS: "true"
volumes:
- meili_data:/meili_data
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--spider", "http://127.0.0.1:7700/health"]
interval: 5s
timeout: 5s
retries: 20
gochat:
image: gochat/gochat:${GOCHAT_VERSION:-latest}
container_name: gochat-app
image: *gochat-image
restart: always
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
meilisearch:
condition: service_healthy
environment: *gochat-environment
ports:
- "127.0.0.1:3000:3000" # Reverse proxy should handle external access
env_file: ../../.env
environment:
- GOCHAT_ENV=production
- GOCHAT_DATABASE_DSN=postgres://${POSTGRES_USER:-gochat}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-gochat_production}?sslmode=disable
- GOCHAT_REDIS_DSN=redis://:${REDIS_PASSWORD}@redis:6379
- "127.0.0.1:${GOCHAT_PORT:-3000}:3000"
volumes:
- gochat_storage:/app/storage
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3000/health"]
interval: 10s
timeout: 5s
start_period: 15s
retries: 3
retries: 30
deploy:
resources:
limits:
memory: 512M
cpus: '1.0'
cpus: "1.0"
reservations:
memory: 256M
cpus: '0.5'
cpus: "0.5"
worker:
image: gochat/gochat:${GOCHAT_VERSION:-latest}
container_name: gochat-worker
image: *gochat-image
restart: always
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
meilisearch:
condition: service_healthy
command: ["serve", "--worker-only"]
env_file: ../../.env
environment:
- GOCHAT_ENV=production
- GOCHAT_DATABASE_DSN=postgres://${POSTGRES_USER:-gochat}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-gochat_production}?sslmode=disable
- GOCHAT_REDIS_DSN=redis://:${REDIS_PASSWORD}@redis:6379
<<: *gochat-environment
GOCHAT_DATABASE_RUN_MIGRATIONS: "false"
volumes:
- gochat_storage:/app/storage
deploy:
resources:
limits:
memory: 512M
cpus: '1.0'
cpus: "1.0"
shangwutong:
image: ${SHANGWUTONG_IMAGE:-ghcr.io/gochat/shangwutong:latest}
container_name: gochat-shangwutong
image: ${SHANGWUTONG_IMAGE_REF:?set SHANGWUTONG_IMAGE_REF to an immutable image digest}
restart: always
stop_grace_period: ${SWT_SHUTDOWN_TIMEOUT:-30s}
environment:
@@ -125,13 +152,15 @@ services:
resources:
limits:
memory: 512M
cpus: '1.0'
cpus: "1.0"
reservations:
memory: 128M
cpus: '0.25'
cpus: "0.25"
volumes:
postgres_data:
redis_data:
meili_data:
gochat_storage:
shangwutong_data:
shangwutong_backups:
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
export LC_ALL=C
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
compose_args=(-f "$script_dir/docker-compose.prod.yml")
if (($#)); then
env_file=$1
set -a
source "$env_file"
set +a
compose_args=(--env-file "$env_file" "${compose_args[@]}")
fi
required=(GOCHAT_IMAGE_REF SHANGWUTONG_IMAGE_REF GOCHAT_SERVER_CORS_ALLOWED_ORIGINS POSTGRES_PASSWORD REDIS_PASSWORD MEILI_MASTER_KEY GOCHAT_JWT_SECRET)
for name in "${required[@]}"; do
value=${!name:-}
if [[ -z $value || ${value^^} == *CHANGE_ME* ]]; then
echo "$name is required and must not contain CHANGE_ME" >&2
exit 1
fi
done
if ((${#GOCHAT_JWT_SECRET} < 32)); then
echo "GOCHAT_JWT_SECRET must be at least 32 characters" >&2
exit 1
fi
if ((${#MEILI_MASTER_KEY} < 16)); then
echo "MEILI_MASTER_KEY must be at least 16 bytes" >&2
exit 1
fi
if [[ -n ${GOCHAT_DATABASE_DSN:-} && ! $GOCHAT_DATABASE_DSN =~ (^|[?&])sslmode=(require|verify-ca|verify-full)(&|$) ]]; then
echo "GOCHAT_DATABASE_DSN must explicitly require TLS for an external database" >&2
exit 1
fi
images=$(docker compose "${compose_args[@]}" config --images)
while IFS= read -r image; do
if [[ $image =~ @sha256:[0-9a-fA-F]{64}$ ]]; then
continue
fi
echo "production image must be pinned to a sha256 digest: $image" >&2
exit 1
done <<< "$images"
echo "production preflight passed"