One-VPS docker compose stack

~$ mkdir isocta
~$ cd isocta
~/isocta$ cat > docker-compose.yml << EOF
services:
  nginx:
    image: nginx
    container_name: isocta-nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/snippets:/etc/nginx/snippets:ro
      - ./nginx/auth:/etc/nginx/auth:ro
      - ./nginx/logs:/var/log/nginx
      - ./nginx/www:/var/www
      - ./certbot/www:/var/www/certbot:ro
      - ./certbot/conf:/etc/letsencrypt:ro
    networks:
      - front
      - back

  certbot:
    image: certbot/certbot:latest
    container_name: isocta-certbot
    restart: "no"
    volumes:
      - ./certbot/www:/var/www/certbot
      - ./certbot/conf:/etc/letsencrypt
    networks:
      - front

  nextcloud:
    image: nextcloud
    container_name: isocta-nextcloud
    restart: unless-stopped
    environment:
      - POSTGRES_HOST=nextcloud-db
      - POSTGRES_DB=${NC_DB_NAME}
      - POSTGRES_USER=${NC_DB_USER}
      - POSTGRES_PASSWORD=${NC_DB_PASS}
      - REDIS_HOST=nextcloud-redis
      - NEXTCLOUD_ADMIN_USER=${NC_ADMIN_USER}
      - NEXTCLOUD_ADMIN_PASSWORD=${NC_ADMIN_PASS}
      - NEXTCLOUD_TRUSTED_DOMAINS=${NC_TRUSTED_DOMAINS}
      - OVERWRITEPROTOCOL=https
    volumes:
      - ./nextcloud/app:/var/www/html
    depends_on:
      - nextcloud-db
      - nextcloud-redis
    networks:
      - back

  nextcloud-db:
    image: postgres
    container_name: isocta-nextcloud-db
    restart: unless-stopped
    environment:
      - POSTGRES_DB=${NC_DB_NAME}
      - POSTGRES_USER=${NC_DB_USER}
      - POSTGRES_PASSWORD=${NC_DB_PASS}
    volumes:
      - ./nextcloud/db:/var/lib/postgresql
    networks:
      - back

  nextcloud-redis:
    image: redis:7-alpine
    container_name: isocta-nextcloud-redis
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - ./nextcloud/redis/data:/data
    networks:
      - back
EOF
~/isocta$ cat .env 

LE_DOMAINS="cloud.isocta.com isocta.com www.isocta.com jiri.isocta.com"

# Email for Let's Encrypt
LE_EMAIL="jiri@isocta.com"

# Nextcloud DB settings
NC_DB_NAME="nextcloud"
NC_DB_USER="nextcloud"
NC_DB_PASS="B66106000151952B963D99E3D1ADBF396C2FBA215188BF749A206BB9EC465C72"
NC_ADMIN_USER="jiri"
NC_ADMIN_PASS="XHRRyhiXF4cQ@PxtrXZXDYM9"
NC_TRUSTED_DOMAINS="cloud.isocta.com"

EOF
~/isocta$ mkdir -p certbot/{conf,www} nextcloud/{app,db,redis} nginx/{auth,conf.d/sites,logs,snippets,www} redis/data scripts
/isocta$ cat > nginx/nginx.conf << EOF
worker_processes auto;

events { worker_connections 1024; }

http {
  include       /etc/nginx/mime.types;
  default_type  application/octet-stream;

  sendfile      on;
  tcp_nopush    on;
  tcp_nodelay   on;
  keepalive_timeout  65;

  # Reasonable defaults
  server_tokens off;
  client_max_body_size 2g;

  # Load per-site configs
  include /etc/nginx/conf.d/*.conf;
  include /etc/nginx/conf.d/sites/*.conf;
}
EOF
~/isocta$ cat > nginx/conf.d/00-acme-redirect.conf << EOF
server {
  listen 80 default_server;
  listen [::]:80 default_server;

  server_name _;

  location ^~ /.well-known/acme-challenge/ {
    root /var/www/certbot;
    default_type "text/plain";
  }

  location / {
    return 301 https://$host$request_uri;
  }
}
EOF
~/isocta$ cat > nginx/conf.d/sites/cloud.isocta.com.conf << EOF
upstream nextcloud_upstream {
  server nextcloud:80;
}

server {
  listen 443 ssl;
  listen [::]:443 ssl;

  http2 on;

  server_name cloud.isocta.com;

  ssl_certificate     /etc/letsencrypt/live/cloud.isocta.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/cloud.isocta.com/privkey.pem;

  client_max_body_size 32g;
  client_body_timeout 3600s;
  fastcgi_read_timeout 3600s;

  include /etc/nginx/snippets/ssl.conf;

  location / {
    include /etc/nginx/snippets/proxy.conf;

    # Nextcloud behind reverse proxy wants this:
    proxy_set_header X-Forwarded-Port 443;

    proxy_pass http://nextcloud_upstream;
  }
}
EOF
~/isocta$ cat > nginx/snippets/proxy.conf << EOF
proxy_set_header Host              $host;
proxy_set_header X-Real-IP         $remote_addr;
proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host  $host;
proxy_set_header X-Forwarded-Proto $scheme;

proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout  3600;
proxy_send_timeout  3600;
EOF
~/isocta$ cat > nginx/snippets/ssl.conf << EOF
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;

ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;

# HSTS
add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy no-referrer-when-downgrade always;
EOF
~/isocta$ cat > scripts/certs.sh << EOF
#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"

echo "Loading env vars"
# Load env vars safely (supports spaces + quotes)
if [ -f .env ]; then
  set -a          # automatically export all variables
  # shellcheck disable=SC1091
  . ./.env
  set +a
fi

: "${LE_EMAIL:?Set LE_EMAIL in .env}"
: "${LE_DOMAINS:?Set LE_DOMAINS in .env}"

cmd="${1:-}"

usage() {
  echo "Usage:"
  echo "  $0 init        # issue certs for all domains in LE_DOMAINS"
  echo "  $0 renew       # renew any expiring certs"
  echo "  $0 reload      # reload nginx"
  exit 1
}

reload_nginx() {
  docker compose exec -T nginx nginx -s reload
}

init_certs() {
  # Ensure nginx is up serving /.well-known/acme-challenge on port 80
  docker compose up -d nginx

  for d in $LE_DOMAINS; do
    echo "$d"
    if [ -d "certbot/conf/live/$d" ]; then
      echo "Cert already exists for $d, skipping."
      continue
    fi

    echo "Issuing cert for $d..."
    docker compose run --rm certbot certonly \
      --webroot -w /var/www/certbot \
      --email "$LE_EMAIL" --agree-tos --no-eff-email \
      -d "$d"

    echo "Done: $d"
  done

  reload_nginx
  echo "All certs issued and nginx reloaded."
}

renew_certs() {
  echo "Renewing certs if needed..."
  docker compose run --rm certbot renew --webroot -w /var/www/certbot
  reload_nginx
  echo "Renew complete; nginx reloaded."
}

case "$cmd" in
  init) init_certs ;;
  renew) renew_certs ;;
  reload) reload_nginx ;;
  *) usage ;;
esac
EOF
~/isocta$ chmod +x scripts/certs.sh