From 68244adf8ee5309d98bcdb6c9ec2edea420d7eec Mon Sep 17 00:00:00 2001 From: Roman Fischer Date: Wed, 11 Feb 2026 20:18:19 +0100 Subject: [PATCH] Initial commit --- .env.dist | 31 +++++++++++++++ .gitignore | 1 + README.md | 57 ++++++++++++++++++++++++++ docker-compose.yml | 84 +++++++++++++++++++++++++++++++++++++++ scripts/create-db-user.sh | 76 +++++++++++++++++++++++++++++++++++ 5 files changed, 249 insertions(+) create mode 100644 .env.dist create mode 100644 .gitignore create mode 100644 docker-compose.yml create mode 100755 scripts/create-db-user.sh diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..651df4e --- /dev/null +++ b/.env.dist @@ -0,0 +1,31 @@ +# +# Shared network name used by this stack and all app stacks. +# In app repos, reference this network as external. +# +SHARED_NETWORK_NAME=web + +# +# Domains +# +TRAEFIK_DASHBOARD_DOMAIN=traefik.example.com +ADMINER_DOMAIN=db.example.com + +# +# Let's Encrypt +# +ACME_EMAIL=admin@example.com + +# +# HTTP basic auth users for protected routes. +# Generate with: htpasswd -nbB admin 'your-password' +# IMPORTANT: escape all "$" as "$$" in .env values. +# +TRAEFIK_DASHBOARD_AUTH=admin:replace_with_escaped_htpasswd_hash +ADMINER_AUTH=admin:replace_with_escaped_htpasswd_hash + +# +# Shared Postgres credentials (used by app stacks) +# +POSTGRES_DB=app +POSTGRES_USER=admin +POSTGRES_PASSWORD=change-this-password diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/README.md b/README.md index d3de67e..4a542ac 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,59 @@ # server-config +Core infrastructure stack for multiple independent web apps on one server. + +## Includes + +- Traefik reverse proxy with automatic TLS certificates (Let's Encrypt) +- Shared PostgreSQL database +- Shared Adminer instance behind Traefik + basic auth +- Shared Docker network so app repositories can join this stack + +## Setup + +1. Copy `.env.dist` to `.env`. +2. Set real domains and credentials in `.env`. +3. Generate auth hashes with `htpasswd -nbB admin 'your-password'`. +4. Escape every `$` in hashes as `$$` before putting them into `.env`. +5. Start the stack: + +```bash +docker compose up -d +``` + +On first initialization of the Postgres volume, the `POSTGRES_USER` from `.env` becomes the bootstrap superuser (default: `admin`). + +## For app repositories + +Each app should connect to the same external Docker network: + +```yaml +networks: + web: + external: true + name: web +``` + +Use the shared Postgres host `postgres` on that network with credentials from this stack's `.env`. + +## Postgres note + +This stack uses the latest Postgres image behavior (v18+), so the volume is mounted at `/var/lib/postgresql`. + +## Create DB user per app + +Use the helper script to create (or update) a dedicated DB user and create a dedicated database: + +```bash +./scripts/create-db-user.sh project1_db project1_user +``` + +Or provide your own password: + +```bash +./scripts/create-db-user.sh project1_db project1_user "your-strong-password" +``` + +The script creates restricted app users (`NOSUPERUSER`, `NOCREATEDB`, `NOCREATEROLE`, `NOREPLICATION`) and limits default DB access by revoking `PUBLIC` access. + +Note: Postgres cannot natively block "Adminer UI login" for a user while still allowing that same user/password for app DB connections. If you need this hard enforcement, the next step is a custom Adminer plugin that only allows selected DB usernames (for example only `admin`). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..38b7d46 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,84 @@ +services: + traefik: + image: traefik:v3 + container_name: traefik + restart: unless-stopped + command: + - --api.dashboard=true + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL} + - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json + - --certificatesresolvers.letsencrypt.acme.httpchallenge=true + - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - traefik_letsencrypt:/letsencrypt + labels: + - traefik.enable=true + - traefik.http.routers.traefik.rule=Host(`${TRAEFIK_DASHBOARD_DOMAIN}`) + - traefik.http.routers.traefik.entrypoints=websecure + - traefik.http.routers.traefik.tls=true + - traefik.http.routers.traefik.tls.certresolver=letsencrypt + - traefik.http.routers.traefik.service=api@internal + - traefik.http.routers.traefik.middlewares=dashboard-auth + - traefik.http.middlewares.dashboard-auth.basicauth.users=${TRAEFIK_DASHBOARD_AUTH} + networks: + - web + + postgres: + image: postgres:latest + container_name: postgres + restart: unless-stopped + environment: + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - web + + adminer: + image: adminer:latest + container_name: adminer + restart: unless-stopped + environment: + - ADMINER_DEFAULT_SERVER=postgres + - ADMINER_DESIGN=pepa-linha + depends_on: + postgres: + condition: service_healthy + labels: + - traefik.enable=true + - traefik.http.routers.adminer.rule=Host(`${ADMINER_DOMAIN}`) + - traefik.http.routers.adminer.entrypoints=websecure + - traefik.http.routers.adminer.tls=true + - traefik.http.routers.adminer.tls.certresolver=letsencrypt + - traefik.http.routers.adminer.middlewares=adminer-auth + - traefik.http.middlewares.adminer-auth.basicauth.users=${ADMINER_AUTH} + - traefik.http.services.adminer.loadbalancer.server.port=8080 + networks: + - web + +volumes: + traefik_letsencrypt: + postgres_data: + +networks: + web: + name: ${SHARED_NETWORK_NAME} + driver: bridge + attachable: true diff --git a/scripts/create-db-user.sh b/scripts/create-db-user.sh new file mode 100755 index 0000000..e2ee822 --- /dev/null +++ b/scripts/create-db-user.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -lt 2 || $# -gt 3 ]]; then + echo "Usage: $0 [db_password]" + echo "Example: $0 project1_db project1_user" + echo "Example: $0 project1_db project1_user 'very-strong-password'" + exit 1 +fi + +DB_NAME="$1" +DB_USER="$2" +DB_PASSWORD="${3:-}" + +if [[ ! "${DB_NAME}" =~ ^[a-zA-Z0-9_]+$ ]]; then + echo "Error: db_name must match [a-zA-Z0-9_]+" + exit 1 +fi + +if [[ ! "${DB_USER}" =~ ^[a-zA-Z0-9_]+$ ]]; then + echo "Error: db_user must match [a-zA-Z0-9_]+" + exit 1 +fi + +if [[ -z "${DB_PASSWORD}" ]]; then + # Generate a random URL-safe password if none was provided. + DB_PASSWORD="$(openssl rand -base64 24 | tr -d '\n' | tr '/+' '_-')" +fi + +POSTGRES_ADMIN_USER="${POSTGRES_USER:-admin}" +POSTGRES_ADMIN_DB="${POSTGRES_DB:-app}" +POSTGRES_CONTAINER="${POSTGRES_CONTAINER_NAME:-postgres}" + +sql_escape() { + printf "%s" "$1" | sed "s/'/''/g" +} + +DB_NAME_ESCAPED="$(sql_escape "${DB_NAME}")" +DB_USER_ESCAPED="$(sql_escape "${DB_USER}")" +DB_PASSWORD_ESCAPED="$(sql_escape "${DB_PASSWORD}")" + +echo "Creating database user and database in container '${POSTGRES_CONTAINER}'..." + +ROLE_EXISTS="$(docker exec "${POSTGRES_CONTAINER}" psql -U "${POSTGRES_ADMIN_USER}" -d "${POSTGRES_ADMIN_DB}" -tAc "SELECT 1 FROM pg_roles WHERE rolname='${DB_USER_ESCAPED}'" || true)" +if [[ "${ROLE_EXISTS}" == "1" ]]; then + docker exec "${POSTGRES_CONTAINER}" psql -v ON_ERROR_STOP=1 -U "${POSTGRES_ADMIN_USER}" -d "${POSTGRES_ADMIN_DB}" \ + -c "ALTER ROLE \"${DB_USER}\" WITH LOGIN PASSWORD '${DB_PASSWORD_ESCAPED}' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION;" +else + docker exec "${POSTGRES_CONTAINER}" psql -v ON_ERROR_STOP=1 -U "${POSTGRES_ADMIN_USER}" -d "${POSTGRES_ADMIN_DB}" \ + -c "CREATE ROLE \"${DB_USER}\" LOGIN PASSWORD '${DB_PASSWORD_ESCAPED}' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION;" +fi + +DB_EXISTS="$(docker exec "${POSTGRES_CONTAINER}" psql -U "${POSTGRES_ADMIN_USER}" -d "${POSTGRES_ADMIN_DB}" -tAc "SELECT 1 FROM pg_database WHERE datname='${DB_NAME_ESCAPED}'" || true)" +if [[ "${DB_EXISTS}" == "1" ]]; then + echo "Database '${DB_NAME}' already exists." +else + docker exec "${POSTGRES_CONTAINER}" psql -v ON_ERROR_STOP=1 -U "${POSTGRES_ADMIN_USER}" -d "${POSTGRES_ADMIN_DB}" \ + -c "CREATE DATABASE \"${DB_NAME}\" OWNER \"${DB_USER}\";" +fi + +docker exec "${POSTGRES_CONTAINER}" psql -v ON_ERROR_STOP=1 -U "${POSTGRES_ADMIN_USER}" -d "${POSTGRES_ADMIN_DB}" \ + -c "REVOKE CONNECT, TEMP ON DATABASE \"${DB_NAME}\" FROM PUBLIC;" +docker exec "${POSTGRES_CONTAINER}" psql -v ON_ERROR_STOP=1 -U "${POSTGRES_ADMIN_USER}" -d "${POSTGRES_ADMIN_DB}" \ + -c "GRANT CONNECT, TEMP ON DATABASE \"${DB_NAME}\" TO \"${DB_USER}\";" + +cat <