رفتن به محتوا
LoopX

پروژه: استقرار کامل

توی این درس یاد می‌گیری همه‌ی چیزهایی که در این دوره دیدی را در یک استقرار واقعی و کامل کنار هم بگذاری: یک فروشگاه کتاب با سایت استاتیک و یک API، روی یک سرور Ubuntu، پشت Nginx. سایت را با استقرار اتمی (پوشه‌ی هر نسخه و یک symlink) منتشر می‌کنی و در یک ثانیه برمی‌گردانی، API را در دو نسخه پشت upstream می‌گذاری، HTTPS را با certbot certonly --webroot و تنظیم دستی راه می‌اندازی، gzip و کش، هدرهای امنیتی و rate limit را اضافه می‌کنی، و آخر سر یک اسکریپت بررسی می‌نویسی که همه‌ی این‌ها را در چند ثانیه تست کند.

مسئله: از «روی لپ‌تاپم کار می‌کند» تا «روی اینترنت امن و سریع است»

Section titled “مسئله: از «روی لپ‌تاپم کار می‌کند» تا «روی اینترنت امن و سریع است»”

یک سایت واقعی فقط «بالا بودن» نیست. فهرست کارهایی که یک مدیر سرور حرفه‌ای قبل از گفتن «تمام شد» چک می‌کند، دقیقاً درس‌های همین دوره است:

نیاز درس
سایت استاتیک، صفحه‌ی ۴۰۴، try_files سایت‌های استاتیک، locationها
API پشت Nginx با هدرهای درست reverse proxy
چند نسخه از API، تحمل خرابی load balancing
HTTPS، انتقال HTTP و www، تمدید خودکار HTTPS و Let’s Encrypt
HSTS، nosniff، CSP، مخفی کردن نسخه امنیت و هدرهای امنیتی
gzip، کش یک‌ساله برای فایل‌های hash‌دار عملکرد
محدودیت API و صفحه‌ی ورود محدودسازی درخواست‌ها
لاگ با زمان پاسخ لاگ‌ها

درس‌های قبلی مثل یاد گرفتن تک‌تک کارهای یک مغازه بود: قفل در (HTTPS)، دوربین و نگهبان (هدرهای امنیتی، rate limit)، قفسه‌چینی سریع (کش و gzip)، دو صندوقدار (load balancing). پروژه‌ی پایانی روز افتتاح است: همه را با هم و درست کنار هم راه می‌اندازی، و قبل از باز کردن در، با یک چک‌لیست همه‌چیز را یکی‌یکی امتحان می‌کنی.

معماری نهایی: Nginx تنها چیزی است که به اینترنت باز است (پورت ۸۰ فقط برای انتقال به HTTPS و چالش ACME). فایل‌های سایت از پوشه‌ی نسخه‌ی فعلی (symlink current) و درخواست‌های /api/ به دو نسخه‌ی API روی 127.0.0.1 می‌روند.

ساختار فایل‌ها روی سرور:

/srv/shop-src/ source the developers gave us (html, css, js)
/srv/shop-api/app.js the API
/var/www/shop/releases/<time>/ one folder per deployed version
/var/www/shop/current -> releases/<newest> Nginx root points here
/var/www/letsencrypt/ webroot for ACME challenges
/etc/nginx/conf.d/lx-shop.conf http-level: gzip, limits, upstream, log format
/etc/nginx/snippets/lx-shop-*.conf security headers, proxy settings
/etc/nginx/sites-available/shop.test the site
/usr/local/bin/shop-deploy, shop-rollback, shop-check

مثال‌های عملی (قدم‌به‌قدم)

Section titled “مثال‌های عملی (قدم‌به‌قدم)”

این پروژه روی ماشین آزمایشی server (Ubuntu 24.04، Nginx 1.24، Node.js 18، certbot 2.9) با کاربر root اجرا شده؛ روی سرور خودت جلوی دستورها sudo بگذار. مثل درس HTTPS، به‌جای Let’s Encrypt از Pebble (سرور ACME آزمایشی ساخت خود Let’s Encrypt) استفاده شده، چون ماشین آزمایشی دامنه‌ی واقعی ندارد؛ روی سرور واقعی فقط گزینه‌ی --server و متغیر REQUESTS_CA_BUNDLE را حذف می‌کنی.

قدم ۱: کد تحویلی، و API در دو نسخه

Section titled “قدم ۱: کد تحویلی، و API در دو نسخه”

فایل‌هایی که برنامه‌نویس‌ها داده‌اند. HTML به /assets/style.css و /assets/app.js لینک می‌دهد؛ اسم‌های hash‌دار را اسکریپت استقرار (قدم ۵) می‌سازد:

/srv/shop-src/index.html
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8">
<title>کتاب‌فروشی</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<h1>کتاب‌فروشی</h1>
<ul id="books"></ul>
<script src="/assets/app.js"></script>
</body>
</html>
/srv/shop-src/404.html
<!doctype html>
<html lang="fa" dir="rtl"><meta charset="utf-8"><title>پیدا نشد</title>
<link rel="stylesheet" href="/assets/style.css">
<h1>این صفحه پیدا نشد</h1><p><a href="/">برگشت به فروشگاه</a></p>
</html>
/srv/shop-src/style.css
/* shop styles (repeated rules make the file big enough to compress) */
body { font-family: sans-serif; max-width: 40rem; margin: 2rem auto; line-height: 1.8; }
h1 { color: #0b7a75; border-bottom: 2px solid #0b7a75; padding-bottom: .5rem; }
ul { list-style: none; padding: 0; }
li { padding: .75rem 1rem; margin: .5rem 0; border: 1px solid #ddd; border-radius: .5rem; }
li .price { float: left; color: #555; font-variant-numeric: tabular-nums; }
a { color: #0b7a75; }
/srv/shop-src/app.js
// load the book list from the API and render it
fetch('/api/products')
.then((r) => r.json())
.then((data) => {
const ul = document.getElementById('books');
for (const b of data.products) {
const li = document.createElement('li');
li.textContent = b.title;
const price = document.createElement('span');
price.className = 'price';
price.textContent = b.price.toLocaleString('fa-IR') + ' تومان';
li.append(price);
ul.append(li);
}
});

API: فهرست کتاب‌ها، ورود (همیشه «رمز اشتباه»؛ اپ نمونه است) و یک مسیر سلامت. هر نسخه پورتش را در پاسخ می‌گذارد تا ببینیم کدام جواب داده:

/srv/shop-api/app.js
// shop API: products, login, health
const http = require('http');
const port = Number(process.env.PORT);
const products = [
{ id: 1, title: 'شازده کوچولو', price: 185000 },
{ id: 2, title: 'بوف کور', price: 210000 },
{ id: 3, title: 'کلیدر', price: 950000 },
];
const json = (res, code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'X-Powered-By': 'node' });
res.end(JSON.stringify({ ...body, instance: port }));
};
http.createServer((req, res) => {
if (req.url === '/api/products') return json(res, 200, { products });
if (req.url === '/api/health') return json(res, 200, { status: 'ok' });
if (req.url === '/api/login' && req.method === 'POST') return json(res, 401, { error: 'wrong username or password' });
json(res, 404, { error: 'not found' });
}).listen(port, '127.0.0.1');
/etc/systemd/system/lx-shop-api@.service
[Unit]
Description=Shop API instance on port %i
After=network.target
[Service]
Environment=PORT=%i
ExecStart=/usr/bin/node /srv/shop-api/app.js
User=www-data
Restart=on-failure
[Install]
WantedBy=multi-user.target
Terminal window
systemctl daemon-reload
systemctl enable --now lx-shop-api@3001 lx-shop-api@3002 2>&1 | grep -c 'Created symlink'
sleep 1
systemctl is-active lx-shop-api@3001 lx-shop-api@3002
curl -s http://127.0.0.1:3001/api/health; echo
ps -o user=,args= -C node
خروجی
2
active
active
{"status":"ok","instance":3001}
www-data /usr/bin/node /srv/shop-api/app.js
www-data /usr/bin/node /srv/shop-api/app.js
  • دو نسخه از یک unit قالبی (همان روش درس load balancing): lx-shop-api@3001 و @3002. enable --now یعنی هم الان روشن شوند و هم بعد از ریبوت (2 = دو symlink ساخته شد).
  • هر دو active و /api/health روی ۳۰۰۱ جواب داد.
  • User=www-data: API با کاربر کم‌دسترسی اجرا می‌شود، نه root. اگر روزی API باگ امنیتی داشت، مهاجم فقط دسترسی www-data را دارد. (ps همین را نشان داد.)
  • API فقط روی 127.0.0.1 گوش می‌دهد؛ از بیرون سرور مستقیم در دسترس نیست. تنها راه رسیدن به آن Nginx است.

قدم ۲: تنظیمات مشترک (context http) و snippet ها

Section titled “قدم ۲: تنظیمات مشترک (context http) و snippet ها”

هر چیزی که در سطح http است (و ممکن است سایت‌های دیگر هم بخواهند) در conf.d، و تکه‌هایی که چند بار include می‌شوند در snippets:

/etc/nginx/conf.d/lx-shop.conf
# http-level settings for the shop (and any other site on this server)
server_tokens off;
# compression (lesson: performance)
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
# rate limits (lesson: rate limiting)
limit_req_zone $binary_remote_addr zone=shop_api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=shop_login:10m rate=5r/m;
limit_req_status 429;
# access log with timings (lesson: logs)
log_format shop '$remote_addr [$time_local] "$request" $status $body_bytes_sent '
'rt=$request_time urt=$upstream_response_time up=$upstream_addr';
# two API instances (lesson: load balancing)
upstream shop_backend {
zone shop_backend 64k;
least_conn;
server 127.0.0.1:3001 max_fails=2 fail_timeout=10s;
server 127.0.0.1:3002 max_fails=2 fail_timeout=10s;
keepalive 16;
}
/etc/nginx/snippets/lx-shop-security.conf
# security headers (lesson: security headers); include again in every location that has its own add_header
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
/etc/nginx/snippets/lx-shop-proxy.conf
# proxy settings for the API (lesson: reverse proxy)
proxy_http_version 1.1;
proxy_set_header Connection "";
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_hide_header X-Powered-By;
proxy_connect_timeout 3s;
proxy_read_timeout 30s;
Terminal window
nginx -t 2>&1 | tail -n 1
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful

تنظیمات بدون هیچ سایتی هم معتبرند. چند نکته:

  • server_tokens off در http، برای همه‌ی سایت‌ها (Server: nginx بدون نسخه).
  • دو zone محدودیت: یکی سخاوتمندانه برای کل API، یکی سفت برای ورود.
  • upstream shop_backend با least_conn، max_fails و keepalive 16؛ برای همین در snippet proxy، proxy_http_version 1.1 و Connection "" آمده (درس reverse proxy). zone upstream اسمش با zone های limit_req فرق دارد؛ هر حافظه‌ی مشترک باید اسم یکتا داشته باشد.
  • proxy_hide_header X-Powered-By: API ما (مثل خیلی از فریم‌ورک‌ها) هدر X-Powered-By: node می‌فرستد که به مهاجم فناوری را لو می‌دهد.

قدم ۳: اول HTTP، فقط برای گرفتن گواهی

Section titled “قدم ۳: اول HTTP، فقط برای گرفتن گواهی”

مشکل مرغ و تخم‌مرغ: تنظیم HTTPS به فایل گواهی نیاز دارد (بدون آن nginx -t شکست می‌خورد؛ اشتباهات رایج)، و گرفتن گواهی به یک سایت HTTP در حال کار. پس اول فقط بخش HTTP را می‌سازیم: چالش‌های ACME از یک پوشه‌ی جدا (webroot) جواب داده می‌شوند و بقیه به HTTPS منتقل می‌شوند.

/etc/nginx/sites-available/shop.test
# port 80: only ACME challenges and the redirect to https
server {
listen 80;
server_name shop.test www.shop.test;
location /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
}
location / {
return 301 https://shop.test$request_uri;
}
}
Terminal window
mkdir -p /var/www/letsencrypt
rm -f /etc/nginx/sites-enabled/default
ln -sf /etc/nginx/sites-available/shop.test /etc/nginx/sites-enabled/
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
export REQUESTS_CA_BUNDLE=/opt/pebble/test/certs/pebble.minica.pem
certbot certonly --webroot -w /var/www/letsencrypt -d shop.test -d www.shop.test \
--server https://localhost:14000/dir --agree-tos -m admin@shop.test --no-eff-email --non-interactive \
--deploy-hook 'systemctl reload nginx' 2>&1 | grep -E 'Successfully|saved at|expires on'
ls /etc/letsencrypt/live/shop.test/
grep -E '^(authenticator|webroot_path|renew_hook)' /etc/letsencrypt/renewal/shop.test.conf
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/shop.test/fullchain.pem
Key is saved at: /etc/letsencrypt/live/shop.test/privkey.pem
This certificate expires on 2027-01-02.
README
cert.pem
chain.pem
fullchain.pem
privkey.pem
renew_hook = systemctl reload nginx
authenticator = webroot
webroot_path = /var/www/letsencrypt,
  • sites-enabled/default را حذف کردیم تا سایت پیش‌فرض Ubuntu روی ۸۰ با ما قاطی نشود.
  • certonly --webroot -w /var/www/letsencrypt: certbot فایل چالش را در /var/www/letsencrypt/.well-known/acme-challenge/ می‌گذارد و Nginx (location ACME) آن را سرو می‌کند. برخلاف --nginx (درس HTTPS)، certbot به تنظیمات Nginx دست نمی‌زند؛ تنظیم HTTPS را خودمان دقیق و تمیز می‌نویسیم.
  • گواهی برای هر دو اسم صادر شد (۹۰ روزه) و در /etc/letsencrypt/live/shop.test/ است: fullchain.pem (گواهی + زنجیره) و privkey.pem (کلید خصوصی؛ هرگز جایی کپی‌اش نکن).
  • فایل تمدید (renewal/shop.test.conf) همه‌ی تصمیم‌ها را به یاد دارد: روش webroot، مسیرش، و renew_hook = systemctl reload nginx (همان --deploy-hook). پس certbot.timer در هر تمدید موفق، Nginx را reload می‌کند.

حالا فایل سایت را کامل می‌کنیم. سه server:

  1. پورت ۸۰ (همان قدم ۳).
  2. www.shop.test روی ۴۴۳: فقط انتقال به دامنه‌ی اصلی.
  3. shop.test روی ۴۴۳: سایت اصلی.
/etc/nginx/sites-available/shop.test
# port 80: only ACME challenges and the redirect to https
server {
listen 80;
server_name shop.test www.shop.test;
location /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
}
location / {
return 301 https://shop.test$request_uri;
}
}
# www -> canonical name
server {
listen 443 ssl http2;
server_name www.shop.test;
ssl_certificate /etc/letsencrypt/live/shop.test/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shop.test/privkey.pem;
return 301 https://shop.test$request_uri;
}
server {
listen 443 ssl http2;
server_name shop.test;
ssl_certificate /etc/letsencrypt/live/shop.test/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shop.test/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
root /var/www/shop/current;
index index.html;
error_page 404 /404.html;
access_log /var/log/nginx/shop.access.log shop;
client_max_body_size 2m;
include snippets/lx-shop-security.conf;
# HTML: always revalidate
location / {
try_files $uri $uri/ =404;
add_header Cache-Control "no-cache" always;
include snippets/lx-shop-security.conf;
}
# hashed assets: cache for a year, serve pre-compressed .gz files
location /assets/ {
try_files $uri =404;
gzip_static on;
expires 1y;
add_header Cache-Control "public, immutable" always;
include snippets/lx-shop-security.conf;
}
location /api/ {
limit_req zone=shop_api burst=20 nodelay;
proxy_pass http://shop_backend;
include snippets/lx-shop-proxy.conf;
add_header Cache-Control "no-store" always;
include snippets/lx-shop-security.conf;
}
# 5 login attempts per minute per IP
location = /api/login {
limit_req zone=shop_login burst=4 nodelay;
proxy_pass http://shop_backend;
include snippets/lx-shop-proxy.conf;
add_header Cache-Control "no-store" always;
include snippets/lx-shop-security.conf;
}
# never serve dotfiles (.git, .env, ...)
location ~ /\. {
deny all;
}
}
Terminal window
mkdir -p /var/www/shop/releases
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
curl -s -I --cacert /root/pebble-root.pem https://shop.test/ | head -n 1
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
HTTP/2 404

تنظیمات معتبر است و HTTPS با HTTP/2 جواب می‌دهد، ولی 404: root به /var/www/shop/current اشاره می‌کند که هنوز وجود ندارد، چون هیچ نسخه‌ای منتشر نکرده‌ایم. قدم بعد.

چند تصمیم مهم در این فایل:

  • یک اسم اصلی: هم http:// و هم www به https://shop.test منتقل می‌شوند؛ پس گوگل و کاربرها همیشه یک آدرس می‌بینند (برای سئو مهم است).
  • هدرهای امنیتی هم در سطح server و هم دوباره در هر location ای که add_header خودش دارد (دلیلش: اشتباهات رایج).
  • location = /api/login (تطابق دقیق) از location /api/ (پیشوندی) اولویت دارد؛ پس ورود محدودیت سفت خودش را می‌گیرد.
  • location ~ /\. هر مسیری که با نقطه شروع شود (.git، .env) را می‌بندد؛ اگر روزی کسی اشتباهی پوشه‌ی git را روی سرور کپی کرد، لو نمی‌رود.

قدم ۵: استقرار اتمی و برگشت

Section titled “قدم ۵: استقرار اتمی و برگشت”

اسکریپت استقرار: از کد تحویلی یک نسخه (release) در پوشه‌ی جدا می‌سازد، به CSS و JS اسم hash‌دار می‌دهد (و HTML را به اسم جدید لینک می‌کند)، فایل‌های .gz برای gzip_static می‌سازد، و در آخر symlink current را به‌صورت اتمی عوض می‌کند. سه نسخه‌ی آخر نگه داشته می‌شوند تا برگشت ممکن باشد.

/usr/local/bin/shop-deploy
#!/usr/bin/env bash
# shop-deploy SRC_DIR: build a release with hashed assets and switch to it atomically
set -euo pipefail
src=${1:?usage: shop-deploy SRC_DIR}
base=/var/www/shop
rel="$base/releases/$(date +%Y%m%d-%H%M%S)"
old=$(readlink "$base/current" || true) # live release before this deploy (empty the first time)
mkdir -p "$rel/assets"
cp "$src"/*.html "$rel/"
for f in style.css app.js; do
hash=$(sha256sum "$src/$f" | cut -c1-8)
name="${f%.*}.$hash.${f##*.}"
cp "$src/$f" "$rel/assets/$name"
sed -i "s|/assets/$f|/assets/$name|g" "$rel"/*.html
done
gzip -k -9 "$rel"/assets/*
# atomic switch: build the new link beside the old one, then rename over it
ln -sfn "$rel" "$base/current.new"
mv -T "$base/current.new" "$base/current"
[ -n "$old" ] && ln -sfn "$old" "$base/previous"
# keep the 3 newest releases, but never the live or the previous one
ls -1d "$base"/releases/* | sort -r | tail -n +4 | { grep -vx -e "$rel" -e "${old:-none}" || true; } | xargs -r rm -rf
echo "deployed $(basename "$rel")"
/usr/local/bin/shop-rollback
#!/usr/bin/env bash
# shop-rollback: swap current and previous (run it twice to go forward again)
set -euo pipefail
base=/var/www/shop
cur=$(readlink "$base/current")
prev=$(readlink "$base/previous" || true)
if [ -z "$prev" ] || [ ! -d "$prev" ]; then
echo "no previous release to roll back to" >&2
exit 1
fi
ln -sfn "$prev" "$base/current.new"
mv -T "$base/current.new" "$base/current"
ln -sfn "$cur" "$base/previous"
echo "rolled back to $(basename "$prev")"
Terminal window
chmod +x /usr/local/bin/shop-deploy /usr/local/bin/shop-rollback
shop-deploy /srv/shop-src
ls -l /var/www/shop/ | grep -e current -e previous | awk '{print $(NF-2), $(NF-1), $NF}'
ls /var/www/shop/current/ /var/www/shop/current/assets/
grep -o '/assets/[^"]*' /var/www/shop/current/index.html
curl -s --cacert /root/pebble-root.pem https://shop.test/ | grep -o '<h1>.*</h1>'
خروجی
deployed 20261004-144553
current -> /var/www/shop/releases/20261004-144553
/var/www/shop/current/:
404.html
assets
index.html
/var/www/shop/current/assets/:
app.00bf4551.js
app.00bf4551.js.gz
style.c58656b0.css
style.c58656b0.css.gz
/assets/style.c58656b0.css
/assets/app.00bf4551.js
<h1>کتاب‌فروشی</h1>
  • نسخه‌ی اول در پوشه‌ای به اسم زمان استقرار ساخته شد و current به آن اشاره می‌کند.
  • در assets/ اسم‌ها hash دارند (style.c58656b0.css؛ هشت کاراکتر اول sha256 محتوا) و کنار هر کدام نسخه‌ی .gz برای gzip_static.
  • HTML به اسم‌های hash‌دار لینک می‌دهد، و سایت روی HTTPS بالا است.

ln -sfn یک symlink جدید (current.new) می‌سازد و mv -T آن را روی current می‌گذارد. mv روی یک سیستم‌فایل یک rename است که اتمی است: هیچ لحظه‌ای وجود ندارد که current نباشد یا نیمه‌کاره باشد. (اگر مستقیم ln -sfn روی current می‌زدی، پشت صحنه «پاک کن و بساز» است و یک لحظه‌ی کوتاه بدون current وجود دارد.)

حالا یک نسخه‌ی جدید (CSS عوض شده) و یک برگشت:

Terminal window
sleep 1
sed -i 's/#0b7a75/#7a0b4f/g' /srv/shop-src/style.css
shop-deploy /srv/shop-src
css() { curl -s --cacert /root/pebble-root.pem https://shop.test/ | grep -o 'style\.[0-9a-f]*\.css'; }
echo "live css: $(css)"
shop-rollback
echo "live css: $(css)"
ls /var/www/shop/releases/
sed -i 's/#7a0b4f/#0b7a75/g' /srv/shop-src/style.css
خروجی
deployed 20261004-144554
live css: style.ebed0ab3.css
rolled back to 20261004-144553
live css: style.c58656b0.css
20261004-144553
20261004-144554
  • رنگ CSS عوض شد و نسخه‌ی دوم منتشر شد. HTML حالا به style.ebed0ab3.css لینک می‌دهد: محتوا عوض شد، پس hash و اسم عوض شد؛ مرورگرهایی که نسخه‌ی قبلی را یک سال کش کرده‌اند، فایل جدید را می‌گیرند، چون اسمش تازه است.
  • shop-rollback: current و previous جابه‌جا شدند و سایت در یک لحظه به نسخه‌ی قبلی (style.c58656b0.css) برگشت؛ بدون build، بدون کپی، بدون reload Nginx.
  • هر دو نسخه در releases/ مانده‌اند. اسکریپت فقط ۳ نسخه‌ی آخر را نگه می‌دارد و هیچ‌وقت نسخه‌ی فعلی یا قبلی را پاک نمی‌کند. (اگر دوباره shop-rollback بزنی، به نسخه‌ی جدیدتر برمی‌گردی.)

آخر بلوک رنگ CSS را در کد منبع برگرداندیم.

قدم ۶: اسکریپت بررسی (چک‌لیست خودکار)

Section titled “قدم ۶: اسکریپت بررسی (چک‌لیست خودکار)”

حالا مهم‌ترین قدم: ثابت کنیم همه‌ی نیازها برآورده شده‌اند. یک اسکریپت که هر نیاز را با یک curl می‌سنجد و PASS یا FAIL می‌گوید. این اسکریپت را بعد از هر تغییر تنظیمات و هر استقرار اجرا می‌کنی:

/usr/local/bin/shop-check
#!/usr/bin/env bash
# shop-check: verify every requirement of the shop deployment
site=https://shop.test
ca=/root/pebble-root.pem # on a real server with Let's Encrypt: drop --cacert
fails=0
c() { curl -s --cacert "$ca" "$@"; }
hdr() { c -o /dev/null -D - "$@" | tr -d '\r'; } # response headers only
check() { # check NAME TEXT REGEX: PASS if some line of TEXT matches REGEX
if grep -qiE -- "$3" <<< "$2"; then printf 'PASS %s\n' "$1"
else printf 'FAIL %s\n' "$1"; fails=$((fails + 1)); fi
}
home=$(hdr "$site/")
css=$(c "$site/" | grep -o '/assets/style\.[0-9a-f]*\.css')
asset=$(hdr -H 'Accept-Encoding: gzip' "$site$css")
api=$(hdr "$site/api/products")
check "http -> https" "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' http://shop.test/x)" '^301 https://shop.test/x$'
check "www -> canonical" "$(hdr https://www.shop.test/)" '^location: https://shop.test/'
check "home 200 over HTTP/2" "$(c -o /dev/null -w '%{http_code} %{http_version}' "$site/")" '^200 2$'
check "custom 404 page" "$(c "$site/nope")" 'این صفحه پیدا نشد'
check "HSTS" "$home" '^strict-transport-security: max-age='
check "CSP" "$home" '^content-security-policy:'
check "nginx version hidden" "$home" '^server: nginx$'
check "HTML no-cache" "$home" '^cache-control: no-cache'
check "assets 1y immutable" "$asset" '^cache-control: public, immutable'
check "assets gzipped" "$asset" '^content-encoding: gzip'
check "headers on assets" "$asset" '^x-content-type-options: nosniff'
check "API returns JSON" "$(c "$site/api/products")" '"products"'
check "API no-store" "$api" '^cache-control: no-store'
check "API hides X-Powered-By" "$(grep -ci '^x-powered-by' <<< "$api")" '^0$'
check "dotfiles denied" "$(c -o /dev/null -w '%{http_code}' "$site/.env")" '^403$'
check "login limited" "$(for i in 1 2 3 4 5 6; do c -o /dev/null -w '%{http_code}\n' -X POST "$site/api/login"; done | tail -n 1)" '^429$'
echo "---"
echo "failed: $fails"
[ "$fails" -eq 0 ]
Terminal window
chmod +x /usr/local/bin/shop-check
shop-check; echo "exit code: $?"
خروجی
PASS http -> https
PASS www -> canonical
PASS home 200 over HTTP/2
PASS custom 404 page
PASS HSTS
PASS CSP
PASS nginx version hidden
PASS HTML no-cache
PASS assets 1y immutable
PASS assets gzipped
PASS headers on assets
PASS API returns JSON
PASS API no-store
PASS API hides X-Powered-By
PASS dotfiles denied
PASS login limited
---
failed: 0
exit code: 0

۱۶ از ۱۶. هر خط یک نیاز است که در درس‌های دوره یاد گرفتی، و حالا با یک curl واقعی ثابت شده. چند نکته‌ی اسکریپت:

  • check NAME TEXT REGEX: یک تابع که اگر یکی از خط‌های متن با الگو جور بود، PASS می‌دهد. هدرهای هر صفحه یک بار گرفته شده‌اند (home، asset، api) و چند بار بررسی می‌شوند.
  • tr -d '\r': هدرهای HTTP با \r\n تمام می‌شوند؛ بدون حذف \r، الگوهایی مثل nginx$ جور نمی‌شوند.
  • کد خروج اسکریپت: ۰ یعنی همه سالم. پس می‌شود آن را در CI، بعد از هر استقرار، یا در cron گذاشت (درس پروژه‌ی Bash).

قدم ۷: تحمل خرابی، لاگ و تمدید

Section titled “قدم ۷: تحمل خرابی، لاگ و تمدید”

سه آزمایش آخر: یکی از نسخه‌های API را خاموش می‌کنیم، لاگ را تحلیل می‌کنیم، و تمدید گواهی را (با deploy hook) آزمایش می‌کنیم:

Terminal window
systemctl stop lx-shop-api@3001
for i in 1 2 3 4 5 6; do
curl -s --cacert /root/pebble-root.pem https://shop.test/api/products | grep -o '"instance":[0-9]*'
done | sort | uniq -c
systemctl start lx-shop-api@3001
echo "=== status codes in the shop log:"
awk -F'"' '{split($3, f, " "); print f[1]}' /var/log/nginx/shop.access.log | sort | uniq -c | sort -rn
echo "=== slowest 3 requests:"
awk '{for (i = 1; i <= NF; i++) if ($i ~ /^rt=/) print substr($i, 4), $4, $5}' /var/log/nginx/shop.access.log | sort -rn | head -n 3
echo "=== renewal test:"
certbot renew --dry-run --run-deploy-hooks --no-random-sleep-on-renew \
--server https://localhost:14000/dir --cert-name shop.test 2>&1 | grep -E 'Simulating|success'
grep 'deploy-hook' /var/log/letsencrypt/letsencrypt.log | tail -n 1 | cut -d: -f4-
خروجی
6 "instance":3002
=== status codes in the shop log:
15 200
5 401
2 404
1 429
1 403
=== slowest 3 requests:
0.007 "GET /api/products
0.002 "POST /api/login
0.002 "GET /api/products
=== renewal test:
Simulating renewal of an existing certificate for shop.test and www.shop.test
/etc/letsencrypt/live/shop.test/fullchain.pem (success)
INFO:certbot.compat.misc:Running deploy-hook command: systemctl reload nginx
  • خرابی: نسخه‌ی ۳۰۰۱ را خاموش کردیم. هر ۶ درخواست را ۳۰۰۲ جواب داد و کاربر هیچ خطایی ندید (max_fails و تلاش دوباره روی عضو بعدی، درس load balancing).
  • لاگ: کدهای وضعیت از لاگ اختصاصی سایت: ۲۰۰ ها (صفحه، فایل‌ها، API)، ۵ تا ۴۰۱ (پنج تلاش ورود اسکریپت بررسی، که به API رسیدند) و ۱ ۴۲۹ (ششمی، که Nginx رد کرد)، ۲ تا ۴۰۴ (قدم ۴ قبل از استقرار، و /nope بررسی) و ۱ ۴۰۳ (/.env). کندترین درخواست‌ها با فیلد rt= پیدا شدند. در awk، با جداکننده‌ی " بخش بعد از "$request" را گرفتیم تا اولین کلمه‌اش کد وضعیت باشد؛ چون تعداد فیلدهای جداشده با فاصله ثابت نیست ($upstream_addr وقتی Nginx دو عضو را امتحان کند، دو آدرس با کاما و فاصله دارد).
  • تمدید: certbot renew --dry-run تمدید را کامل شبیه‌سازی کرد (success). --dry-run به‌صورت پیش‌فرض deploy hook را اجرا نمی‌کند؛ با --run-deploy-hooks آن را هم آزمایش کردیم. certbot اجرای hook را در صفحه چاپ نمی‌کند، فقط در لاگ خودش (/var/log/letsencrypt/letsencrypt.log)؛ خط آخر خروجی همان است: Running deploy-hook command: systemctl reload nginx. پس مطمئنیم بعد از تمدید واقعی، Nginx reload می‌شود. (--no-random-sleep-on-renew فقط برای این آزمایش است: certbot وقتی از ترمینال اجرا نشود، قبل از تمدید چند ثانیه تا چند دقیقه‌ی تصادفی صبر می‌کند تا همه‌ی سرورهای دنیا همزمان به Let’s Encrypt هجوم نبرند.)

پشت پرده: زمان یک درخواست کجا می‌رود؟

Section titled “پشت پرده: زمان یک درخواست کجا می‌رود؟”

حالا که همه‌چیز سر جایش است، یک درخواست را زیر ذره‌بین ببینیم. curl -w زمان هر مرحله را از شروع اندازه می‌گیرد:

Terminal window
for url in https://shop.test/ https://shop.test/api/products; do
curl -s -o /dev/null --cacert /root/pebble-root.pem -w "$url
dns %{time_namelookup}s
tcp %{time_connect}s
tls %{time_appconnect}s
first byte %{time_starttransfer}s
total %{time_total}s (HTTP/%{http_version} %{http_code})
" "$url"
done
tail -n 2 /var/log/nginx/shop.access.log
خروجی
https://shop.test/
dns 0.000709s
tcp 0.000844s
tls 0.004759s
first byte 0.005149s
total 0.005221s (HTTP/2 200)
https://shop.test/api/products
dns 0.000414s
tcp 0.000525s
tls 0.005587s
first byte 0.008319s
total 0.008380s (HTTP/2 200)
127.0.0.1 [04/Oct/2026:14:45:57 +0000] "GET / HTTP/2.0" 200 299 rt=0.000 urt=- up=-
127.0.0.1 [04/Oct/2026:14:45:57 +0000] "GET /api/products HTTP/2.0" 200 181 rt=0.001 urt=0.002 up=127.0.0.1:3002

زمان‌ها تجمعی از شروع درخواست‌اند:

مرحله صفحه‌ی اصلی API چه می‌گذرد
DNS زیر ۱ms زیر ۱ms از /etc/hosts (روی اینترنت: ده‌ها میلی‌ثانیه، یک بار و بعد کش)
TCP زیر ۱ms زیر ۱ms سه‌گانه‌ی SYN (روی اینترنت: یک رفت‌وبرگشت)
TLS حدود ۴ تا ۶ms حدود ۴ تا ۶ms سنگین‌ترین بخش روی این ماشین: تبادل کلید و بررسی گواهی
اولین بایت کمی بعد از TLS چند میلی‌ثانیه بیشتر Nginx فایل را فرستاد؛ برای API، رفت و برگشت به Node (urt در لاگ)

دو درس عملی:

  1. TLS گران است، پس اتصال‌ها را زنده نگه دار: HTTP/2 همه‌ی فایل‌های یک صفحه را روی یک اتصال می‌فرستد و مرورگر TLS را فقط یک بار انجام می‌دهد. (روی اینترنت واقعی، TLS ۱.۳ یک رفت‌وبرگشت و TLS ۱.۲ دو رفت‌وبرگشت لازم دارد؛ برای کاربری با پینگ ۱۰۰ میلی‌ثانیه این یعنی ۱۰۰ تا ۲۰۰ میلی‌ثانیه.)
  2. در لاگ rt (کل زمان از دید Nginx) و urt (زمان API) را مقایسه کن: اگر rt خیلی بیشتر از urt است، مشکل شبکه یا کاربر کند است؛ اگر urt بالاست، API کند است. فایل استاتیک اصلاً urt ندارد (-).

ترتیب کارهایی که Nginx روی هر درخواست https://shop.test/api/login انجام می‌دهد:

مرحله چه اتفاقی می‌افتد از کدام تنظیم
TLS با SNI shop.test گواهی درست انتخاب و رمزنگاری برقرار می‌شود ssl_certificate
انتخاب server بر اساس listen و server_name سه server
انتخاب location = /api/login (تطابق دقیق) برنده است location
rewrite (return اینجا اجرا می‌شود؛ در این location نداریم)
preaccess limit_req سطل IP را بررسی می‌کند zone=shop_login
access allow / deny / رمز (اینجا ندارد)
content proxy_pass به یکی از دو نسخه (least_conn) با اتصال keepalive upstream shop_backend
فیلتر هدر add_header ها اضافه، X-Powered-By حذف snippet ها
فیلتر بدنه gzip (اگر کلاینت بخواهد و نوع در gzip_types باشد) conf.d/lx-shop.conf
log یک خط با rt و urt log_format shop
فایل context محتوا
conf.d/lx-shop.conf http server_tokens، gzip، limit_req_zone، log_format، upstream
snippets/lx-shop-security.conf server و location HSTS، nosniff، X-Frame-Options، Referrer-Policy، CSP
snippets/lx-shop-proxy.conf location HTTP/1.1 و keepalive، هدرهای X-Forwarded-*، timeout ها
sites-available/shop.test server سه server و locationها

سیاست هر بخش سایت:

مسیر کش محدودیت منبع
/ و HTML no-cache ندارد /var/www/shop/current
/assets/ یک سال، immutable، gzip_static ندارد همان
/api/ no-store 10r/s، burst=20 nodelay upstream shop_backend
/api/login no-store 5r/m، burst=4 nodelay همان
فایل‌های نقطه‌دار deny all (۴۰۳)

۱) تنظیم HTTPS قبل از وجود گواهی

Section titled “۱) تنظیم HTTPS قبل از وجود گواهی”
Terminal window
cp /etc/nginx/sites-available/shop.test /root/shop.test.bak
sed -i 's|/etc/letsencrypt/live/shop.test/fullchain.pem|/etc/letsencrypt/live/shop.tset/fullchain.pem|' /etc/nginx/sites-available/shop.test
nginx -t 2>&1 | cut -c1-160
cp /root/shop.test.bak /etc/nginx/sites-available/shop.test
خروجی
2026/10/04 14:45:57 [emerg] 43325#43325: cannot load certificate "/etc/letsencrypt/live/shop.tset/fullchain.pem": BIO_new_file() failed (SSL: error:80000002:sys
nginx: configuration file /etc/nginx/nginx.conf test failed

اگر فایل گواهی نباشد (هنوز گرفته نشده، یا مثل اینجا یک غلط تایپی در مسیر)، کل Nginx شروع یا reload نمی‌شود؛ نه فقط این سایت. برای همین در قدم ۳ اول فقط HTTP را ساختیم. اگر مجبوری از اول تنظیم کامل را داشته باشی، یک گواهی خودامضای موقت بساز (درس HTTPS) و بعد از گرفتن گواهی واقعی مسیرها را عوض کن.

۲) add_header در یک location و گم شدن هدرهای امنیتی

Section titled “۲) add_header در یک location و گم شدن هدرهای امنیتی”
Terminal window
sed -i 's|^ # never serve dotfiles| location = /promo.html {\n add_header Cache-Control "max-age=60" always;\n }\n\n # never serve dotfiles|' /etc/nginx/sites-available/shop.test
cp /var/www/shop/current/index.html /var/www/shop/current/promo.html
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
for p in / /promo.html; do
printf '%-12s security headers: ' "$p"
curl -s -o /dev/null -D - --cacert /root/pebble-root.pem "https://shop.test$p" | grep -ciE '^(strict-transport|x-content-type|x-frame|referrer-policy|content-security)'
done
cp /root/shop.test.bak /etc/nginx/sites-available/shop.test
rm /var/www/shop/current/promo.html
systemctl reload nginx
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
/ security headers: 5
/promo.html security headers: 0

صفحه‌ی اصلی ۵ هدر امنیتی دارد، /promo.html هیچ. location تازه یک add_header خودش دارد و قانون Nginx این است: اگر یک سطح هر add_header ای داشته باشد، هیچ add_header ای از سطح بالاتر به ارث نمی‌برد. این اشتباه بی‌صدا است؛ سایت کار می‌کند، فقط امنیت یک صفحه رفته. راه‌حل: در هر location که add_header دارد، snippet را هم include کن (کاری که در قدم ۴ کردیم)، و اسکریپت بررسی را بعد از هر تغییر اجرا کن.

۳) کپی فایل‌ها روی نسخه‌ی در حال اجرا

Section titled “۳) کپی فایل‌ها روی نسخه‌ی در حال اجرا”

اگر به‌جای پوشه‌ی جدید و symlink، فایل‌های نسخه‌ی جدید را مستقیم روی همان پوشه‌ی سایت کپی کنی (cp -r یا rsync روی current)، در چند ثانیه‌ی کپی، کاربری ممکن است HTML جدید را با CSS قدیمی بگیرد (یا برعکس)؛ و اگر نسخه‌ی جدید خراب بود، نسخه‌ی قبلی دیگر وجود ندارد که برگردی. راه‌حل: همان استقرار اتمی قدم ۵: پوشه‌ی جدید، بعد mv -T روی symlink (عوض شدن symlink با mv یک عمل اتمی در لینوکس است: هر درخواست یا تماماً نسخه‌ی قبلی را می‌بیند یا تماماً نسخه‌ی جدید).

certbot گواهی تازه را در /etc/letsencrypt می‌نویسد، ولی Nginx گواهی را موقع شروع یا reload در حافظه بارگذاری می‌کند. بدون reload، Nginx همان گواهی قدیمی را تا انقضا نشان می‌دهد. راه‌حل: --deploy-hook 'systemctl reload nginx' (قدم ۳؛ certbot آن را در فایل تمدید ذخیره کرد) یا افزونه‌ی --nginx که خودش reload می‌کند.

✎ تمرینآسان

برنامه‌نویس‌ها یک تغییر داده‌اند: عنوان صفحه باید «کتاب‌فروشی LoopX» باشد. آن را منتشر کن، با shop-check مطمئن شو همه‌چیز سالم است، بعد فرض کن مدیر از عنوان خوشش نیامد و برگردان.

دیدن جواب
Terminal window
sleep 1
sed -i 's|<h1>کتاب‌فروشی</h1>|<h1>کتاب‌فروشی LoopX</h1>|' /srv/shop-src/index.html
shop-deploy /srv/shop-src
curl -s --cacert /root/pebble-root.pem https://shop.test/ | grep -o '<h1>.*</h1>'
shop-check | tail -n 1
shop-rollback
curl -s --cacert /root/pebble-root.pem https://shop.test/ | grep -o '<h1>.*</h1>'
sed -i 's|<h1>کتاب‌فروشی LoopX</h1>|<h1>کتاب‌فروشی</h1>|' /srv/shop-src/index.html
خروجی
deployed 20261004-144559
<h1>کتاب‌فروشی LoopX</h1>
failed: 0
rolled back to 20261004-144553
<h1>کتاب‌فروشی</h1>
  • نسخه‌ی جدید منتشر شد و عنوان عوض شد.
  • shop-check: failed: 0. (تست «محدودیت ورود» دوباره هم 429 گرفت، چون سطل ورود از اجرای قبلی هنوز پر است؛ تست درست است.)
  • shop-rollback سایت را به نسخه‌ای برگرداند که قبل از این استقرار زنده بود. دقت کن: این همان نسخه‌ی رنگ سبز است، نه نسخه‌ی بنفشی که در قدم ۵ ساختیم و برگرداندیم. برای همین اسکریپت previous را جدا نگه می‌دارد و به «نسخه‌ی قبلی در ترتیب زمانی» تکیه نمی‌کند. (اولین نسخه‌ی این اسکریپت همین باگ را داشت و همین تمرین پیدایش کرد.)
✎ تمرینمتوسط

یک بخش مدیریت /admin/ اضافه کن (یک فایل index.html در نسخه‌ی فعلی کافی است) که: ۱) رمز داشته باشد (کاربر boss، با htpasswd)؛ ۲) جز از 127.0.0.1 (مثلاً IP دفتر) در دسترس نباشد؛ ۳) هدرهای امنیتی را داشته باشد و کش نشود. هر چهار حالت (IP دیگر، بدون رمز، رمز غلط، درست) را آزمایش کن.

دیدن جواب
Terminal window
mkdir -p /var/www/shop/current/admin
echo '<h1>admin panel</h1>' > /var/www/shop/current/admin/index.html
htpasswd -bc /etc/nginx/lx-shop.htpasswd boss 'S3cret-Pass' 2>&1
chown root:www-data /etc/nginx/lx-shop.htpasswd
chmod 640 /etc/nginx/lx-shop.htpasswd
cp /etc/nginx/sites-available/shop.test /root/shop.test.bak
sed -i 's|^ # never serve dotfiles| location /admin/ {\n allow 127.0.0.1;\n deny all;\n auth_basic "shop admin";\n auth_basic_user_file /etc/nginx/lx-shop.htpasswd;\n add_header Cache-Control "no-store" always;\n include snippets/lx-shop-security.conf;\n }\n\n # never serve dotfiles|' /etc/nginx/sites-available/shop.test
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
a() { curl -s -o /dev/null --cacert /root/pebble-root.pem -w '%{http_code}' "$@" https://shop.test/admin/; }
echo "other IP (127.0.0.2): $(a --interface 127.0.0.2 -u boss:S3cret-Pass)"
echo "no password: $(a)"
echo "wrong password: $(a -u boss:guess)"
echo "right password: $(a -u boss:S3cret-Pass)"
curl -s -D - -o /dev/null --cacert /root/pebble-root.pem -u boss:S3cret-Pass https://shop.test/admin/ | grep -ciE '^(strict-transport|content-security|cache-control: no-store)'
cp /root/shop.test.bak /etc/nginx/sites-available/shop.test
rm -rf /var/www/shop/current/admin /etc/nginx/lx-shop.htpasswd
systemctl reload nginx
خروجی
Adding password for user boss
nginx: configuration file /etc/nginx/nginx.conf test is successful
other IP (127.0.0.2): 403
no password: 401
wrong password: 401
right password: 200
3

چهار حالت، چهار جواب درست:

  • IP دیگر (127.0.0.2)، حتی با رمز درست: 403. allow و deny در مرحله‌ی access قبل از رمز بررسی می‌شوند.
  • بدون رمز و رمز غلط: 401 (مرورگر پنجره‌ی رمز نشان می‌دهد).
  • رمز درست از IP مجاز: 200، و ۳ هدر خواسته‌شده (HSTS، CSP و no-store) هم هستند.

فایل رمز با htpasswd -bc (از بسته‌ی apache2-utils) ساخته شد: -c فایل جدید، -b رمز از خط فرمان (در عمل بهتر است بدون -b بزنی تا رمز در تاریخچه‌ی shell نماند). مالکیت root:www-data و دسترسی 640: workerهای Nginx (کاربر www-data) می‌خوانند، بقیه نه. آخر بلوک همه‌چیز برگشت.

✎ تمرینسخت

تمرین اصلی درس: پروژه را روی یک سرور واقعی مستقر کن. قبل از گرفتن گواهی واقعی، یک اسکریپت shop-preflight بنویس که آماده بودن سرور را بسنجد: ۱) nginx -t سالم است؛ ۲) Nginx روی ۸۰ و ۴۴۳ گوش می‌دهد؛ ۳) هر دو نسخه‌ی API فعال و enable هستند (بعد از ریبوت بالا می‌آیند)؛ ۴) گواهی دست‌کم ۱۴ روز اعتبار دارد؛ ۵) تایمر تمدید certbot فعال است؛ ۶) اسم دامنه به یک IP همین سرور اشاره می‌کند. آن را روی سرور آزمایشی اجرا کن.

دیدن جواب
/usr/local/bin/shop-preflight
#!/usr/bin/env bash
# shop-preflight DOMAIN: is this server ready to serve DOMAIN?
domain=${1:?usage: shop-preflight DOMAIN}
ok() { printf 'OK %s\n' "$1"; }
bad() { printf 'FAIL %s\n' "$1"; fails=$((fails + 1)); }
fails=0
nginx -t > /dev/null 2>&1 && ok "nginx config valid" || bad "nginx -t fails"
for port in 80 443; do
ss -ltn "sport = :$port" | grep -q LISTEN && ok "listening on $port" || bad "nothing on port $port"
done
for unit in lx-shop-api@3001 lx-shop-api@3002; do
if systemctl is-active --quiet "$unit" && systemctl is-enabled --quiet "$unit"; then ok "$unit active+enabled"
else bad "$unit not active or not enabled"; fi
done
cert=/etc/letsencrypt/live/$domain/fullchain.pem
if openssl x509 -in "$cert" -noout -checkend $((14 * 24 * 3600)) > /dev/null 2>&1; then
ok "certificate valid > 14 days ($(openssl x509 -in "$cert" -noout -enddate | cut -d= -f2))"
else bad "certificate missing or expires within 14 days"; fi
systemctl is-active --quiet certbot.timer && ok "certbot.timer active" || bad "certbot.timer inactive"
ip=$(getent ahostsv4 "$domain" | awk 'NR == 1 {print $1}')
if [ -n "$ip" ] && { [ "$ip" = 127.0.0.1 ] || hostname -I | tr ' ' '\n' | grep -qx "$ip"; }; then
ok "$domain -> $ip (this server)"
else bad "$domain -> ${ip:-nothing}, not this server"; fi
echo "--- $fails problem(s)"
[ "$fails" -eq 0 ]
Terminal window
chmod +x /usr/local/bin/shop-preflight
shop-preflight shop.test; echo "exit code: $?"
echo "=== simulate a problem:"
systemctl disable --now lx-shop-api@3002 2>&1 | grep -c Removed
shop-preflight shop.test | grep -E 'FAIL|problem'
systemctl enable --now lx-shop-api@3002 2>&1 | grep -c Created
خروجی
OK nginx config valid
OK listening on 80
OK listening on 443
OK lx-shop-api@3001 active+enabled
OK lx-shop-api@3002 active+enabled
OK certificate valid > 14 days (Jan 2 14:45:50 2027 GMT)
OK certbot.timer active
OK shop.test -> 127.0.0.1 (this server)
--- 0 problem(s)
exit code: 0
=== simulate a problem:
1
FAIL lx-shop-api@3002 not active or not enabled
--- 1 problem(s)
1

روی سرور آزمایشی هر ۸ بررسی OK شد. بعد یک مشکل واقعی ساختیم: نسخه‌ی ۳۰۰۲ را disable --now کردیم (هم خاموش، هم بعد از ریبوت بالا نمی‌آید) و shop-preflight دقیقاً همان را گزارش داد؛ و آخر سر برگرداندیم.

بررسی‌های مهم:

  • openssl x509 -checkend SECONDS: اگر گواهی تا این چند ثانیه‌ی دیگر منقضی شود، کد غیرصفر. ۱۴ روز فرصت کافی برای واکنش است (Let’s Encrypt گواهی ۹۰ روزه را از ۳۰ روز مانده تمدید می‌کند).
  • ss -ltn "sport = :443": کسی روی این پورت گوش می‌دهد؟
  • getent ahostsv4 اسم را مثل خود سیستم resolve می‌کند (اینجا از /etc/hosts؛ روی سرور واقعی از DNS) و با hostname -I (IP های سرور) مقایسه می‌کنیم. روی سرور ابری، IP عمومی ممکن است روی کارت شبکه نباشد (NAT)؛ آن‌جا IP را دستی به اسکریپت بده.

روی سرور واقعی (این قسمت روی ماشین آزمایشی اجرا نشده و فقط ترتیب کار است): رکورد A دامنه را به IP سرور بده، پورت ۸۰ و ۴۴۳ را در فایروال باز کن (درس فایروال در دوره‌ی لینوکس)، همین قدم‌ها را به ترتیب اجرا کن، فقط certbot را بدون --server و REQUESTS_CA_BUNDLE بزن تا از Let’s Encrypt واقعی گواهی بگیرد، --cacert را از shop-check بردار، و آخر سر shop-preflight و shop-check باید هر دو بدون خطا باشند.

⚡ بررسی سریع

چرا در قدم ۳ اول فقط server پورت ۸۰ را ساختیم؟

؟ آزمونک
  1. چرا استقرار با symlink و mv -T «اتمی» است؟

  2. location جدیدی با add_header Cache-Control ساختی و هدرهای HSTS و CSP در آن صفحه نیستند. چرا؟

  3. فایده‌ی --deploy-hook "systemctl reload nginx" در certbot چیست؟

  4. چرا CSS و JS اسم hash‌دار دارند؟

  5. یکی از دو نسخه‌ی API خاموش شد. کاربر چه می‌بیند؟

  6. اسکریپت shop-check چه زمانی باید اجرا شود؟

  • ترتیب: API و سرویس‌ها ← تنظیمات مشترک (conf.d و snippets) ← سایت HTTP با webroot ← certbot certonly --webroot ... --deploy-hook 'systemctl reload nginx' ← سایت کامل HTTPS ← استقرار ← بررسی خودکار.
  • استقرار اتمی: پوشه‌ی هر نسخه + ln -sfn و mv -T روی current؛ برگشت = عوض کردن symlink. فایل‌های hash‌دار + gzip -k -9 برای gzip_static.
  • سه server: ۸۰ (ACME و انتقال)، www روی ۴۴۳ (انتقال)، اصلی روی ۴۴۳ با http2.
  • سیاست هر مسیر: HTML no-cache، /assets/ یک سال immutable، API no-store و محدود، /api/login ۵ در دقیقه، فایل‌های نقطه‌دار deny.
  • امنیت: server_tokens off، snippet هدرها در هر location که add_header دارد، proxy_hide_header X-Powered-By، API فقط روی 127.0.0.1 و با کاربر www-data.
  • عملیات: least_conn + max_fails برای تحمل خرابی، log_format با rt و urt، certbot renew --dry-run، و shop-check بعد از هر تغییر.
برگه‌ی تقلب این درس
دستورکاری که می‌کند
certbot certonly --webroot -w /var/www/letsencrypt -d DOMAIN --deploy-hook "systemctl reload nginx"گواهی بدون دست زدن به تنظیمات
ln -sfn RELEASE current.new && mv -T current.new currentعوض کردن اتمی نسخه
gzip -k -9 assets/*فایل‌های .gz برای gzip_static
systemctl enable --now lx-shop-api@3001نسخه‌ی API از unit قالبی
listen 443 ssl http2;HTTPS با HTTP/2
return 301 https://shop.test$request_uri;انتقال به دامنه‌ی اصلی
include snippets/lx-shop-security.conf;هدرهای امنیتی در هر location
proxy_hide_header X-Powered-By;پنهان کردن فناوری اپ
location ~ /\. { deny all; }محافظت از فایل‌های نقطه‌دار
log_format shop '... rt=$request_time urt=$upstream_response_time';لاگ با زمان
curl -w "%{time_appconnect} %{time_starttransfer}" URLزمان TLS و اولین بایت
openssl x509 -in CERT -noout -checkend 1209600گواهی تا ۱۴ روز دیگر معتبر است؟
certbot renew --dry-runآزمایش تمدید