feat(deploy): add vps deployment assets
This commit is contained in:
26
deploy/vps/.env.example
Normal file
26
deploy/vps/.env.example
Normal file
@@ -0,0 +1,26 @@
|
||||
APP_DOMAIN=demo.example.com
|
||||
PUBLIC_BASE_DOMAIN=example.com
|
||||
|
||||
TENANT_ID=11111111-1111-1111-1111-111111111111
|
||||
TENANT_SUBDOMAIN=demo
|
||||
MASTER_DATABASE_NAME=classeo_master
|
||||
TENANT_DATABASE_NAME=classeo_tenant_demo
|
||||
POSTGRES_USER=classeo
|
||||
POSTGRES_PASSWORD=change-this-db-password
|
||||
|
||||
APP_SECRET=change-this-app-secret
|
||||
JWT_PASSPHRASE=change-this-jwt-passphrase
|
||||
|
||||
TRUSTED_PROXIES='127.0.0.1/32,172.16.0.0/12'
|
||||
TRUSTED_HOSTS='^(.+\.)?example\.com$'
|
||||
CORS_ALLOW_ORIGIN='^https://([\w-]+\.)?example\.com$'
|
||||
|
||||
MAILER_DSN=null://null
|
||||
ADMIN_ALERT_EMAIL=admin@example.com
|
||||
|
||||
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
|
||||
TURNSTILE_FAIL_OPEN=true
|
||||
PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
||||
|
||||
SENTRY_DSN=
|
||||
SENTRY_ENVIRONMENT=production
|
||||
15
deploy/vps/Caddyfile
Normal file
15
deploy/vps/Caddyfile
Normal file
@@ -0,0 +1,15 @@
|
||||
{$APP_DOMAIN} {
|
||||
encode zstd gzip
|
||||
|
||||
handle /api/* {
|
||||
reverse_proxy php:8000
|
||||
}
|
||||
|
||||
handle /uploads/* {
|
||||
reverse_proxy php:8000
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy frontend:3000
|
||||
}
|
||||
}
|
||||
283
deploy/vps/generate-env.sh
Executable file
283
deploy/vps/generate-env.sh
Executable file
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
ROOT_DIR=$(cd -- "${SCRIPT_DIR}/../.." && pwd)
|
||||
EXAMPLE_FILE="${SCRIPT_DIR}/.env.example"
|
||||
DEFAULT_TARGET_FILE="${SCRIPT_DIR}/.env"
|
||||
TARGET_FILE="${1:-${DEFAULT_TARGET_FILE}}"
|
||||
COMPOSE_FILE="${ROOT_DIR}/compose.prod.yaml"
|
||||
|
||||
if [ ! -f "${EXAMPLE_FILE}" ]; then
|
||||
echo "Missing template: ${EXAMPLE_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v openssl >/dev/null 2>&1; then
|
||||
echo "openssl is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
load_existing_env() {
|
||||
if [ -f "${TARGET_FILE}" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "${TARGET_FILE}"
|
||||
set +a
|
||||
fi
|
||||
}
|
||||
|
||||
generate_uuid() {
|
||||
if command_exists uuidgen; then
|
||||
uuidgen | tr '[:upper:]' '[:lower:]'
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -r /proc/sys/kernel/random/uuid ]; then
|
||||
tr '[:upper:]' '[:lower:]' < /proc/sys/kernel/random/uuid
|
||||
return
|
||||
fi
|
||||
|
||||
openssl rand -hex 16 | sed -E 's/(.{8})(.{4})(.{4})(.{4})(.{12})/\1-\2-\3-\4-\5/'
|
||||
}
|
||||
|
||||
generate_secret() {
|
||||
openssl rand -hex "${1}"
|
||||
}
|
||||
|
||||
detect_public_ipv4() {
|
||||
if ! command_exists curl; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
for url in \
|
||||
"https://api.ipify.org" \
|
||||
"https://ifconfig.me/ip"
|
||||
do
|
||||
ip=$(curl -fsSL --max-time 3 "${url}" 2>/dev/null || true)
|
||||
if printf '%s' "${ip}" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){3}$'; then
|
||||
printf '%s\n' "${ip}"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
prompt() {
|
||||
local prompt_text="${1}"
|
||||
local default_value="${2:-}"
|
||||
local answer
|
||||
|
||||
if [ -n "${default_value}" ]; then
|
||||
printf "%s [%s]: " "${prompt_text}" "${default_value}" >&2
|
||||
else
|
||||
printf "%s: " "${prompt_text}" >&2
|
||||
fi
|
||||
|
||||
IFS= read -r answer
|
||||
if [ -z "${answer}" ]; then
|
||||
printf '%s\n' "${default_value}"
|
||||
else
|
||||
printf '%s\n' "${answer}"
|
||||
fi
|
||||
}
|
||||
|
||||
prompt_required() {
|
||||
local prompt_text="${1}"
|
||||
local default_value="${2:-}"
|
||||
local value
|
||||
|
||||
while true; do
|
||||
value=$(prompt "${prompt_text}" "${default_value}")
|
||||
if [ -n "${value}" ]; then
|
||||
printf '%s\n' "${value}"
|
||||
return 0
|
||||
fi
|
||||
echo "This value is required." >&2
|
||||
done
|
||||
}
|
||||
|
||||
prompt_yes_no() {
|
||||
local prompt_text="${1}"
|
||||
local default_answer="${2:-y}"
|
||||
local suffix="[Y/n]"
|
||||
|
||||
if [ "${default_answer}" = "n" ]; then
|
||||
suffix="[y/N]"
|
||||
fi
|
||||
|
||||
while true; do
|
||||
printf "%s %s: " "${prompt_text}" "${suffix}"
|
||||
IFS= read -r answer
|
||||
answer=${answer:-${default_answer}}
|
||||
|
||||
case "${answer}" in
|
||||
y|Y|yes|YES)
|
||||
return 0
|
||||
;;
|
||||
n|N|no|NO)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Please answer y or n."
|
||||
done
|
||||
}
|
||||
|
||||
sanitize_database_suffix() {
|
||||
printf '%s' "${1}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/_/g; s/^_+//; s/_+$//'
|
||||
}
|
||||
|
||||
escape_regex() {
|
||||
printf '%s' "${1}" | sed -E 's/[][(){}.^$*+?|\\-]/\\&/g'
|
||||
}
|
||||
|
||||
write_env_file() {
|
||||
cat > "${TARGET_FILE}" <<EOF
|
||||
APP_DOMAIN=${APP_DOMAIN}
|
||||
PUBLIC_BASE_DOMAIN=${PUBLIC_BASE_DOMAIN}
|
||||
|
||||
TENANT_ID=${TENANT_ID}
|
||||
TENANT_SUBDOMAIN=${TENANT_SUBDOMAIN}
|
||||
MASTER_DATABASE_NAME=${MASTER_DATABASE_NAME}
|
||||
TENANT_DATABASE_NAME=${TENANT_DATABASE_NAME}
|
||||
POSTGRES_USER=${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
|
||||
APP_SECRET=${APP_SECRET}
|
||||
JWT_PASSPHRASE=${JWT_PASSPHRASE}
|
||||
|
||||
TRUSTED_PROXIES='${TRUSTED_PROXIES}'
|
||||
TRUSTED_HOSTS='${TRUSTED_HOSTS}'
|
||||
CORS_ALLOW_ORIGIN='${CORS_ALLOW_ORIGIN}'
|
||||
|
||||
MAILER_DSN=${MAILER_DSN}
|
||||
ADMIN_ALERT_EMAIL=${ADMIN_ALERT_EMAIL}
|
||||
|
||||
TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY}
|
||||
TURNSTILE_FAIL_OPEN=${TURNSTILE_FAIL_OPEN}
|
||||
PUBLIC_TURNSTILE_SITE_KEY=${PUBLIC_TURNSTILE_SITE_KEY}
|
||||
|
||||
SENTRY_DSN=${SENTRY_DSN}
|
||||
SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
|
||||
EOF
|
||||
}
|
||||
|
||||
validate_compose_config() {
|
||||
if ! command_exists docker; then
|
||||
echo "docker not found, skipping compose validation."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ ! -f "${COMPOSE_FILE}" ]; then
|
||||
echo "compose.prod.yaml not found, skipping compose validation."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if docker compose --env-file "${TARGET_FILE}" -f "${COMPOSE_FILE}" config >/dev/null; then
|
||||
echo "docker compose validation: OK"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "docker compose validation failed." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
load_existing_env
|
||||
|
||||
echo "Classeo VPS env generator"
|
||||
echo
|
||||
|
||||
PUBLIC_IP_DEFAULT=${PUBLIC_IP:-$(detect_public_ipv4 || true)}
|
||||
if [ -n "${PUBLIC_IP_DEFAULT}" ]; then
|
||||
echo "Detected public IPv4: ${PUBLIC_IP_DEFAULT}"
|
||||
echo
|
||||
fi
|
||||
|
||||
DEFAULT_BASE_DOMAIN=${PUBLIC_BASE_DOMAIN:-example.com}
|
||||
PUBLIC_BASE_DOMAIN=$(prompt_required "Base domain" "${DEFAULT_BASE_DOMAIN}")
|
||||
|
||||
DEFAULT_TENANT_SUBDOMAIN=${TENANT_SUBDOMAIN:-demo}
|
||||
TENANT_SUBDOMAIN=$(prompt_required "Tenant subdomain" "${DEFAULT_TENANT_SUBDOMAIN}")
|
||||
|
||||
DEFAULT_APP_DOMAIN=${APP_DOMAIN:-${TENANT_SUBDOMAIN}.${PUBLIC_BASE_DOMAIN}}
|
||||
APP_DOMAIN=$(prompt_required "Full application domain" "${DEFAULT_APP_DOMAIN}")
|
||||
|
||||
DEFAULT_ADMIN_ALERT_EMAIL=${ADMIN_ALERT_EMAIL:-admin@${PUBLIC_BASE_DOMAIN}}
|
||||
ADMIN_ALERT_EMAIL=$(prompt_required "Admin alert email" "${DEFAULT_ADMIN_ALERT_EMAIL}")
|
||||
|
||||
DEFAULT_POSTGRES_USER=${POSTGRES_USER:-classeo}
|
||||
POSTGRES_USER=$(prompt_required "PostgreSQL user" "${DEFAULT_POSTGRES_USER}")
|
||||
|
||||
DEFAULT_MASTER_DATABASE_NAME=${MASTER_DATABASE_NAME:-classeo_master}
|
||||
MASTER_DATABASE_NAME=$(prompt_required "Master database name" "${DEFAULT_MASTER_DATABASE_NAME}")
|
||||
|
||||
DEFAULT_TENANT_ID=${TENANT_ID:-$(generate_uuid)}
|
||||
TENANT_ID=$(prompt_required "Tenant UUID" "${DEFAULT_TENANT_ID}")
|
||||
|
||||
DATABASE_SUFFIX=$(sanitize_database_suffix "${TENANT_SUBDOMAIN}")
|
||||
DEFAULT_TENANT_DATABASE_NAME=${TENANT_DATABASE_NAME:-classeo_tenant_${DATABASE_SUFFIX}}
|
||||
TENANT_DATABASE_NAME=$(prompt_required "Tenant database name" "${DEFAULT_TENANT_DATABASE_NAME}")
|
||||
|
||||
DEFAULT_MAILER_DSN=${MAILER_DSN:-null://null}
|
||||
MAILER_DSN=$(prompt_required "Mailer DSN" "${DEFAULT_MAILER_DSN}")
|
||||
|
||||
DEFAULT_SENTRY_DSN=${SENTRY_DSN:-}
|
||||
SENTRY_DSN=$(prompt "Sentry DSN (optional)" "${DEFAULT_SENTRY_DSN}")
|
||||
|
||||
if prompt_yes_no "Use Cloudflare Turnstile test keys" "y"; then
|
||||
TURNSTILE_SECRET_KEY="1x0000000000000000000000000000000AA"
|
||||
TURNSTILE_FAIL_OPEN="true"
|
||||
PUBLIC_TURNSTILE_SITE_KEY="1x00000000000000000000AA"
|
||||
else
|
||||
TURNSTILE_SECRET_KEY=$(prompt_required "Turnstile secret key" "${TURNSTILE_SECRET_KEY:-}")
|
||||
TURNSTILE_FAIL_OPEN=$(prompt_required "Turnstile fail open (true/false)" "${TURNSTILE_FAIL_OPEN:-false}")
|
||||
PUBLIC_TURNSTILE_SITE_KEY=$(prompt_required "Turnstile site key" "${PUBLIC_TURNSTILE_SITE_KEY:-}")
|
||||
fi
|
||||
|
||||
TRUSTED_PROXIES=${TRUSTED_PROXIES:-127.0.0.1/32,172.16.0.0/12}
|
||||
ESCAPED_BASE_DOMAIN=$(escape_regex "${PUBLIC_BASE_DOMAIN}")
|
||||
TRUSTED_HOSTS="^(.+\\.)?${ESCAPED_BASE_DOMAIN}\$"
|
||||
CORS_ALLOW_ORIGIN="^https://([\\w-]+\\.)?${ESCAPED_BASE_DOMAIN}\$"
|
||||
POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-$(generate_secret 16)}
|
||||
APP_SECRET=${APP_SECRET:-$(generate_secret 32)}
|
||||
JWT_PASSPHRASE=${JWT_PASSPHRASE:-$(generate_secret 24)}
|
||||
SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-production}
|
||||
|
||||
echo
|
||||
echo "Summary"
|
||||
echo "- App domain: ${APP_DOMAIN}"
|
||||
echo "- Base domain: ${PUBLIC_BASE_DOMAIN}"
|
||||
echo "- Tenant subdomain: ${TENANT_SUBDOMAIN}"
|
||||
echo "- Tenant UUID: ${TENANT_ID}"
|
||||
echo "- Target file: ${TARGET_FILE}"
|
||||
if [ -n "${PUBLIC_IP_DEFAULT}" ]; then
|
||||
echo "- Public IPv4: ${PUBLIC_IP_DEFAULT}"
|
||||
fi
|
||||
echo
|
||||
|
||||
if ! prompt_yes_no "Write ${TARGET_FILE}" "y"; then
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "${TARGET_FILE}")"
|
||||
|
||||
if [ -f "${TARGET_FILE}" ]; then
|
||||
cp "${TARGET_FILE}" "${TARGET_FILE}.bak"
|
||||
echo "Backup created: ${TARGET_FILE}.bak"
|
||||
fi
|
||||
|
||||
write_env_file
|
||||
echo "Generated ${TARGET_FILE}"
|
||||
|
||||
validate_compose_config
|
||||
|
||||
echo
|
||||
echo "Next steps:"
|
||||
echo "1. Review ${TARGET_FILE}"
|
||||
echo "2. Run ./deploy/vps/generate-jwt.sh"
|
||||
echo "3. Run docker compose --env-file ${TARGET_FILE} -f compose.prod.yaml up -d --build"
|
||||
24
deploy/vps/generate-jwt.sh
Executable file
24
deploy/vps/generate-jwt.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ ! -f deploy/vps/.env ]; then
|
||||
echo "Missing deploy/vps/.env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
JWT_PASSPHRASE=$(grep '^JWT_PASSPHRASE=' deploy/vps/.env | cut -d= -f2-)
|
||||
|
||||
if [ -z "$JWT_PASSPHRASE" ]; then
|
||||
echo "JWT_PASSPHRASE is empty in deploy/vps/.env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p backend/config/jwt
|
||||
|
||||
openssl genrsa -aes256 -passout "pass:${JWT_PASSPHRASE}" -out backend/config/jwt/private.pem 4096
|
||||
openssl rsa -pubout -passin "pass:${JWT_PASSPHRASE}" -in backend/config/jwt/private.pem -out backend/config/jwt/public.pem
|
||||
|
||||
chmod 600 backend/config/jwt/private.pem
|
||||
chmod 644 backend/config/jwt/public.pem
|
||||
|
||||
echo "JWT keypair generated in backend/config/jwt"
|
||||
15
deploy/vps/postgres/01-create-tenant-db.sh
Executable file
15
deploy/vps/postgres/01-create-tenant-db.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ -z "${TENANT_DATABASE_NAME:-}" ] || [ "${TENANT_DATABASE_NAME}" = "${POSTGRES_DB}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname postgres <<-EOSQL
|
||||
SELECT format('CREATE DATABASE %I', '${TENANT_DATABASE_NAME}')
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_database
|
||||
WHERE datname = '${TENANT_DATABASE_NAME}'
|
||||
) \gexec
|
||||
EOSQL
|
||||
Reference in New Issue
Block a user