NGINX: Serve a Staging and Production Site with Auth & Maintenance Mode

This guide walks through setting up NGINX to serve both a production and a staging site, with a focus on reliable deployment and access control. It includes how to:

  • Enable and disable maintenance mode to minimise downtime during updates
  • Use robots.txt to control search engine indexing for each environment
  • Protect the staging site with basic HTTP authentication to prevent unauthorised access
  • Automate deployments with push_staging and push_production scripts

This setup helps keep your websites secure, organised, and easy to manage while allowing smooth development and updates.

Prerequisites #

Before starting, ensure the following are in place:

  • A registered domain name for the production website (e.g., example.com)
  • A configured subdomain for the staging environment (e.g., staging.example.com)

Basic Workflow #

  1. Develop Locally
    Create and edit website files on your local machine. This provides a safe environment to build and test changes without affecting any live site.
  2. Push to Staging
    Upload your changes from local to the staging server. This allows you to verify that the updates work correctly in an environment that mirrors production, catching issues early.
  3. Push to Production
    Once confirmed on staging, push the changes to the live production site. This ensures your live site remains stable and minimizes the risk of downtime or errors.

Benefits:

  • Safe development without risking the live site

  • Early detection of issues in a realistic environment

  • Smooth, controlled deployment process

  • Reduced downtime and improved site reliability

Step 1: Plan Directory Structure #

Organise the file system for clarity, security, and scalability. Separate production and staging environments while centralising shared resources like error pages.

Directory structure:

filesystem
/var/www/example.com/
  └── public_html/
      ├── index.html
      └── robots.txt

/var/www/staging.example.com/
  └── public_html/
      ├── index.html
      └── robots.txt

/var/www/html/
  └── errors/
      └── 503.html

/etc/nginx/sites-available/
  ├── default
  ├── example.com
  └── staging.example.com

/etc/nginx/sites-enabled/
  ├── default -> ../sites-available/default (symlink)
  ├── example.com -> ../sites-available/example.com (symlink)
  └── staging.example.com -> ../sites-available/staging.example.com (symlink)

Purpose

1. Production Site

  • Path: /var/www/example.com/public_html/
  • Purpose: Live website for end-users.
  • Reasoning: Kept isolated for performance, stability, and security.

2. Staging Site

  • Path: /var/www/staging.example.com/public_html/
  • Purpose: Internal pre-production testing area.
  • Reasoning: Prevents experimental changes from affecting live content.

3. Shared Error Pages

  • Path: /var/www/html/errors/
  • Purpose: Central error pages for all virtual hosts.
  • Reasoning: Avoid duplication, ease updates, and ensure consistent UX.

4. NGINX Configuration

  • sites-available: Static configuration files.
  • sites-enabled: Active configurations via symbolic links.
  • Reasoning: Standard NGINX best practice. Decouples definition from activation, enabling easier site enable/disable with ln or unlink.

Step 2: Create 503 Maintenance Page #

Centralising error pages like the 503 maintenance page ensures consistent user experience and simplifies management by eliminating duplication across virtual hosts.

For detailed instructions, see: NGINX: Set Up Centralised Custom Error Pages

Step 3: Create Site Configs #

default #

Define a default server block to catch and reject unmatched or unintended requests. This enhances security and server hygiene by dropping connections not explicitly permitted.

Open default config:

bash
sudo vim /etc/nginx/sites-available/default

Add the following configuration:

/etc/nginx/sites-available/default
server {
  listen 80 default_server;
  listen [::]:80 default_server;
  server_name _;

  return 444;   # Drop HTTP requests silently
}

server {
  listen 443 ssl http2 default_server;
  listen [::]:443 ssl http2 default_server;
  server_name _;

  ssl_certificate /etc/ssl/cloudflare/cert.pem;
  ssl_certificate_key /etc/ssl/cloudflare/key.pem;

  return 444;  # Drop HTTPS requests silently
}

example.com #

Define the production site configuration with HTTPS support, error handling, maintenance mode, and PHP processing.

Create and open site config:

bash
sudo vim /etc/nginx/sites-available/example.com

Add the following configuration:

/etc/nginx/sites-available/example.com
# Redirect HTTP to HTTPS
server {
  listen 80;
  listen [::]:80;
  server_name example.com;

  return 301 https://$host$request_uri;
}

# HTTPS configuration
server {
  listen 443;
  listen [::]:443;
  server_name example.com;

  ...

  # SSL certificate
  ssl_certificate /etc/ssl/cloudflare/cert.pem;
  ssl_certificate_key /etc/ssl/cloudflare/key.pem;

  # Include shared error page locations
  include snippets/error_pages.conf;

  # Maintenance mode
  location / {
    if (-f /var/www/html/errors/maintenance.flag) {
      return 503;
    }
    try_files $uri $uri/ /index.php$is_args$args;
  }

  ...

  # PHP processing
  location ~ \.php$ {
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
  }
}

Explanation: Maintenance Mode

The maintenance mode mechanism allows administrators to temporarily take the site offline in a controlled manner, returning a 503 Service Unavailable response to all users.

How It Works

  • Inside the server block, this condition is checked:
/etc/nginx/sites-available/example.com
if (-f /var/www/html/errors/maintenance.flag) {
  return 503;
}
  • If the file /var/www/html/errors/maintenance.flag exists, NGINX serves a 503 status.
  • This halts normal processing and prevents the backend application from being reached.
  • The 503 error response should be defined in the included error_pages.conf to display a friendly message.

Commands to Toggle Maintenance Mode

To enable maintenance mode:

bash
sudo touch /var/www/html/errors/maintenance.flag

To disable maintenance mode:

bash
sudo rm /var/www/html/errors/maintenance.flag

Advantages

  • No need to alter application code or disable services
  • Reversible with a simple file operation
  • Cleanly integrates with shared error handling

staging.example.com #

Set up the staging environment with HTTPS enforcement, error handling, and PHP processing. This configuration mirrors production while allowing internal testing and isolation.

Create and open site config:

bash
sudo vim /etc/nginx/sites-available/staging.example.com

Add the following:

/etc/nginx/sites-available/staging.example.com
# Redirect HTTP to HTTPS
server {
  listen 80;
  listen [::]:80;
  server_name staging.example.com;

  return 301 https://$host$request_uri;
}

# HTTPS server block
server {
  listen 443;
  listen [::]:443;
  server_name staging.example.com;

  ...

  # SSL certificate
  ssl_certificate /etc/ssl/cloudflare/cert.pem;
  ssl_certificate_key /etc/ssl/cloudflare/key.pem;

  # Include shared error page locations
  include snippets/error_pages.conf;

  ...

  # PHP processing
  location ~ \.php$ {
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
  }
}

The maintenance.flag check is unnecessary on the staging site since staging is for testing. Only the production site should use it to enable maintenance mode.

Step 4: Enable Sites #

To activate the site configurations, create symbolic links from sites-available to sites-enabled. This tells NGINX which server blocks to load.

Create symbolic links:

bash
sudo ln -s /etc/nginx/sites-available/default /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/staging.example.com /etc/nginx/sites-enabled/

Step 5: Test and Reload #

Verify configuration and reload NGINX:

bash
sudo nginx -t && sudo systemctl reload nginx

This completes activation of the NGINX server blocks.

Step 6: Add robots.txt #

example.com #

Goal: Allow the production site to be indexed by search engines.

Create robots.txt file:

bash
sudo vim /var/www/example.com/public_html/robots.txt

Add the following:

/var/www/staging.example.com/public_html/robots.txt
User-agent: *
Disallow:
Sitemap: https://example.com/sitemap.xml

Explanation:

Directive Description
User-agent: * Applies the rule to all web crawlers
Disallow: Allows indexing of all pages

staging.example.com #

Goal: Prevent the staging environment from being indexed by search engines.

Create robots.txt file:

bash
sudo vim /var/www/staging.example.com/public_html/robots.txt

Add the following:

/var/www/staging.example.com/public_html/robots.txt
User-agent: *
Disallow: /

Explanation:

Directive Description
User-agent: * Applies the rule to all web crawlers
Disallow: / Blocks all access to the site, preventing indexing

This ensures the staging site remains hidden from search engines.

Step 7: Set Up Basic HTTP Authentication #

This restricts access to the staging site by requiring a username and password. It prevents unauthorised users from viewing the site, protecting sensitive development work and reducing the risk of accidental public exposure before the site is ready.

For detailed instructions, see: How to Set Up Basic HTTP Authentication in NGINX.

Step 8: Create Deployment Scripts #

Local Machine #

push_staging.sh

This script syncs your local project to the remote staging server using rsync. It performs a dry run first, confirms with the user, then optionally executes the sync.

Create and edit the script file:

bash
sudo vim <path/to>/push_staging.sh

Add the following:

<path/to>/push_staging.sh
#!/bin/bash

SRC_DIR=<path/to/project>
DEST_DIR="<host>:/var/www/staging.example.com/public_html/"

echo "Starting dry run..."
rsync -avz --delete --dry-run \
  --exclude='.*' \
  --exclude='/robots.txt' \
  "$SRC_DIR" \
  "$DEST_DIR"

echo
read -n1 -r -p "Dry run complete. Execute push? (Y/N): " choice
echo

if [[ $choice =~ ^[Yy]$ ]]; then
  echo "Executing push..."
  if rsync -avz --delete \
  --exclude='.*' \
  --exclude='/robots.txt' \
    "$SRC_DIR" \
    "$DEST_DIR"; then
    echo "Push completed successfully."
  else
    echo "An error occurred during sync."
  fi
else
  echo "Push canceled."
fi

What it does:

  • Performs a dry run showing files to be synced or deleted
  • Excludes hidden files and robots.txt
  • Prompts for confirmation before proceeding
  • Runs the actual rsync only if confirmed
  • Provides success or error feedback

Make the script executable:

bash
chmod +x <path/to>/push_staging.sh

Remote Server #

shared_functions.sh

This script defines reusable functions for consistent logging and directory validation across scripts.

Create and edit the script:

bash
sudo vim /home/<user>/shared_functions.sh

Add the following:

/home/<user>/shared_functions.sh
#!/bin/bash

# Function to log a message with a timestamp to screen and/or a file
log_entry() {
  local timestamp message

  # Get current timestamp
  timestamp=$(date "+%Y-%m-%d %H:%M:%S")
  
  # Format the message
  message="[$timestamp] $1"

  # Log to screen if enabled
  if [[ "$LOG_TO_SCREEN" == true ]]; then
    echo "$message"
  fi

  # Log to file if enabled
  if [[ "$LOG_TO_FILE" == true && -n "$LOG_FILE" ]]; then
    echo "$message" >> "$LOG_FILE"
  fi
}

# Function to log an info message
log_info() {
  log_entry "$1"
}

# Function to log a warning message
log_warning() {
  log_entry "WARNING: $1"
}

# Function to log an error message and exit the script
log_error() {
  log_entry "ERROR: $1"
  exit 1
}

# Function to ensure a directory exists and is writable
ensure_dir() { 
  local dir="$1"

  # Create the directory if it doesn't exist
  mkdir -p "$dir" || log_error "Failed to create directory '$dir'."

  # Check if the directory is writable, otherwise log an error
  if [[ ! -w "$dir" ]]; then
    log_error "Directory '$dir' is either not created or not writable."
  fi
}

Purpose:

  • log_entry: Logs timestamped messages to screen or file.
  • log_info, log_warning, log_error: Log messages with appropriate severity.
  • ensure_dir: Verifies or creates a writable directory. Exits on failure.

push_production.sh

Automates deploying updates from the staging site to the production site safely.

Create and edit the script:

bash
sudo vim /home/<user>/push_production.sh

Add the following:

/home/<user>/push_production.sh
#!/bin/bash

set -euo pipefail  # Stop script on errors and handle unset variables

# Load shared functions
source /home/<user>/shared_functions.sh

# =============================================================================
# Constants
# =============================================================================

SCRIPT_NAME="$(basename "$0")"
HOME_DIR="/home/<user>"
STAGING_DIR="/var/www/staging.example.com/public_html/"
PRODUCTION_DIR="/var/www/example.com/public_html/"
MAINTENANCE_FLAG="/var/www/html/errors/maintenance.flag"

# Log file
LOG_FILE="$HOME_DIR/${SCRIPT_NAME%.sh}.log"
LOG_TO_SCREEN=false
LOG_TO_FILE=true

# =============================================================================
# Functions
# =============================================================================

# Enable maintenance mode by creating the flag file and reloading nginx
enable_maintenance() {
  log_info "Enabling maintenance mode..."
  sudo touch "$MAINTENANCE_FLAG" || log_error "Failed to create maintenance flag file: $MAINTENANCE_FLAG"
  sudo systemctl reload nginx || log_error "Failed to reload nginx after enabling maintenance mode"
  log_info "Maintenance mode enabled."
}

# Disable maintenance mode by removing the flag file and reloading nginx
disable_maintenance() {
  log_info "Disabling maintenance mode..."
  sudo rm -f "$MAINTENANCE_FLAG" || log_error "Failed to remove maintenance flag file: $MAINTENANCE_FLAG"
  sudo systemctl reload nginx || log_error "Failed to reload nginx after disabling maintenance mode"
  log_info "Maintenance mode disabled."
}

# Synchronise files from staging to production with exclusions
sync_files() {
  log_info "Syncing files from staging to production..."
  sudo rsync -av --delete \
    --exclude='.*' \
    --exclude='/robots.txt' \
    "$STAGING_DIR" "$PRODUCTION_DIR"
  log_info "Sync complete."
}

# =============================================================================
# Main
# =============================================================================

# Ensure the log directory exists and create the log file if logging is enabled
if [[ "$LOG_TO_FILE" == true ]]; then
  log_dir="$(dirname "$LOG_FILE")"
  
  # Ensure the directory exists and is writable
  ensure_dir "$log_dir"
  
  # Create the log file if it doesn't exist
  touch "$LOG_FILE" || log_error "Failed to create log file '$LOG_FILE'."
fi

log_info "Starting sync from staging to production..."

enable_maintenance
sync_files
disable_maintenance

log_info "Sync completed."

What it does:

  • Enables maintenance mode using a flag file and reloads NGINX
  • Rsyncs files from the staging directory to the production site
  • Disables maintenance mode and reloads NGINX
  • Logs all steps to a file

Restrict script execution to the owner:

bash
sudo chown <user>:<user> /home/<user>/push_production.sh /home/<user>/shared_functions.sh
sudo chmod 700 /home/<user>/push_production.sh /home/<user>/shared_functions.sh

Or, using a loop for multiple files:

bash
for file in /home/<user>/push_production.sh /home/<user>/shared_functions.sh; do
  sudo chown <user>:<user> "$file"
  sudo chmod 700 "$file"
done

This ensures only the script owner can read, write, and execute these files, improving security.

Step 9: Test Deployment Scripts #

Local Machine #

To run the local sync script:

bash
cd <path/to/project>
./push_staging.sh

Remote Server #

Run the production push script:

bash
sudo /home/<user>/push_production.sh

Monitor the log output in real-time:

bash
sudo tail -f /home/<user>/push_production.log

To clear the log file without interrupting running processes:

bash
sudo truncate -s 0 /home/<user>/push_production.log

Explanation:

  • truncate adjusts file size without deleting it.
  • -s 0 sets the file size to zero, clearing its contents while keeping the file intact.

Step 10 (Optional): Set Up Aliases #

Create convenient shortcuts by adding aliases to your shell configuration:

~/.aliases
# Local Machine
alias push_staging='<path/to>/push_staging.sh'

# Remove Server
alias push_production='sudo /home/<user>/push_production.sh'
alias push_production_log='sudo tail -f /home/<user>/push_production.log'

These aliases allow you to:

  • Run the staging push script with push_staging
  • Run the production push script with push_production
  • Monitor the production push log in real time with push_production_log