diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fde7f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Ignore mkcert generated files (certificates and keys) +certs/ +localhost+*.pem \ No newline at end of file diff --git a/README.md b/README.md index 41713d3..3e14e22 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ -# Nginx/LetsEncrypt Reverse Proxy -The goal of this repository is to make it easy to [reverse proxy](https://en.wikipedia.org/wiki/Reverse_proxy) one or more website services on a Virtual Private Server (VPS). **Note: Services must be started with Docker.** - -[Here](https://github.com/MattHalloran/NLN) is a project that uses this. +# Nginx Reverse Proxy with SSL Certificate +The goal of this repository is to make it easy to set up a [reverse proxy](https://en.wikipedia.org/wiki/Reverse_proxy) and [SSL certificate](https://www.cloudflare.com/learning/ssl/what-is-an-ssl-certificate/) for a website running locally or on a VPS. When running locally, the SSL certificate is self-signed. When running on a VPS, the SSL certificate is provided by [LetsEncrypt](https://letsencrypt.org/). Heavily inspired by [this article](https://olex.biz/2019/09/hosting-with-docker-nginx-reverse-proxy-letsencrypt/). If you're looking for someone to thank, it is them! @@ -14,18 +12,28 @@ Heavily inspired by [this article](https://olex.biz/2019/09/hosting-with-docker- | [Docker](https://www.docker.com/) | Container handler | latest | ## Prerequisites -1. Must have a website name, with access to its DNS settings. If you're not sure where to get started, I like using [Google Domains](https://domains.google/). -2. Must have access to a Virtual Private Server (VPS). They can be as little as $5 a month. Here are some good sites: +1. If not running locally, must have a website name and access to its DNS settings +2. If not running locally, must have access to a Virtual Private Server (VPS). Here are some good sites: * [DigitalOcean](https://m.do.co/c/eb48adcdd2cb) (Referral link) * [Vultr](https://www.vultr.com/) * [Linode](https://www.linode.com/) -3. Must have Dockerfiles or docker-compose files to start your website's services. Each service that interfaces with Nginx (i.e. is connected to with a port) can be configured with the following environment variables: +3. Must have Dockerfiles or docker-compose files to start your website's services. Each service that interfaces with Nginx (i.e. is connected to with a port) can be configured using the following environment variables: - *VIRTUAL_HOST* - the website's name(s), separated by a comma with no spaces (e.g. `examplesite.com,www.examplesite.com`) - *VIRTUAL_PORT* - the container's port - *LETSENCRYPT_HOST* - website name used by LetsEncrypt. Most likely the same as *VIRTUAL_HOST* - *LETSENCRYPT_EMAIL* - the email address to be associated with the LetsEncrypt process ## Getting started + +### Running locally +1. Clone repository: + `git clone https://github.com/MattHalloran/NginxSSLReverseProxy && cd NginxSSLReverseProxy` +2. Run setup script: + `chmod +x ./scripts/fullSetup.sh && ./scripts/fullSetup.sh` +3. Start docker: + a. `sudo docker-compose -f docker-compose.local.yml up -d` + +### Running on a VPS 1. Set up VPS ([example](https://www.youtube.com/watch?v=Dwlqa6NJdMo&t=142s)). 2. Edit DNS settings to point to the VPS. Here is an example: | Host Name | Type | TTL | Data | @@ -39,7 +47,7 @@ Heavily inspired by [this article](https://olex.biz/2019/09/hosting-with-docker- 5. Run setup script: `chmod +x ./scripts/fullSetup.sh && ./scripts/fullSetup.sh` 6. Start docker: - `sudo docker-compose up -d` + a. `sudo docker-compose -f docker-compose.remote.yml up -d` ## Common commands @@ -48,6 +56,8 @@ Heavily inspired by [this article](https://olex.biz/2019/09/hosting-with-docker- ## Custom proxy -Custom proxy configurations can be put in the `my_proxy.conf` file. By default, this only contains one line: `client_max_body_size 100m;`. This raises the maximum payload size for uploading files. This is useful if you'd like users to have the ability to upload multiple images in one request, for example. +Custom proxy configurations can be put in the `nginx/conf.d/local.conf` or `nginx/conf.d/remote.conf` file, depending on if this will be running locally or remotely. + +By default, the local version contains the standard configuration for self-signed SSL setup. Both versions also contain `client_max_body_size 100m;`. This raises the maximum payload size for uploading files. This is useful if you'd like users to have the ability to upload multiple images in one request, for example. If you are not using custom configurations, you can remove the docker-compose line `- ./my_proxy.conf:/etc/nginx/conf.d/my_proxy.conf:ro`. diff --git a/docker-compose-local.yml b/docker-compose-local.yml new file mode 100644 index 0000000..c294537 --- /dev/null +++ b/docker-compose-local.yml @@ -0,0 +1,22 @@ +version: '3.8' + +services: + nginx-local: + image: nginx:latest + container_name: nginx-local-dev + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/conf.d/local.conf:/etc/nginx/conf.d/local.conf:ro + - ./certs/localhost+2.pem:/etc/nginx/certs/localhost+2.pem + - ./certs/localhost+2-key.pem:/etc/nginx/certs/localhost+2-key.pem + # Remove default config + - /dev/null:/etc/nginx/conf.d/default.conf:ro + networks: + - proxy + +networks: + proxy: + name: nginx-proxy + external: true \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose-remote.yml similarity index 90% rename from docker-compose.yml rename to docker-compose-remote.yml index c689aa3..446d471 100644 --- a/docker-compose.yml +++ b/docker-compose-remote.yml @@ -15,7 +15,7 @@ services: - dhparam:/etc/nginx/dhparam - certs:/etc/nginx/certs:ro - /var/run/docker.sock:/tmp/docker.sock:ro - - ./my_proxy.conf:/etc/nginx/conf.d/my_proxy.conf:ro + - ./nginx/conf.d/remote.conf:/etc/nginx/conf.d/remote.conf:ro - ./50x.html:/usr/share/nginx/html/errors/50x.html:ro networks: - proxy @@ -48,5 +48,5 @@ volumes: networks: proxy: - external: - name: nginx-proxy \ No newline at end of file + name: nginx-proxy + external: true \ No newline at end of file diff --git a/nginx/conf.d/local.conf b/nginx/conf.d/local.conf new file mode 100644 index 0000000..00aa927 --- /dev/null +++ b/nginx/conf.d/local.conf @@ -0,0 +1,68 @@ +client_max_body_size 100m; + +resolver 127.0.0.11 valid=30s; + +# Enable HTTP/2 globally +http2 on; + +server { + listen 80; + listen [::]:80; + server_name localhost; + + # Redirect HTTP to HTTPS + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name localhost; + + ssl_certificate /etc/nginx/certs/localhost+2.pem; + ssl_certificate_key /etc/nginx/certs/localhost+2-key.pem; + + ssl_session_cache shared:SSL:1m; + ssl_session_timeout 10m; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + # Use variables to defer DNS resolution + set $ui_upstream ui:3000; # Change to match your UI server's name and port + set $api_upstream server:5329; # Change to match your API server's name and port + + # UI Server Proxy + location / { + # proxy_pass http://$ui_upstream; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_http_version 1.1; + # proxy_set_header Connection ""; + # proxy_buffering off; # For WebSocket support + # proxy_request_buffering off; + proxy_pass http://$ui_upstream; + 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-Proto $scheme; + } + + # API Server Proxy + location /api/ { + proxy_pass http://$api_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_http_version 1.1; + proxy_set_header Connection ""; + } + + # Support for WebSocket connections + location /sockjs-node { + proxy_pass http://$ui_upstream/sockjs-node; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + } +} \ No newline at end of file diff --git a/my_proxy.conf b/nginx/conf.d/remote.conf similarity index 100% rename from my_proxy.conf rename to nginx/conf.d/remote.conf diff --git a/scripts/fullSetup.sh b/scripts/fullSetup.sh index 5d791f2..a79149a 100644 --- a/scripts/fullSetup.sh +++ b/scripts/fullSetup.sh @@ -1,96 +1,190 @@ #!/bin/bash # Fully sets up server -HERE=`dirname $0` -source "${HERE}/prettify.sh" - -# ======================================================== -# General Ubuntu setup -# ======================================================== -header "Cleaning up apt library" -sudo rm -rvf /var/lib/apt/lists/* - -header "Upgrading cache limit" -sed -i 's/^.*APT::Cache-Limit.*$/APT::Cache-Limit \"100000000\";/' /etc/apt/apt.conf.d/70debconf - -header "Checking for package updates" -sudo apt-get update -header "Running upgrade" -sudo apt-get -y upgrade - -info "Updating max listeners, since npm uses a lot. Not sure exactly what they do, but the default max amount is not enough" -echo fs.inotify.max_user_watches=20000 | sudo tee -a /etc/sysctl.conf -echo vm.overcommit_memory=1 | sudo tee -a /etc/sysctl.conf - -# ======================================================== -# Installing required packages -# ======================================================== - -# -------------------------------------------------------- -# Docker -# -------------------------------------------------------- -header "Cleaning up old versions of Docker" -sudo apt-get remove docker docker-engine docker.io containerd runc - -header "Installing Docker prerequisites" -sudo apt-get install \ - apt-transport-https \ - ca-certificates \ - curl \ - gnupg \ - lsb-release - -header "Installing Docker from official GPG key" -curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg - -header "Specify stable version of Docker" -echo \ - "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \ - $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null - -header "Installing Docker Engine" -sudo apt-get install docker-ce docker-ce-cli containerd.io - -header "Verifing that Docker Engine is running successfully. Container will automatically close" -sudo docker run hello-world - -header "Creating docker user group, so docker can be run without sudo" -sudo groupadd docker -sudo usermod -aG docker $USER - -header "Configuring Docker to run on boot" -sudo systemctl enable docker.service -sudo systemctl enable containerd.service - -header "Installing Docker Compose" -sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose - -header "Making Docker Compose executable" -sudo chmod +x /usr/local/bin/docker-compose - -header "Create proxy network" -sudo docker network create nginx-proxy - - -# -------------------------------------------------------- -# Nginx -# -------------------------------------------------------- -info "Nginx will be inside a docker instance" -header "Purging any existing Nginx configurations" -sudo apt-get purge nginx nginx-common - - -# ======================================================== -# Setting up firewall -# ======================================================== -info "Since Nginx is inside docker, we must handle the firewall settings ourselves" -header "Setting up firewall" -# Enable firewall -sudo ufw enable -# Disable all connections -sudo ufw default allow outgoing -sudo ufw default deny incoming -# Only allow 80 and 443 (80 is required for certificates) -sudo ufw allow 80/tcp -sudo ufw allow ssh - -sudo sysctl -p + +set -e # Exit if any command fails +set -o pipefail # Exit if piped command (e.g. curl, apt-get) fails + +HERE=$(dirname $0) +source "${HERE}/utils.sh" + +check_root_privileges() { + if [[ $EUID -ne 0 ]]; then + echo "This script must be run as root or with sudo privileges" + exit 1 + fi +} + +handle_apt_errors() { + # Function to handle errors during apt operations + if ! "$@"; then + error "ERROR: APT operation failed: $*" + warning "APT failures may affect the installation of necessary components. Check output above." + fi +} + +setup_ubuntu() { + header "Cleaning up apt library" + sudo rm -rvf /var/lib/apt/lists/* + + header "Upgrading cache limit" + sed -i 's/^.*APT::Cache-Limit.*$/APT::Cache-Limit \"100000000\";/' /etc/apt/apt.conf.d/70debconf + + header "Checking for package updates" + handle_apt_errors sudo apt-get update + + header "Running upgrade" + handle_apt_errors sudo apt-get -y upgrade + + info "Updating max listeners, since npm uses a lot. Not sure exactly what they do, but the default max amount is not enough" + # Remove any duplicates of fs.inotify.max_user_watches and vm.overcommit_memory + sudo sed -i '/^fs.inotify.max_user_watches=.*$/d' /etc/sysctl.conf + sudo sed -i '/^vm.overcommit_memory=.*$/d' /etc/sysctl.conf + # Add fs.inotify.max_user_watches if not present + if ! grep -q "^fs.inotify.max_user_watches=30000$" /etc/sysctl.conf; then + echo "fs.inotify.max_user_watches=30000" | sudo tee -a /etc/sysctl.conf + else + info "fs.inotify.max_user_watches is already set" + fi + # Add vm.overcommit_memory if not present + if ! grep -q "^vm.overcommit_memory=1$" /etc/sysctl.conf; then + echo "vm.overcommit_memory=1" | sudo tee -a /etc/sysctl.conf + else + info "vm.overcommit_memory is already set" + fi +} + +setup_docker() { + header "Installing Docker prerequisites" + if ! command -v docker >/dev/null 2>&1; then + sudo apt-get install -y apt-transport-https ca-certificates curl gnupg lsb-release + + header "Adding Docker’s official GPG key" + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg + + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null + sudo apt-get update + sudo apt-get install -y docker-ce docker-ce-cli containerd.io + else + info "Detected Docker version: $(docker --version)" + fi + + header "Verifying Docker Engine" + sudo docker run hello-world || true # Non-blocking + # Remove the "hello-world" container to avoid clutter + if sudo docker ps -a --filter "ancestor=hello-world" --format "{{.ID}}" | grep -q .; then + sudo docker rm $(sudo docker ps -a --filter "ancestor=hello-world" --format "{{.ID}}") + fi + + if ! getent group docker >/dev/null; then + sudo groupadd docker + sudo usermod -aG docker $USER + fi + + header "Configuring Docker to start on boot" + sudo systemctl enable docker.service + sudo systemctl enable containerd.service + + if ! command -v docker-compose >/dev/null; then + header "Installing Docker Compose" + sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + fi + + if ! sudo docker network ls --filter name=^nginx-proxy$ --format "{{.Name}}" | grep -qw nginx-proxy; then + header "Creating proxy network" + sudo docker network create nginx-proxy + fi +} + +setup_self_cert() { + if ! command -v mkcert >/dev/null; then + header "Installing mkcert for local SSL development certificates" + sudo apt-get install -y libnss3-tools + curl -L "https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/mkcert-v1.4.4-linux-amd64" -o mkcert + chmod +x mkcert + sudo mv mkcert /usr/local/bin/ + mkcert -install + fi + + header "Generating SSL certificates for localhost" + local CERT_DIR="${HERE}/../certs" + mkdir -p "${CERT_DIR}" + if [ ! -f "${CERT_DIR}/localhost+2.pem" ]; then + cd "${CERT_DIR}" + mkcert localhost 127.0.0.1 ::1 + info "Certificates generated at: ${CERT_DIR}" + cd - + else + info "Existing SSL certificates found. Skipping regeneration." + fi + + # Ensure the certificates are readable + chmod 644 "${CERT_DIR}/localhost+2.pem" +} + +purge_nginx() { + header "Checking for Nginx on host machine" + info "Nginx will be inside a docker instance, rather than installed on the host machine. We will need to purge any existing Nginx configurations on the host machine." + + # Check if nginx is installed + if dpkg -l | grep -qw nginx; then + # Nginx is installed, ask for confirmation to purge + if prompt_confirm "Nginx configurations found. Do you want to purge them?"; then + header "Purging existing Nginx configurations" + sudo apt-get purge -y nginx nginx-common + else + info "Purging canceled by user." + fi + else + info "No existing Nginx configurations found. No action required." + fi +} + +setup_firewall() { + info "Since Nginx is inside docker, we must handle the firewall settings ourselves" + header "Setting up firewall" + # Enable firewall + sudo ufw enable + # Disable all connections + sudo ufw default allow outgoing + sudo ufw default deny incoming + # Only allow 80 and 443 (80 is required for certificates) + sudo ufw allow 80/tcp + sudo ufw allow ssh + sudo sysctl -p +} + +SERVER_LOCATION="local" # Default to local +main() { + while [[ $# -gt 0 ]]; do + key="$1" + case $key in + -l | --location) + if [ -z "$2" ] || [[ "$2" == -* ]]; then + echo "Error: Option $key requires an argument." + exit 1 + fi + SERVER_LOCATION="${2}" + shift # past argument + shift # past value + ;; + -h | --help) + echo "Usage: $0 [-l SERVER_LOCATION] [-h]" + echo " -l --location: Server location (e.g. \"local\", \"remote\")" + echo " -h --help: Show this help message" + exit 0 + ;; + esac + done + + check_root_privileges + setup_ubuntu + setup_docker + if [ "$SERVER_LOCATION" == "local" ]; then + setup_self_cert + fi + purge_nginx + setup_firewall +} + +run_if_executed main "$@" diff --git a/scripts/prettify.sh b/scripts/prettify.sh deleted file mode 100644 index 8ead37b..0000000 --- a/scripts/prettify.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/bash -# These functions help to prettify echos - -# Determine if tput is available -if [ -n "$(command -v tput)" ]; then - # Set colors - RED=$(tput setaf 1) - GREEN=$(tput setaf 2) - YELLOW=$(tput setaf 3) - BLUE=$(tput setaf 4) - MAGENTA=$(tput setaf 5) - CYAN=$(tput setaf 6) - WHITE=$(tput setaf 7) - RESET=$(tput sgr0) -else - RED="" - GREEN="" - YELLOW="" - BLUE="" - MAGENTA="" - CYAN="" - WHITE="" - RESET="" -fi - -# Print header message -header() { - echo "${MAGENTA}${1}${RESET}" -} - -# Print info message -info() { - echo "${CYAN}${1}${RESET}" -} - -# Print success message -success() { - echo "${GREEN}${1}${RESET}" -} - -# Print error message -error() { - echo "${RED}${1}${RESET}" -} \ No newline at end of file diff --git a/scripts/utils.sh b/scripts/utils.sh new file mode 100644 index 0000000..3b67ca2 --- /dev/null +++ b/scripts/utils.sh @@ -0,0 +1,112 @@ +#!/bin/bash + +# Exit codes +E_NO_TPUT=1 + +# Set default terminal type if not set +export TERM=${TERM:-xterm} + +# Helper function to get color code +get_color_code() { + local color=$1 + case $color in + RED) echo "1" ;; + GREEN) echo "2" ;; + YELLOW) echo "3" ;; + BLUE) echo "4" ;; + MAGENTA) echo "5" ;; + CYAN) echo "6" ;; + WHITE) echo "7" ;; + *) echo "0" ;; + esac +} + +# Initialize a single color +initialize_color() { + local color_name="$1" + local color_code=$(get_color_code "$color_name") + + if [ -t 1 ] && command -v tput >/dev/null 2>&1; then + eval "$color_name=$(tput setaf "$color_code")" + else + eval "$color_name=''" + fi +} + +# Initialize color reset +initialize_reset() { + if [ -t 1 ] && command -v tput >/dev/null 2>&1; then + RESET=$(tput sgr0) + else + RESET='' + fi +} + +# Echo colored text +echo_color() { + local color="$1" + local message="$2" + + initialize_color "$color" + initialize_reset + echo "${!color}${message}${RESET}" +} + +# Print header message +header() { + echo_color MAGENTA "$1" +} + +# Print info message +info() { + echo_color CYAN "$1" +} + +# Print success message +success() { + echo_color GREEN "$1" +} + +# Print error message +error() { + echo_color RED "$1" +} + +# Print warning message +warning() { + echo_color YELLOW "$1" +} + +# Print input prompt message +prompt() { + echo_color BLUE "$1" +} + +# One-line confirmation prompt +prompt_confirm() { + local message="$1" + prompt "$message (y/n) " + read -r -n 1 confirm + echo + case "$confirm" in + [Yy]*) return 0 ;; # User confirmed + *) return 1 ;; # User did not confirm + esac +} + +# Exit with error message and code +exit_with_error() { + local message="$1" + local code="${2:-1}" # Default to exit code 1 if not provided + error "$message" + exit "$code" +} + +# Only run a function if the script is executed (not sourced) +run_if_executed() { + local callback="$1" + shift # Remove the first argument + if [[ "${BASH_SOURCE[1]}" == "${0}" ]]; then + "$callback" "$@" + fi +}