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
andpush_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 #
- 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. - 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. - 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:
/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
orunlink
.
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:
sudo vim /etc/nginx/sites-available/default
Add the following configuration:
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:
sudo vim /etc/nginx/sites-available/example.com
Add the following configuration:
# 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:
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:
sudo touch /var/www/html/errors/maintenance.flag
To disable maintenance mode:
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:
sudo vim /etc/nginx/sites-available/staging.example.com
Add the following:
# 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:
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:
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:
sudo vim /var/www/example.com/public_html/robots.txt
Add the following:
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:
sudo vim /var/www/staging.example.com/public_html/robots.txt
Add the following:
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:
sudo vim <path/to>/push_staging.sh
Add the following:
#!/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:
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:
sudo vim /home/<user>/shared_functions.sh
Add the following:
#!/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:
sudo vim /home/<user>/push_production.sh
Add the following:
#!/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:
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:
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:
cd <path/to/project>
./push_staging.sh
Remote Server #
Run the production push script:
sudo /home/<user>/push_production.sh
Monitor the log output in real-time:
sudo tail -f /home/<user>/push_production.log
To clear the log file without interrupting running processes:
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:
# 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