A Complete Step-by-Step Guide with Security Hardening
This architecture uses Docker containers to isolate services, with Nginx acting as a reverse proxy and static file server. The React application is served as static files, while API requests are proxied to a backend container.
Ensure you have Ubuntu Server 20.04 LTS or newer installed and updated.
sudo apt update && sudo apt upgrade -y
# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# Add your user to docker group
sudo usermod -aG docker $USER
# Start and enable Docker
sudo systemctl start docker
sudo systemctl enable docker
# Verify installation
docker --version
# Download Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
# Make it executable
sudo chmod +x /usr/local/bin/docker-compose
# Verify installation
docker-compose --version
# Install Node.js 18.x
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# Verify installation
node --version
npm --version
Create a well-organized project structure:
my-web-app/
├── docker-compose.yml
├── nginx/
│ ├── Dockerfile
│ ├── nginx.conf
│ └── ssl/
│ ├── cert.pem
│ └── key.pem
├── frontend/
│ ├── package.json
│ ├── public/
│ └── src/
│ ├── App.js
│ └── index.js
└── backend/
├── Dockerfile
├── package.json
└── server.js
mkdir -p my-web-app/{nginx/ssl,frontend,backend}
cd my-web-app
cd frontend
npx create-react-app .
# Wait for installation to complete
Edit frontend/src/App.js:
import React, { useState, useEffect } from 'react';
import './App.css';
function App() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch('/api/data');
const result = await response.json();
setData(result);
} catch (error) {
console.error('Error fetching data:', error);
}
setLoading(false);
};
return (
<div className="App">
<header className="App-header">
<h1>React + REST API + Nginx + Docker</h1>
<button onClick={fetchData} disabled={loading}>
{loading ? 'Loading...' : 'Fetch Data from API'}
</button>
{data && (
<div>
<h2>API Response:</h2>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
)}
</header>
</div>
);
}
export default App;
npm run build
# This creates a 'build' folder with optimized production files
cd ../backend
npm init -y
npm install express cors helmet
Create backend/server.js:
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const app = express();
const PORT = process.env.PORT || 3000;
// Security middleware
app.use(helmet());
app.use(cors());
app.use(express.json());
// Health check endpoint
app.get('/api/health', (req, res) => {
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});
// Sample data endpoint
app.get('/api/data', (req, res) => {
res.json({
message: 'Hello from REST API!',
data: [
{ id: 1, name: 'Item 1', value: 100 },
{ id: 2, name: 'Item 2', value: 200 },
{ id: 3, name: 'Item 3', value: 300 }
],
timestamp: new Date().toISOString()
});
});
// Sample POST endpoint
app.post('/api/data', (req, res) => {
const { name, value } = req.body;
res.json({
success: true,
created: { id: Date.now(), name, value }
});
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`API Server running on port ${PORT}`);
});
Create backend/Dockerfile:
FROM node:18-alpine
# Create app directory
WORKDIR /usr/src/app
# Install app dependencies
COPY package*.json ./
RUN npm ci --only=production
# Copy app source
COPY . .
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Change ownership
RUN chown -R nodejs:nodejs /usr/src/app
# Switch to non-root user
USER nodejs
# Expose port
EXPOSE 3000
# Start app
CMD ["node", "server.js"]
Create nginx/nginx.conf:
# Main nginx configuration
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
# Performance optimization
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# Hide nginx version
server_tokens off;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript
application/x-javascript application/xml+rss
application/json application/javascript;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
# Upstream backend
upstream api_backend {
server backend:3000;
keepalive 32;
}
server {
listen 80;
server_name localhost;
# Security limits
client_max_body_size 10M;
client_body_buffer_size 128k;
# Root directory for React app
root /usr/share/nginx/html;
index index.html;
# API proxy
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
limit_conn conn_limit 10;
proxy_pass http://api_backend;
proxy_http_version 1.1;
# Proxy headers
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;
proxy_set_header Connection "";
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# React app - serve static files
location / {
try_files $uri $uri/ /index.html;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# Deny access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}
# HTTPS server (uncomment and configure for production)
# server {
# listen 443 ssl http2;
# server_name your-domain.com;
#
# ssl_certificate /etc/nginx/ssl/cert.pem;
# ssl_certificate_key /etc/nginx/ssl/key.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;
#
# # ... rest of server configuration
# }
}
Create nginx/Dockerfile:
FROM nginx:1.25-alpine
# Remove default nginx config
RUN rm /etc/nginx/nginx.conf
# Copy custom nginx config
COPY nginx.conf /etc/nginx/nginx.conf
# Copy React build files
COPY ../frontend/build /usr/share/nginx/html
# Create non-root user (nginx user already exists)
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx && \
touch /var/run/nginx.pid && \
chown -R nginx:nginx /var/run/nginx.pid
# Switch to non-root user
USER nginx
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Create docker-compose.yml in the project root:
version: '3.8'
services:
nginx:
build:
context: .
dockerfile: nginx/Dockerfile
container_name: web-nginx
ports:
- "80:80"
- "443:443"
networks:
- app-network
depends_on:
- backend
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
- NET_BIND_SERVICE
read_only: true
tmpfs:
- /var/cache/nginx
- /var/run
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
# Uncomment for SSL
# - ./nginx/ssl:/etc/nginx/ssl:ro
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: api-backend
expose:
- "3000"
networks:
- app-network
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp
environment:
- NODE_ENV=production
- PORT=3000
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
app-network:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
Create backend/.dockerignore:
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
.DS_Store
Already included in the nginx.conf above. This prevents DDoS attacks and API abuse:
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req zone=api_limit burst=20 nodelay;
Generate self-signed certificate for testing:
cd nginx/ssl
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout key.pem -out cert.pem \
-subj "/C=US/ST=State/L=City/O=Organization/CN=localhost"
For production, use Let's Encrypt:
sudo apt install certbot
sudo certbot certonly --standalone -d your-domain.com
Add to nginx.conf for enhanced security:
# Content Security Policy
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" always;
# Strict Transport Security (HTTPS only)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Prevent clickjacking
add_header X-Frame-Options "DENY" always;
Add to server block in nginx.conf:
# Block SQL injection attempts
location ~* (union|select|insert|cast|set|declare|drop|update|md5|benchmark) {
deny all;
}
# Block directory traversal attempts
location ~* \.\./|\.\.\\ {
deny all;
}
# Block access to sensitive files
location ~* \.(env|log|ini|conf|bak|sql|sh)$ {
deny all;
}
| Security Feature | Purpose | Implementation |
|---|---|---|
| Rate Limiting | Prevent DDoS attacks | limit_req_zone directive |
| SSL/TLS | Encrypt traffic | TLS 1.2+ only |
| Security Headers | Browser protection | X-Frame-Options, CSP, HSTS |
| Hide Version | Reduce attack surface | server_tokens off |
Already implemented in Dockerfiles. Always run containers as non-root users to limit privilege escalation attacks.
# In Dockerfile
USER nodejs # or USER nginx
Already configured in docker-compose.yml. This prevents malicious code from writing to the filesystem:
read_only: true
tmpfs:
- /tmp
- /var/cache/nginx
Minimize container privileges:
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # Only add what's needed
# Enable Docker Content Trust
export DOCKER_CONTENT_TRUST=1
# Scan images for vulnerabilities
docker scan backend:latest
# Use AppArmor/SELinux profiles
security_opt:
- no-new-privileges:true
- apparmor=docker-default
Edit /etc/docker/daemon.json:
{
"icc": false,
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"userns-remap": "default",
"live-restore": true,
"userland-proxy": false,
"no-new-privileges": true
}
Restart Docker after changes:
sudo systemctl restart docker
# Update base images regularly
docker pull node:18-alpine
docker pull nginx:1.25-alpine
# Scan for vulnerabilities using Trivy
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add -
echo "deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | sudo tee -a /etc/apt/sources.list.d/trivy.list
sudo apt update
sudo apt install trivy
# Scan images
trivy image nginx:1.25-alpine
trivy image node:18-alpine
# Enable UFW
sudo ufw enable
# Allow SSH
sudo ufw allow ssh
# Allow HTTP and HTTPS
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Deny all other incoming traffic
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Configure Docker to work with UFW
# Edit /etc/default/docker and add:
DOCKER_OPTS="--iptables=false"
# Restart Docker
sudo systemctl restart docker
Use custom bridge networks (already implemented in docker-compose.yml) to isolate containers:
networks:
app-network:
driver: bridge
internal: false # Set to true for fully isolated network
cd frontend
npm run build
cd ..
# Build images
docker-compose build
# Start services
docker-compose up -d
# View logs
docker-compose logs -f
# Check container status
docker-compose ps
# Test React app
curl http://localhost/
# Test API health check
curl http://localhost/api/health
# Test API data endpoint
curl http://localhost/api/data
# Test from browser
# Open: http://your-server-ip/
# View container logs
docker logs web-nginx
docker logs api-backend
# Monitor resource usage
docker stats
# Check nginx configuration
docker exec web-nginx nginx -t
# Reload nginx configuration
docker exec web-nginx nginx -s reload
# View nginx access logs
docker exec web-nginx tail -f /var/log/nginx/access.log
# Backup Docker volumes
docker run --rm -v my-web-app_data:/data -v $(pwd):/backup \
alpine tar czf /backup/backup.tar.gz /data
# Backup configurations
tar czf config-backup.tar.gz nginx/ docker-compose.yml
# Stop containers
docker-compose down
# Restore from backup
tar xzf config-backup.tar.gz
# Restart services
docker-compose up -d
| Command | Description |
|---|---|
docker-compose up -d |
Start all services in background |
docker-compose down |
Stop and remove all containers |
docker-compose restart |
Restart all services |
docker-compose logs -f [service] |
View real-time logs |
docker-compose exec [service] sh |
Access container shell |
docker system prune -a |
Clean up unused Docker resources |
# Check logs
docker-compose logs [service-name]
# Verify configuration
docker-compose config
# Check port conflicts
sudo netstat -tulpn | grep :80
Usually means Nginx can't reach the backend:
# Check backend is running
docker-compose ps
# Check backend logs
docker-compose logs backend
# Verify network connectivity
docker-compose exec nginx ping backend
# Check file ownership
ls -la nginx/ frontend/ backend/
# Fix permissions
sudo chown -R $USER:$USER .
# Rebuild containers
docker-compose build --no-cache