Initial commit
This commit is contained in:
31
.env.dist
Normal file
31
.env.dist
Normal file
@@ -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
|
||||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
57
README.md
57
README.md
@@ -1,2 +1,59 @@
|
|||||||
# server-config
|
# 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`).
|
||||||
|
|||||||
84
docker-compose.yml
Normal file
84
docker-compose.yml
Normal file
@@ -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
|
||||||
76
scripts/create-db-user.sh
Executable file
76
scripts/create-db-user.sh
Executable file
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -lt 2 || $# -gt 3 ]]; then
|
||||||
|
echo "Usage: $0 <db_name> <db_user> [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 <<EOF
|
||||||
|
Done.
|
||||||
|
|
||||||
|
Use these values in your app .env:
|
||||||
|
DB_HOST=postgres
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=${DB_NAME}
|
||||||
|
DB_USER=${DB_USER}
|
||||||
|
DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
EOF
|
||||||
Reference in New Issue
Block a user