Last active
November 18, 2025 10:37
-
-
Save drhema/0eeca2102acbc5899e2652596ea5f7f4 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # PostgreSQL 16 Server Setup for Multi-Tenant SaaS | |
| # DNS Challenge Edition - Compatible with Nginx Proxy Manager | |
| # Complete installation with SSL via Cloudflare DNS, control database, and management utilities | |
| # Run on Ubuntu 24.04 as root | |
| # | |
| set -euo pipefail | |
| # Colors for output | |
| GREEN="\033[0;32m" | |
| CYAN="\033[0;36m" | |
| YELLOW="\033[1;33m" | |
| RED="\033[0;31m" | |
| NC="\033[0m" | |
| # Logging functions | |
| log_info() { | |
| echo -e "${CYAN}[INFO]${NC} $1" | |
| } | |
| log_success() { | |
| echo -e "${GREEN}[SUCCESS]${NC} $1" | |
| } | |
| log_warn() { | |
| echo -e "${YELLOW}[WARN]${NC} $1" | |
| } | |
| log_error() { | |
| echo -e "${RED}[ERROR]${NC} $1" | |
| } | |
| # Check if running as root | |
| ensure_root() { | |
| if [[ $EUID -ne 0 ]]; then | |
| log_error "This script must be run as root (use sudo)" | |
| exit 1 | |
| fi | |
| } | |
| # Display banner | |
| show_banner() { | |
| echo -e "${GREEN}" | |
| cat <<'BANNER' | |
| ╔═══════════════════════════════════════════════════════════════╗ | |
| ║ ║ | |
| ║ PostgreSQL 16 Multi-Tenant SaaS Server Setup ║ | |
| ║ DNS Challenge Edition - Cloudflare DNS ║ | |
| ║ Compatible with Nginx Proxy Manager ║ | |
| ║ ║ | |
| ╚═══════════════════════════════════════════════════════════════╝ | |
| BANNER | |
| echo -e "${NC}" | |
| } | |
| # Prompt for configuration | |
| get_configuration() { | |
| echo -e "${CYAN}Please provide the following information:${NC}\n" | |
| read -p "Enter your domain for SSL (e.g., db.yourdomain.com): " DOMAIN | |
| while [[ -z "$DOMAIN" ]]; do | |
| log_error "Domain cannot be empty" | |
| read -p "Enter your domain for SSL: " DOMAIN | |
| done | |
| read -p "Enter email for Let's Encrypt notifications: " EMAIL | |
| while [[ -z "$EMAIL" ]]; do | |
| log_error "Email cannot be empty" | |
| read -p "Enter email for Let's Encrypt: " EMAIL | |
| done | |
| echo "" | |
| echo -e "${CYAN}Choose SSL certificate method:${NC}" | |
| echo " 1) Cloudflare DNS API (Recommended - Automated)" | |
| echo " 2) Manual DNS TXT Record (No API token needed)" | |
| echo "" | |
| read -p "Select option [1-2]: " SSL_METHOD | |
| while [[ ! "$SSL_METHOD" =~ ^[1-2]$ ]]; do | |
| log_error "Please select 1 or 2" | |
| read -p "Select option [1-2]: " SSL_METHOD | |
| done | |
| if [[ "$SSL_METHOD" == "1" ]]; then | |
| echo "" | |
| echo -e "${YELLOW}You need a Cloudflare API Token with DNS edit permissions.${NC}" | |
| echo -e "${YELLOW}Create one at: https://dash.cloudflare.com/profile/api-tokens${NC}" | |
| echo -e "${YELLOW}Use the 'Edit zone DNS' template${NC}" | |
| echo "" | |
| read -p "Enter your Cloudflare API Token: " CF_API_TOKEN | |
| while [[ -z "$CF_API_TOKEN" ]]; do | |
| log_error "API Token cannot be empty" | |
| read -p "Enter your Cloudflare API Token: " CF_API_TOKEN | |
| done | |
| fi | |
| read -sp "Enter PostgreSQL admin password (min 12 chars): " POSTGRES_ADMIN_PASSWORD | |
| echo | |
| while [[ ${#POSTGRES_ADMIN_PASSWORD} -lt 12 ]]; do | |
| log_error "Password must be at least 12 characters" | |
| read -sp "Enter PostgreSQL admin password: " POSTGRES_ADMIN_PASSWORD | |
| echo | |
| done | |
| read -sp "Confirm PostgreSQL admin password: " POSTGRES_ADMIN_PASSWORD_CONFIRM | |
| echo | |
| while [[ "$POSTGRES_ADMIN_PASSWORD" != "$POSTGRES_ADMIN_PASSWORD_CONFIRM" ]]; do | |
| log_error "Passwords do not match" | |
| read -sp "Enter PostgreSQL admin password: " POSTGRES_ADMIN_PASSWORD | |
| echo | |
| read -sp "Confirm password: " POSTGRES_ADMIN_PASSWORD_CONFIRM | |
| echo | |
| done | |
| echo "" | |
| log_info "Configuration summary:" | |
| echo " Domain: $DOMAIN" | |
| echo " Email: $EMAIL" | |
| echo " SSL Method: $([ "$SSL_METHOD" == "1" ] && echo "Cloudflare DNS API" || echo "Manual DNS")" | |
| echo " Password: ********" | |
| echo "" | |
| read -p "Continue with installation? [y/N]: " -n 1 -r | |
| echo | |
| if [[ ! $REPLY =~ ^[Yy]$ ]]; then | |
| log_error "Installation cancelled" | |
| exit 1 | |
| fi | |
| } | |
| # Update system packages | |
| update_system() { | |
| log_info "Updating system packages..." | |
| export DEBIAN_FRONTEND=noninteractive | |
| apt-get update -qq | |
| apt-get upgrade -y -qq | |
| log_success "System updated" | |
| } | |
| # Install PostgreSQL 16 | |
| install_postgresql() { | |
| log_info "Installing PostgreSQL 16..." | |
| # Add PostgreSQL APT repository | |
| apt-get install -y -qq postgresql-common | |
| /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y | |
| # Install PostgreSQL 16, contrib packages, and extensions | |
| apt-get install -y \ | |
| postgresql-16 \ | |
| postgresql-contrib-16 \ | |
| postgresql-client-16 \ | |
| postgresql-16-pgvector \ | |
| postgresql-16-postgis-3 | |
| log_success "PostgreSQL 16 and extensions installed" | |
| } | |
| # Install SSL and other tools | |
| install_dependencies() { | |
| log_info "Installing dependencies..." | |
| apt-get install -y -qq \ | |
| certbot \ | |
| python3-certbot-dns-cloudflare \ | |
| curl \ | |
| htop \ | |
| net-tools \ | |
| openssl \ | |
| pgbouncer | |
| log_success "Dependencies installed (including Cloudflare DNS plugin and PgBouncer)" | |
| } | |
| # Obtain SSL certificate using DNS challenge | |
| obtain_ssl_certificate() { | |
| if [[ "$SSL_METHOD" == "1" ]]; then | |
| obtain_ssl_cloudflare_api | |
| else | |
| obtain_ssl_manual_dns | |
| fi | |
| } | |
| # Cloudflare API method | |
| obtain_ssl_cloudflare_api() { | |
| log_info "Obtaining SSL certificate using Cloudflare DNS API for $DOMAIN..." | |
| # Create Cloudflare credentials file | |
| mkdir -p /root/.secrets | |
| cat > /root/.secrets/cloudflare.ini <<EOF | |
| # Cloudflare API token | |
| dns_cloudflare_api_token = $CF_API_TOKEN | |
| EOF | |
| chmod 600 /root/.secrets/cloudflare.ini | |
| # Obtain certificate using DNS challenge | |
| certbot certonly \ | |
| --dns-cloudflare \ | |
| --dns-cloudflare-credentials /root/.secrets/cloudflare.ini \ | |
| --dns-cloudflare-propagation-seconds 60 \ | |
| --non-interactive \ | |
| --agree-tos \ | |
| --email "$EMAIL" \ | |
| --domain "$DOMAIN" | |
| if [[ $? -ne 0 ]]; then | |
| log_error "Failed to obtain SSL certificate via Cloudflare DNS" | |
| log_error "Please check:" | |
| log_error " 1. Your API token has DNS edit permissions" | |
| log_error " 2. The domain $DOMAIN is managed by Cloudflare" | |
| log_error " 3. The API token is for the correct Cloudflare account" | |
| exit 1 | |
| fi | |
| log_success "SSL certificate obtained for $DOMAIN via Cloudflare DNS" | |
| } | |
| # Manual DNS method | |
| obtain_ssl_manual_dns() { | |
| log_info "Obtaining SSL certificate using Manual DNS challenge for $DOMAIN..." | |
| echo "" | |
| log_warn "=========================================================================" | |
| log_warn " MANUAL DNS CHALLENGE MODE" | |
| log_warn "=========================================================================" | |
| log_warn "" | |
| log_warn "Certbot will now show you a TXT record that you need to add to your DNS." | |
| log_warn "" | |
| log_warn "Steps:" | |
| log_warn " 1. Certbot will display: _acme-challenge.${DOMAIN}" | |
| log_warn " 2. Copy the TXT record value shown" | |
| log_warn " 3. Go to your DNS provider (Cloudflare, etc.)" | |
| log_warn " 4. Add a new TXT record:" | |
| log_warn " Name: _acme-challenge" | |
| log_warn " Type: TXT" | |
| log_warn " Value: (the value certbot shows you)" | |
| log_warn " 5. Wait 1-2 minutes for DNS propagation" | |
| log_warn " 6. Press Enter when certbot prompts you" | |
| log_warn "" | |
| log_warn "=========================================================================" | |
| echo "" | |
| read -p "Press Enter when you're ready to start..." | |
| # Run certbot in interactive mode to show the TXT record | |
| certbot certonly \ | |
| --manual \ | |
| --preferred-challenges dns \ | |
| --agree-tos \ | |
| --email "$EMAIL" \ | |
| --domain "$DOMAIN" | |
| if [[ $? -ne 0 ]]; then | |
| log_error "Failed to obtain SSL certificate" | |
| log_error "Please ensure you added the DNS TXT record correctly" | |
| log_error "" | |
| log_error "Common issues:" | |
| log_error " - TXT record not added correctly" | |
| log_error " - DNS not propagated (wait 2-5 minutes)" | |
| log_error " - Wrong DNS provider/zone" | |
| echo "" | |
| log_info "You can retry by running: certbot certonly --manual --preferred-challenges dns -d $DOMAIN" | |
| exit 1 | |
| fi | |
| log_success "SSL certificate obtained for $DOMAIN via Manual DNS" | |
| } | |
| # Configure PostgreSQL SSL | |
| configure_postgresql_ssl() { | |
| log_info "Configuring PostgreSQL SSL certificates..." | |
| # Create SSL directory | |
| mkdir -p /etc/postgresql/16/main/ssl | |
| # Copy certificates | |
| cp /etc/letsencrypt/live/$DOMAIN/fullchain.pem /etc/postgresql/16/main/ssl/server.crt | |
| cp /etc/letsencrypt/live/$DOMAIN/privkey.pem /etc/postgresql/16/main/ssl/server.key | |
| # Set proper ownership and permissions | |
| chown postgres:postgres /etc/postgresql/16/main/ssl/server.crt | |
| chown postgres:postgres /etc/postgresql/16/main/ssl/server.key | |
| chmod 600 /etc/postgresql/16/main/ssl/server.key | |
| chmod 644 /etc/postgresql/16/main/ssl/server.crt | |
| log_success "SSL certificates configured for PostgreSQL" | |
| } | |
| # Configure PostgreSQL | |
| configure_postgresql() { | |
| log_info "Configuring PostgreSQL..." | |
| # Backup original configs | |
| cp /etc/postgresql/16/main/postgresql.conf /etc/postgresql/16/main/postgresql.conf.backup | |
| cp /etc/postgresql/16/main/pg_hba.conf /etc/postgresql/16/main/pg_hba.conf.backup | |
| # Get server memory for tuning | |
| TOTAL_MEM_KB=$(grep MemTotal /proc/meminfo | awk '{print $2}') | |
| TOTAL_MEM_GB=$((TOTAL_MEM_KB / 1024 / 1024)) | |
| # Calculate shared_buffers (25% of RAM, max 8GB) | |
| SHARED_BUFFERS_GB=$((TOTAL_MEM_GB / 4)) | |
| if [ $SHARED_BUFFERS_GB -gt 8 ]; then | |
| SHARED_BUFFERS_GB=8 | |
| fi | |
| if [ $SHARED_BUFFERS_GB -lt 1 ]; then | |
| SHARED_BUFFERS_GB=1 | |
| fi | |
| # Calculate effective_cache_size (75% of RAM) | |
| EFFECTIVE_CACHE_GB=$((TOTAL_MEM_GB * 3 / 4)) | |
| if [ $EFFECTIVE_CACHE_GB -lt 1 ]; then | |
| EFFECTIVE_CACHE_GB=1 | |
| fi | |
| # CRITICAL FIX: Replace listen_addresses instead of just appending | |
| # This ensures PostgreSQL listens on all interfaces, not just localhost | |
| sed -i "s/^#*listen_addresses *=.*/listen_addresses = '*'/" /etc/postgresql/16/main/postgresql.conf | |
| # Update postgresql.conf with additional settings | |
| cat >> /etc/postgresql/16/main/postgresql.conf <<EOF | |
| # =================================================================== | |
| # Custom Configuration for Multi-Tenant PostgreSQL SaaS | |
| # Auto-configured based on server specs | |
| # =================================================================== | |
| # Connection Settings | |
| # listen_addresses is already set above via sed command | |
| port = 5432 | |
| max_connections = 500 | |
| superuser_reserved_connections = 10 | |
| # Memory Settings (tuned for ${TOTAL_MEM_GB}GB RAM) | |
| shared_buffers = ${SHARED_BUFFERS_GB}GB | |
| effective_cache_size = ${EFFECTIVE_CACHE_GB}GB | |
| work_mem = 16MB | |
| maintenance_work_mem = 512MB | |
| wal_buffers = 16MB | |
| # Checkpoint Settings | |
| checkpoint_completion_target = 0.9 | |
| checkpoint_timeout = 15min | |
| max_wal_size = 2GB | |
| min_wal_size = 1GB | |
| # Query Planner | |
| random_page_cost = 1.1 | |
| effective_io_concurrency = 200 | |
| # SSL Configuration | |
| ssl = on | |
| ssl_cert_file = '/etc/postgresql/16/main/ssl/server.crt' | |
| ssl_key_file = '/etc/postgresql/16/main/ssl/server.key' | |
| ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL' | |
| ssl_prefer_server_ciphers = on | |
| ssl_min_protocol_version = 'TLSv1.2' | |
| # Logging | |
| logging_collector = on | |
| log_directory = 'log' | |
| log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log' | |
| log_truncate_on_rotation = off | |
| log_rotation_age = 1d | |
| log_rotation_size = 100MB | |
| log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h ' | |
| log_timezone = 'UTC' | |
| log_statement = 'ddl' | |
| log_duration = off | |
| log_min_duration_statement = 1000 | |
| log_checkpoints = on | |
| log_connections = on | |
| log_disconnections = on | |
| log_lock_waits = on | |
| log_temp_files = 0 | |
| # Performance Extensions | |
| shared_preload_libraries = 'pg_stat_statements' | |
| # Statistics | |
| track_activities = on | |
| track_counts = on | |
| track_io_timing = on | |
| track_functions = all | |
| track_activity_query_size = 2048 | |
| # Autovacuum | |
| autovacuum = on | |
| autovacuum_max_workers = 4 | |
| autovacuum_naptime = 10s | |
| autovacuum_vacuum_scale_factor = 0.05 | |
| autovacuum_analyze_scale_factor = 0.02 | |
| # Locale | |
| datestyle = 'iso, mdy' | |
| timezone = 'UTC' | |
| lc_messages = 'en_US.UTF-8' | |
| lc_monetary = 'en_US.UTF-8' | |
| lc_numeric = 'en_US.UTF-8' | |
| lc_time = 'en_US.UTF-8' | |
| default_text_search_config = 'pg_catalog.english' | |
| EOF | |
| # Configure pg_hba.conf | |
| cat > /etc/postgresql/16/main/pg_hba.conf <<'EOF' | |
| # PostgreSQL Client Authentication Configuration File | |
| # This file controls: which hosts are allowed to connect, how clients | |
| # are authenticated, which PostgreSQL user names they can use, which | |
| # databases they can access. | |
| # | |
| # TYPE DATABASE USER ADDRESS METHOD | |
| # =================================================================== | |
| # Local connections (Unix socket) | |
| # =================================================================== | |
| local all postgres peer | |
| local all all scram-sha-256 | |
| # =================================================================== | |
| # Localhost connections (127.0.0.1) | |
| # =================================================================== | |
| host all postgres 127.0.0.1/32 scram-sha-256 | |
| host all all 127.0.0.1/32 scram-sha-256 | |
| # IPv6 localhost | |
| host all postgres ::1/128 scram-sha-256 | |
| host all all ::1/128 scram-sha-256 | |
| # =================================================================== | |
| # Allow replication connections from localhost | |
| # =================================================================== | |
| local replication all peer | |
| host replication all 127.0.0.1/32 scram-sha-256 | |
| host replication all ::1/128 scram-sha-256 | |
| # =================================================================== | |
| # GLOBAL ACCESS - Allow all IPs (with SSL required) | |
| # Default rule: Allow any IP to connect to any database | |
| # The API will manage per-database IP restrictions below | |
| # =================================================================== | |
| hostssl all all 0.0.0.0/0 scram-sha-256 | |
| hostssl all all ::/0 scram-sha-256 | |
| # =================================================================== | |
| # API MANAGED SECTION - Per-Database IP Whitelisting | |
| # DO NOT EDIT BELOW THIS LINE - MANAGED BY API | |
| # | |
| # When IP whitelisting is enabled for a database, the API will: | |
| # 1. Add specific rules here for allowed IPs | |
| # 2. Add a REJECT rule at the end to block all other IPs | |
| # | |
| # Example format: | |
| # hostssl tenant_abc123 user_abc123 203.0.113.5/32 scram-sha-256 | |
| # hostssl tenant_abc123 user_abc123 0.0.0.0/0 reject | |
| # =================================================================== | |
| ### API_MANAGED_SECTION_START ### | |
| ### API_MANAGED_SECTION_END ### | |
| EOF | |
| log_success "PostgreSQL configured" | |
| } | |
| # Start PostgreSQL | |
| start_postgresql() { | |
| log_info "Starting PostgreSQL..." | |
| systemctl enable postgresql | |
| # Restart to apply configuration changes (especially listen_addresses) | |
| systemctl restart postgresql | |
| # Wait for PostgreSQL to be ready | |
| sleep 3 | |
| if systemctl is-active --quiet postgresql; then | |
| log_success "PostgreSQL started successfully" | |
| else | |
| log_error "Failed to start PostgreSQL" | |
| systemctl status postgresql | |
| exit 1 | |
| fi | |
| } | |
| # Set PostgreSQL admin password | |
| set_postgres_password() { | |
| log_info "Setting PostgreSQL admin password..." | |
| sudo -u postgres psql -c "ALTER USER postgres WITH PASSWORD '$POSTGRES_ADMIN_PASSWORD';" | |
| log_success "PostgreSQL admin password set" | |
| } | |
| # Configure PgBouncer | |
| configure_pgbouncer() { | |
| log_info "Configuring PgBouncer connection pooler..." | |
| # Create PgBouncer directories | |
| mkdir -p /etc/pgbouncer | |
| mkdir -p /var/log/pgbouncer | |
| mkdir -p /var/run/pgbouncer | |
| chown -R postgres:postgres /var/log/pgbouncer | |
| chown -R postgres:postgres /var/run/pgbouncer | |
| # Note: With auth_query, passwords are fetched from PostgreSQL | |
| # We still add admin users to userlist.txt for initial access | |
| # Using SCRAM-SHA-256, passwords will be verified via auth_query | |
| # Create pgbouncer.ini configuration | |
| cat > /etc/pgbouncer/pgbouncer.ini <<'PGBOUNCER_INI' | |
| [databases] | |
| postgres_control = host=127.0.0.1 port=5432 dbname=postgres_control | |
| postgres = host=127.0.0.1 port=5432 dbname=postgres | |
| * = host=127.0.0.1 port=5432 | |
| [pgbouncer] | |
| ; Connection pooling mode | |
| ; transaction = Best for web applications (1 connection per transaction) | |
| ; session = Traditional (1 connection per client session) | |
| pool_mode = transaction | |
| ; Maximum client connections | |
| max_client_conn = 1000 | |
| ; Connection pool settings per database | |
| default_pool_size = 25 | |
| min_pool_size = 5 | |
| reserve_pool_size = 5 | |
| reserve_pool_timeout = 3 | |
| ; Global limits | |
| max_db_connections = 100 | |
| max_user_connections = 100 | |
| ; Connection lifetime | |
| server_lifetime = 3600 | |
| server_idle_timeout = 600 | |
| ; Network settings | |
| listen_addr = 0.0.0.0 | |
| listen_port = 6432 | |
| ; Logging | |
| logfile = /var/log/pgbouncer/pgbouncer.log | |
| pidfile = /var/run/pgbouncer/pgbouncer.pid | |
| log_connections = 1 | |
| log_disconnections = 1 | |
| log_pooler_errors = 1 | |
| ; Authentication | |
| auth_type = scram-sha-256 | |
| auth_file = /etc/pgbouncer/userlist.txt | |
| auth_user = postgres | |
| auth_query = SELECT usename, passwd FROM pg_shadow WHERE usename=$1 | |
| ; Admin console | |
| admin_users = postgres | |
| stats_users = postgres, api_user | |
| ; TLS/SSL (optional - can enable later) | |
| ;client_tls_sslmode = prefer | |
| ;client_tls_cert_file = /etc/postgresql/16/main/ssl/server.crt | |
| ;client_tls_key_file = /etc/postgresql/16/main/ssl/server.key | |
| ; Performance tuning | |
| max_packet_size = 4096 | |
| pkt_buf = 4096 | |
| sbuf_loopcnt = 5 | |
| PGBOUNCER_INI | |
| # Create empty userlist.txt for now (will be populated after api_user is created) | |
| touch /etc/pgbouncer/userlist.txt | |
| # Set proper permissions | |
| chown -R postgres:postgres /etc/pgbouncer | |
| chmod 640 /etc/pgbouncer/pgbouncer.ini | |
| chmod 600 /etc/pgbouncer/userlist.txt | |
| log_success "PgBouncer configuration created (userlist.txt will be populated after users are created)" | |
| } | |
| # Setup PgBouncer authentication (call this AFTER users are created) | |
| setup_pgbouncer_auth() { | |
| log_info "Setting up PgBouncer authentication with SCRAM-SHA-256..." | |
| # With auth_query and SCRAM-SHA-256, we need plaintext passwords in userlist.txt | |
| # PgBouncer will use these to authenticate to PostgreSQL when running auth_query | |
| # Tenant users will be authenticated via auth_query (fetching from pg_shadow) | |
| # Create userlist.txt with plaintext passwords for admin users | |
| cat > /etc/pgbouncer/userlist.txt <<EOF | |
| "postgres" "${POSTGRES_ADMIN_PASSWORD}" | |
| "api_user" "${POSTGRES_ADMIN_PASSWORD}" | |
| EOF | |
| chmod 600 /etc/pgbouncer/userlist.txt | |
| chown postgres:postgres /etc/pgbouncer/userlist.txt | |
| log_success "PgBouncer authentication configured with SCRAM-SHA-256" | |
| } | |
| # Start PgBouncer service | |
| start_pgbouncer() { | |
| log_info "Starting PgBouncer service..." | |
| # Create systemd tmpfiles.d configuration for runtime directory | |
| # This ensures /var/run/pgbouncer is created on boot | |
| cat > /etc/tmpfiles.d/pgbouncer.conf <<'TMPFILES' | |
| d /var/run/pgbouncer 0755 postgres postgres - | |
| TMPFILES | |
| # Create the runtime directory now | |
| mkdir -p /var/run/pgbouncer | |
| chown postgres:postgres /var/run/pgbouncer | |
| # Create systemd service override to run as postgres user | |
| mkdir -p /etc/systemd/system/pgbouncer.service.d | |
| cat > /etc/systemd/system/pgbouncer.service.d/override.conf <<'OVERRIDE' | |
| [Service] | |
| User=postgres | |
| Group=postgres | |
| OVERRIDE | |
| # Reload systemd | |
| systemctl daemon-reload | |
| # Enable and start PgBouncer | |
| systemctl enable pgbouncer | |
| # Restart to apply configuration changes (especially listen_addr) | |
| systemctl restart pgbouncer | |
| # Wait for PgBouncer to be ready | |
| sleep 2 | |
| if systemctl is-active --quiet pgbouncer; then | |
| log_success "PgBouncer started successfully on port 6432" | |
| else | |
| log_error "Failed to start PgBouncer" | |
| journalctl -u pgbouncer -n 50 | |
| exit 1 | |
| fi | |
| } | |
| # Create control database | |
| create_control_database() { | |
| log_info "Creating control database for metadata..." | |
| sudo -u postgres psql <<SQLEOF | |
| -- Create control database | |
| CREATE DATABASE postgres_control; | |
| -- Connect to control database | |
| \c postgres_control; | |
| -- =================================================================== | |
| -- Enable core extensions | |
| -- =================================================================== | |
| CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- UUID generation | |
| CREATE EXTENSION IF NOT EXISTS "pg_stat_statements"; -- Query statistics | |
| -- =================================================================== | |
| -- Search, Text & Indexing extensions | |
| -- =================================================================== | |
| CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- Trigram indexes for ILIKE searches | |
| CREATE EXTENSION IF NOT EXISTS "unaccent"; -- Remove accents (café → cafe) | |
| CREATE EXTENSION IF NOT EXISTS "btree_gin"; -- Extra GIN index operator classes | |
| CREATE EXTENSION IF NOT EXISTS "btree_gist"; -- Extra GiST index operator classes | |
| -- =================================================================== | |
| -- Types & Convenience extensions | |
| -- =================================================================== | |
| CREATE EXTENSION IF NOT EXISTS "citext"; -- Case-insensitive text type | |
| CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- Cryptographic functions | |
| -- =================================================================== | |
| -- AI / Vector extensions | |
| -- =================================================================== | |
| CREATE EXTENSION IF NOT EXISTS "vector"; -- pgvector for embeddings/AI | |
| -- =================================================================== | |
| -- Geospatial extension | |
| -- =================================================================== | |
| CREATE EXTENSION IF NOT EXISTS "postgis"; -- Geographic objects support | |
| -- =================================================================== | |
| -- Databases table - stores metadata for all tenant databases | |
| -- =================================================================== | |
| CREATE TABLE databases ( | |
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| database_name VARCHAR(255) UNIQUE NOT NULL, | |
| username VARCHAR(255) UNIQUE NOT NULL, | |
| password_hash VARCHAR(255) NOT NULL, | |
| owner_email VARCHAR(255), | |
| friendly_name VARCHAR(255), | |
| max_connections INTEGER DEFAULT 20, | |
| status VARCHAR(50) DEFAULT 'active', | |
| created_at TIMESTAMP DEFAULT NOW(), | |
| updated_at TIMESTAMP DEFAULT NOW() | |
| ); | |
| -- =================================================================== | |
| -- IP whitelist table - stores allowed IPs per database | |
| -- =================================================================== | |
| CREATE TABLE ip_whitelist ( | |
| id SERIAL PRIMARY KEY, | |
| database_id UUID REFERENCES databases(id) ON DELETE CASCADE, | |
| ip_address VARCHAR(50) NOT NULL, | |
| description TEXT, | |
| added_at TIMESTAMP DEFAULT NOW(), | |
| UNIQUE(database_id, ip_address) | |
| ); | |
| -- =================================================================== | |
| -- API keys table - stores API authentication keys | |
| -- =================================================================== | |
| CREATE TABLE api_keys ( | |
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| key_hash VARCHAR(255) UNIQUE NOT NULL, | |
| name VARCHAR(255), | |
| permissions JSONB DEFAULT '{"databases": ["create", "read", "update", "delete"]}'::jsonb, | |
| created_at TIMESTAMP DEFAULT NOW(), | |
| expires_at TIMESTAMP, | |
| last_used_at TIMESTAMP | |
| ); | |
| -- =================================================================== | |
| -- Audit logs table - tracks all API operations | |
| -- =================================================================== | |
| CREATE TABLE audit_logs ( | |
| id SERIAL PRIMARY KEY, | |
| api_key_id UUID REFERENCES api_keys(id), | |
| action VARCHAR(100) NOT NULL, | |
| resource_type VARCHAR(50), | |
| resource_id VARCHAR(255), | |
| ip_address VARCHAR(50), | |
| details JSONB, | |
| created_at TIMESTAMP DEFAULT NOW() | |
| ); | |
| -- =================================================================== | |
| -- Database statistics table - stores metrics | |
| -- =================================================================== | |
| CREATE TABLE database_stats ( | |
| id SERIAL PRIMARY KEY, | |
| database_id UUID REFERENCES databases(id) ON DELETE CASCADE, | |
| size_bytes BIGINT, | |
| active_connections INTEGER, | |
| total_queries BIGINT, | |
| recorded_at TIMESTAMP DEFAULT NOW() | |
| ); | |
| -- =================================================================== | |
| -- Shadow databases table - stores shadow database information | |
| -- Used for Prisma migrations, testing, and development | |
| -- =================================================================== | |
| CREATE TABLE shadow_databases ( | |
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| parent_database_id UUID REFERENCES databases(id) ON DELETE CASCADE, | |
| shadow_database_name VARCHAR(255) UNIQUE NOT NULL, | |
| shadow_username VARCHAR(255) UNIQUE NOT NULL, | |
| shadow_password_hash VARCHAR(255) NOT NULL, | |
| status VARCHAR(50) DEFAULT 'active', | |
| created_at TIMESTAMP DEFAULT NOW(), | |
| synced_at TIMESTAMP, | |
| last_sync_status VARCHAR(50) | |
| ); | |
| -- =================================================================== | |
| -- Indexes for performance | |
| -- =================================================================== | |
| CREATE INDEX idx_databases_status ON databases(status); | |
| CREATE INDEX idx_databases_created_at ON databases(created_at); | |
| CREATE INDEX idx_databases_email ON databases(owner_email); | |
| CREATE INDEX idx_ip_whitelist_db ON ip_whitelist(database_id); | |
| CREATE INDEX idx_shadow_databases_parent ON shadow_databases(parent_database_id); | |
| CREATE INDEX idx_shadow_databases_status ON shadow_databases(status); | |
| CREATE INDEX idx_ip_whitelist_ip ON ip_whitelist(ip_address); | |
| CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at); | |
| CREATE INDEX idx_audit_logs_api_key ON audit_logs(api_key_id); | |
| CREATE INDEX idx_audit_logs_action ON audit_logs(action); | |
| CREATE INDEX idx_database_stats_db ON database_stats(database_id); | |
| CREATE INDEX idx_database_stats_recorded ON database_stats(recorded_at); | |
| -- =================================================================== | |
| -- Create API user for the NestJS application | |
| -- =================================================================== | |
| CREATE USER api_user WITH PASSWORD '$POSTGRES_ADMIN_PASSWORD'; | |
| -- Grant privileges on control database | |
| GRANT CONNECT ON DATABASE postgres_control TO api_user; | |
| GRANT ALL PRIVILEGES ON DATABASE postgres_control TO api_user; | |
| GRANT ALL ON ALL TABLES IN SCHEMA public TO api_user; | |
| GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO api_user; | |
| GRANT ALL ON ALL FUNCTIONS IN SCHEMA public TO api_user; | |
| -- Allow api_user to create databases and roles | |
| ALTER USER api_user CREATEDB CREATEROLE; | |
| -- Set default privileges for future objects | |
| ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO api_user; | |
| ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO api_user; | |
| ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO api_user; | |
| -- =================================================================== | |
| -- Create helpful views | |
| -- =================================================================== | |
| -- View: Active databases with stats | |
| CREATE VIEW v_active_databases AS | |
| SELECT | |
| d.id, | |
| d.database_name, | |
| d.friendly_name, | |
| d.owner_email, | |
| d.max_connections, | |
| d.status, | |
| d.created_at, | |
| COUNT(DISTINCT iw.id) as whitelisted_ips, | |
| pg_database_size(d.database_name) as size_bytes | |
| FROM databases d | |
| LEFT JOIN ip_whitelist iw ON d.id = iw.database_id | |
| WHERE d.status = 'active' | |
| GROUP BY d.id, d.database_name, d.friendly_name, d.owner_email, | |
| d.max_connections, d.status, d.created_at; | |
| -- View: Audit summary | |
| CREATE VIEW v_audit_summary AS | |
| SELECT | |
| DATE(created_at) as date, | |
| action, | |
| COUNT(*) as count | |
| FROM audit_logs | |
| GROUP BY DATE(created_at), action | |
| ORDER BY date DESC, count DESC; | |
| GRANT SELECT ON v_active_databases TO api_user; | |
| GRANT SELECT ON v_audit_summary TO api_user; | |
| -- =================================================================== | |
| -- Create utility functions | |
| -- =================================================================== | |
| -- Function: Get database size in human-readable format | |
| CREATE OR REPLACE FUNCTION get_database_size_pretty(db_name TEXT) | |
| RETURNS TEXT AS \$\$ | |
| BEGIN | |
| RETURN pg_size_pretty(pg_database_size(db_name)); | |
| END; | |
| \$\$ LANGUAGE plpgsql; | |
| -- Function: Clean old audit logs (keep 90 days) | |
| CREATE OR REPLACE FUNCTION cleanup_old_audit_logs() | |
| RETURNS INTEGER AS \$\$ | |
| DECLARE | |
| deleted_count INTEGER; | |
| BEGIN | |
| DELETE FROM audit_logs | |
| WHERE created_at < NOW() - INTERVAL '90 days'; | |
| GET DIAGNOSTICS deleted_count = ROW_COUNT; | |
| RETURN deleted_count; | |
| END; | |
| \$\$ LANGUAGE plpgsql; | |
| GRANT EXECUTE ON FUNCTION get_database_size_pretty(TEXT) TO api_user; | |
| GRANT EXECUTE ON FUNCTION cleanup_old_audit_logs() TO api_user; | |
| SQLEOF | |
| if [[ $? -eq 0 ]]; then | |
| log_success "Control database created successfully" | |
| else | |
| log_error "Failed to create control database" | |
| exit 1 | |
| fi | |
| } | |
| # Setup SSL certificate auto-renewal | |
| setup_ssl_renewal() { | |
| log_info "Setting up SSL certificate auto-renewal..." | |
| mkdir -p /etc/letsencrypt/renewal-hooks/post | |
| cat > /etc/letsencrypt/renewal-hooks/post/postgresql-reload.sh <<'RENEWAL_SCRIPT' | |
| #!/bin/bash | |
| # PostgreSQL SSL Certificate Renewal Hook | |
| # This script runs after certbot renews the SSL certificate | |
| # Find the actual domain directory (exclude README file) | |
| DOMAIN=$(ls -d /etc/letsencrypt/live/*/ 2>/dev/null | head -n 1 | xargs basename) | |
| SSL_DIR="/etc/postgresql/16/main/ssl" | |
| # Copy renewed certificates | |
| cp /etc/letsencrypt/live/$DOMAIN/fullchain.pem $SSL_DIR/server.crt | |
| cp /etc/letsencrypt/live/$DOMAIN/privkey.pem $SSL_DIR/server.key | |
| # Set proper ownership and permissions | |
| chown postgres:postgres $SSL_DIR/server.crt | |
| chown postgres:postgres $SSL_DIR/server.key | |
| chmod 600 $SSL_DIR/server.key | |
| chmod 644 $SSL_DIR/server.crt | |
| # Reload PostgreSQL to use new certificates | |
| systemctl reload postgresql | |
| echo "PostgreSQL SSL certificates renewed and reloaded at $(date)" | |
| RENEWAL_SCRIPT | |
| chmod +x /etc/letsencrypt/renewal-hooks/post/postgresql-reload.sh | |
| # Test certbot renewal (dry-run) | |
| log_info "Testing certificate renewal process..." | |
| certbot renew --dry-run | |
| log_success "SSL auto-renewal configured" | |
| } | |
| # Create management scripts | |
| create_management_scripts() { | |
| log_info "Creating management scripts..." | |
| # Script 1: Quick status check | |
| cat > /usr/local/bin/pg-status <<'STATUS_SCRIPT' | |
| #!/bin/bash | |
| echo "=== PostgreSQL Status ===" | |
| systemctl status postgresql --no-pager -l | |
| echo "" | |
| echo "=== PgBouncer Status ===" | |
| systemctl status pgbouncer --no-pager -l | |
| echo "" | |
| echo "=== PgBouncer Pools ===" | |
| psql -h 127.0.0.1 -p 6432 -U postgres pgbouncer -c "SHOW POOLS;" 2>/dev/null || echo "PgBouncer not accessible" | |
| echo "" | |
| echo "=== Active Connections ===" | |
| sudo -u postgres psql -c "SELECT datname, count(*) FROM pg_stat_activity GROUP BY datname;" | |
| echo "" | |
| echo "=== Database Sizes ===" | |
| sudo -u postgres psql -c "SELECT datname, pg_size_pretty(pg_database_size(datname)) as size FROM pg_database WHERE datistemplate = false ORDER BY pg_database_size(datname) DESC;" | |
| STATUS_SCRIPT | |
| chmod +x /usr/local/bin/pg-status | |
| # Script 4: PgBouncer status | |
| cat > /usr/local/bin/pg-bouncer-status <<'PGBOUNCER_STATUS_SCRIPT' | |
| #!/bin/bash | |
| echo "=== PgBouncer Service Status ===" | |
| systemctl status pgbouncer --no-pager | |
| echo "" | |
| echo "=== PgBouncer Connection Pools ===" | |
| psql -h 127.0.0.1 -p 6432 -U postgres pgbouncer -c "SHOW POOLS;" | |
| echo "" | |
| echo "=== PgBouncer Statistics ===" | |
| psql -h 127.0.0.1 -p 6432 -U postgres pgbouncer -c "SHOW STATS;" | |
| echo "" | |
| echo "=== PgBouncer Databases ===" | |
| psql -h 127.0.0.1 -p 6432 -U postgres pgbouncer -c "SHOW DATABASES;" | |
| PGBOUNCER_STATUS_SCRIPT | |
| chmod +x /usr/local/bin/pg-bouncer-status | |
| # Script 5: PgBouncer reload | |
| cat > /usr/local/bin/pg-bouncer-reload <<'PGBOUNCER_RELOAD_SCRIPT' | |
| #!/bin/bash | |
| echo "Reloading PgBouncer configuration..." | |
| psql -h 127.0.0.1 -p 6432 -U postgres pgbouncer -c "RELOAD;" | |
| echo "PgBouncer configuration reloaded" | |
| PGBOUNCER_RELOAD_SCRIPT | |
| chmod +x /usr/local/bin/pg-bouncer-reload | |
| # Script 2: Backup control database | |
| cat > /usr/local/bin/pg-backup-control <<'BACKUP_SCRIPT' | |
| #!/bin/bash | |
| BACKUP_DIR="/var/backups/postgresql" | |
| mkdir -p $BACKUP_DIR | |
| BACKUP_FILE="$BACKUP_DIR/postgres_control_$(date +%Y%m%d_%H%M%S).sql.gz" | |
| sudo -u postgres pg_dump postgres_control | gzip > $BACKUP_FILE | |
| echo "Backup created: $BACKUP_FILE" | |
| # Keep only last 7 days of backups | |
| find $BACKUP_DIR -name "postgres_control_*.sql.gz" -mtime +7 -delete | |
| BACKUP_SCRIPT | |
| chmod +x /usr/local/bin/pg-backup-control | |
| # Script 3: View active databases | |
| cat > /usr/local/bin/pg-list-databases <<'LIST_SCRIPT' | |
| #!/bin/bash | |
| sudo -u postgres psql -d postgres_control -c "SELECT * FROM v_active_databases ORDER BY created_at DESC;" | |
| LIST_SCRIPT | |
| chmod +x /usr/local/bin/pg-list-databases | |
| log_success "Management scripts created" | |
| } | |
| # Setup daily backup cron | |
| setup_backup_cron() { | |
| log_info "Setting up daily backup cron job..." | |
| # Create cron job for daily backup at 2 AM | |
| (crontab -l 2>/dev/null; echo "0 2 * * * /usr/local/bin/pg-backup-control >> /var/log/postgresql-backup.log 2>&1") | crontab - | |
| log_success "Daily backup cron job configured" | |
| } | |
| # Get server IP | |
| get_server_info() { | |
| SERVER_IP=$(hostname -I | awk '{print $1}') | |
| SERVER_HOSTNAME=$(hostname) | |
| } | |
| # Print final summary | |
| print_summary() { | |
| get_server_info | |
| cat <<SUMMARY | |
| ${GREEN}╔════════════════════════════════════════════════════════════════╗ | |
| ║ ║ | |
| ║ PostgreSQL 16 Installation Complete! ║ | |
| ║ DNS Challenge Edition ║ | |
| ║ ║ | |
| ╚════════════════════════════════════════════════════════════════╝${NC} | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| ${CYAN}Server Information${NC} | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| Server IP: ${SERVER_IP} | |
| Hostname: ${SERVER_HOSTNAME} | |
| Domain: ${DOMAIN} | |
| PostgreSQL Port: 5432 (Direct connection) | |
| PgBouncer Port: 6432 (Connection pooling - RECOMMENDED) | |
| SSL: Enabled (Let's Encrypt via DNS Challenge) | |
| SSL Method: $([ "$SSL_METHOD" == "1" ] && echo "Cloudflare DNS API (Automatic renewal)" || echo "Manual DNS (Manual renewal required)") | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| ${CYAN}Database Credentials${NC} | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| ${YELLOW}Superuser:${NC} | |
| Username: postgres | |
| Password: ${POSTGRES_ADMIN_PASSWORD} | |
| ${YELLOW}API User:${NC} | |
| Username: api_user | |
| Password: ${POSTGRES_ADMIN_PASSWORD} | |
| Database: postgres_control | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| ${CYAN}Connection Strings${NC} | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| ${YELLOW}Via PgBouncer (RECOMMENDED for apps):${NC} | |
| postgresql://api_user:${POSTGRES_ADMIN_PASSWORD}@${DOMAIN}:6432/postgres_control?sslmode=require | |
| ${YELLOW}Direct PostgreSQL (for admin/migrations):${NC} | |
| postgresql://api_user:${POSTGRES_ADMIN_PASSWORD}@${DOMAIN}:5432/postgres_control?sslmode=require | |
| ${YELLOW}Using IP address:${NC} | |
| postgresql://api_user:${POSTGRES_ADMIN_PASSWORD}@${SERVER_IP}:6432/postgres_control?sslmode=require | |
| ${YELLOW}For local testing:${NC} | |
| postgresql://api_user:${POSTGRES_ADMIN_PASSWORD}@localhost:6432/postgres_control | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| ${CYAN}SSL Certificate${NC} | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| Certificate: /etc/letsencrypt/live/${DOMAIN}/fullchain.pem | |
| Private Key: /etc/letsencrypt/live/${DOMAIN}/privkey.pem | |
| Auto-renewal: Enabled (certbot timer) | |
| Renewal Hook: /etc/letsencrypt/renewal-hooks/post/postgresql-reload.sh | |
| SUMMARY | |
| if [[ "$SSL_METHOD" == "1" ]]; then | |
| cat <<CLOUDFLARE_INFO | |
| ${YELLOW}Cloudflare API Token:${NC} | |
| Stored in: /root/.secrets/cloudflare.ini | |
| Auto-renewal: Fully automated via DNS API | |
| CLOUDFLARE_INFO | |
| else | |
| cat <<MANUAL_INFO | |
| ${RED}⚠ IMPORTANT - Manual DNS Renewal${NC} | |
| Since you used manual DNS challenge, you'll need to manually renew | |
| the certificate every 60-90 days by running: | |
| certbot renew | |
| And adding the DNS TXT record when prompted. | |
| ${YELLOW}Or switch to Cloudflare API for automatic renewal:${NC} | |
| 1. Get Cloudflare API token | |
| 2. Create /root/.secrets/cloudflare.ini with token | |
| 3. Update certbot renewal config to use dns-cloudflare | |
| MANUAL_INFO | |
| fi | |
| cat <<SUMMARY2 | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| ${CYAN}Configuration Files${NC} | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| postgresql.conf: /etc/postgresql/16/main/postgresql.conf | |
| pg_hba.conf: /etc/postgresql/16/main/pg_hba.conf | |
| SSL Cert: /etc/postgresql/16/main/ssl/server.crt | |
| SSL Key: /etc/postgresql/16/main/ssl/server.key | |
| Logs: /var/log/postgresql/postgresql-16-main.log | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| ${CYAN}Management Commands${NC} | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| ${YELLOW}Status & Monitoring:${NC} | |
| pg-status - Quick status overview | |
| pg-list-databases - List all tenant databases | |
| systemctl status postgresql - Service status | |
| ${YELLOW}Logs:${NC} | |
| tail -f /var/log/postgresql/postgresql-16-main.log | |
| journalctl -u postgresql -f | |
| ${YELLOW}Database Access:${NC} | |
| sudo -u postgres psql - Connect as superuser | |
| sudo -u postgres psql -d postgres_control - Control database | |
| ${YELLOW}Backup & Restore:${NC} | |
| pg-backup-control - Backup control database | |
| /var/backups/postgresql/ - Backup location | |
| ${YELLOW}Service Management:${NC} | |
| systemctl restart postgresql - Restart service | |
| systemctl reload postgresql - Reload config | |
| systemctl stop postgresql - Stop service | |
| systemctl start postgresql - Start service | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| ${CYAN}Security Notes${NC} | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| ${GREEN}✓${NC} Compatible with Nginx Proxy Manager (no port 80 conflict) | |
| ${GREEN}✓${NC} PostgreSQL only accepts SSL connections | |
| ${GREEN}✓${NC} Strong password authentication (scram-sha-256) | |
| ${GREEN}✓${NC} SSL certificate auto-renews (DNS challenge) | |
| ${GREEN}✓${NC} Daily backups configured (2 AM) | |
| ${RED}⚠${NC} Save the credentials securely | |
| ${RED}⚠${NC} Firewall: Ensure port 5432 is accessible from your API server | |
| ${RED}⚠${NC} Monitor /var/log/postgresql/ regularly | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| ${CYAN}Nginx Proxy Manager Integration${NC} | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| ${YELLOW}PostgreSQL does NOT need reverse proxy${NC} | |
| PostgreSQL uses port 5432 (direct connection), not HTTP/HTTPS. | |
| ${YELLOW}If using NPM for API server:${NC} | |
| 1. Deploy your Express API on this server (port 3000) | |
| 2. In NPM, create a new Proxy Host: | |
| - Domain: api.yourdomain.com | |
| - Forward to: localhost:3000 | |
| - SSL: Request new SSL (or use existing) | |
| - Websockets: Off | |
| 3. API connects to PostgreSQL via: localhost:5432 | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| ${CYAN}Next Steps${NC} | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| 1. ${YELLOW}Test Connection:${NC} | |
| psql "postgresql://api_user:${POSTGRES_ADMIN_PASSWORD}@${DOMAIN}:5432/postgres_control?sslmode=require" | |
| 2. ${YELLOW}Verify SSL:${NC} | |
| openssl s_client -connect ${DOMAIN}:5432 -starttls postgres | |
| 3. ${YELLOW}Set up your Express API:${NC} | |
| - Use the connection string above in your .env file | |
| - Deploy the API (same server or remote) | |
| - Configure NPM if proxying the API | |
| 4. ${YELLOW}Test from API server:${NC} | |
| node -e "const {Pool}=require('pg');const p=new Pool({connectionString:'postgresql://api_user:${POSTGRES_ADMIN_PASSWORD}@${DOMAIN}:5432/postgres_control?sslmode=require'});p.query('SELECT NOW()').then(r=>console.log(r.rows[0])).catch(console.error).finally(()=>p.end())" | |
| 5. ${YELLOW}Monitor:${NC} | |
| Run 'pg-status' to check system health | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| ${CYAN}Important Files to Save${NC} | |
| ${CYAN}═══════════════════════════════════════════════════════════════${NC} | |
| Save this output to: installation_details.txt | |
| ${YELLOW}Command to save:${NC} | |
| cat > ~/postgresql_installation_\$(date +%Y%m%d).txt <<'EOF' | |
| Domain: ${DOMAIN} | |
| Server IP: ${SERVER_IP} | |
| Admin Password: ${POSTGRES_ADMIN_PASSWORD} | |
| Connection: postgresql://api_user:${POSTGRES_ADMIN_PASSWORD}@${DOMAIN}:5432/postgres_control?sslmode=require | |
| SSL Method: $([ "$SSL_METHOD" == "1" ] && echo "Cloudflare DNS API" || echo "Manual DNS") | |
| EOF | |
| ${GREEN}╔════════════════════════════════════════════════════════════════╗ | |
| ║ ║ | |
| ║ Installation completed successfully! ║ | |
| ║ ║ | |
| ║ Your PostgreSQL SaaS platform is ready for production! ║ | |
| ║ Works seamlessly with Nginx Proxy Manager! ║ | |
| ║ ║ | |
| ╚════════════════════════════════════════════════════════════════╝${NC} | |
| SUMMARY2 | |
| } | |
| # Main installation function | |
| main() { | |
| show_banner | |
| ensure_root | |
| get_configuration | |
| log_info "Starting installation..." | |
| echo "" | |
| update_system | |
| install_postgresql | |
| install_dependencies | |
| obtain_ssl_certificate | |
| configure_postgresql_ssl | |
| configure_postgresql | |
| start_postgresql | |
| set_postgres_password | |
| configure_pgbouncer | |
| create_control_database | |
| setup_pgbouncer_auth | |
| start_pgbouncer | |
| setup_ssl_renewal | |
| create_management_scripts | |
| setup_backup_cron | |
| echo "" | |
| log_success "All installation steps completed!" | |
| echo "" | |
| print_summary | |
| } | |
| # Run main function | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment