توی این درس یاد میگیری همهی چیزهایی که در این دوره دیدی را در یک استقرار واقعی و کامل کنار هم بگذاری: یک فروشگاه کتاب با سایت استاتیک و یک 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 و صفحهی ورود | محدودسازی درخواستها |
| لاگ با زمان پاسخ | لاگها |
تشبیه: افتتاح یک مغازه
Section titled “تشبیه: افتتاح یک مغازه”درسهای قبلی مثل یاد گرفتن تکتک کارهای یک مغازه بود: قفل در (HTTPS)، دوربین و نگهبان (هدرهای امنیتی، rate limit)، قفسهچینی سریع (کش و gzip)، دو صندوقدار (load balancing). پروژهی پایانی روز افتتاح است: همه را با هم و درست کنار هم راه میاندازی، و قبل از باز کردن در، با یک چکلیست همهچیز را یکییکی امتحان میکنی.
نقشهی پروژه
Section titled “نقشهی پروژه”ساختار فایلها روی سرور:
/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دار را اسکریپت استقرار (قدم ۵) میسازد:
<!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><!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>/* 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; }// load the book list from the API and render itfetch('/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: فهرست کتابها، ورود (همیشه «رمز اشتباه»؛ اپ نمونه است) و یک مسیر سلامت. هر نسخه پورتش را در پاسخ میگذارد تا ببینیم کدام جواب داده:
// shop API: products, login, healthconst 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');[Unit]Description=Shop API instance on port %iAfter=network.target
[Service]Environment=PORT=%iExecStart=/usr/bin/node /srv/shop-api/app.jsUser=www-dataRestart=on-failure
[Install]WantedBy=multi-user.targetsystemctl daemon-reloadsystemctl enable --now lx-shop-api@3001 lx-shop-api@3002 2>&1 | grep -c 'Created symlink'sleep 1systemctl is-active lx-shop-api@3001 lx-shop-api@3002curl -s http://127.0.0.1:3001/api/health; echops -o user=,args= -C node2activeactive{"status":"ok","instance":3001}www-data /usr/bin/node /srv/shop-api/app.jswww-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:
# 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;}# security headers (lesson: security headers); include again in every location that has its own add_headeradd_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;# 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;nginx -t 2>&1 | tail -n 1nginx: 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).zoneupstream اسمش با 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 منتقل میشوند.
# port 80: only ACME challenges and the redirect to httpsserver { 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; }}mkdir -p /var/www/letsencryptrm -f /etc/nginx/sites-enabled/defaultln -sf /etc/nginx/sites-available/shop.test /etc/nginx/sites-enabled/nginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1export REQUESTS_CA_BUNDLE=/opt/pebble/test/certs/pebble.minica.pemcertbot 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.confnginx: configuration file /etc/nginx/nginx.conf test is successfulSuccessfully received certificate.Certificate is saved at: /etc/letsencrypt/live/shop.test/fullchain.pemKey is saved at: /etc/letsencrypt/live/shop.test/privkey.pemThis certificate expires on 2027-01-02.READMEcert.pemchain.pemfullchain.pemprivkey.pemrenew_hook = systemctl reload nginxauthenticator = webrootwebroot_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 میکند.
قدم ۴: سایت کامل با HTTPS
Section titled “قدم ۴: سایت کامل با HTTPS”حالا فایل سایت را کامل میکنیم. سه server:
- پورت ۸۰ (همان قدم ۳).
www.shop.testروی ۴۴۳: فقط انتقال به دامنهی اصلی.shop.testروی ۴۴۳: سایت اصلی.
# port 80: only ACME challenges and the redirect to httpsserver { 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 nameserver { 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; }}mkdir -p /var/www/shop/releasesnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1curl -s -I --cacert /root/pebble-root.pem https://shop.test/ | head -n 1nginx: configuration file /etc/nginx/nginx.conf test is successfulHTTP/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/bin/env bash# shop-deploy SRC_DIR: build a release with hashed assets and switch to it atomicallyset -euo pipefailsrc=${1:?usage: shop-deploy SRC_DIR}base=/var/www/shoprel="$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"/*.htmldonegzip -k -9 "$rel"/assets/*
# atomic switch: build the new link beside the old one, then rename over itln -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 onels -1d "$base"/releases/* | sort -r | tail -n +4 | { grep -vx -e "$rel" -e "${old:-none}" || true; } | xargs -r rm -rfecho "deployed $(basename "$rel")"#!/usr/bin/env bash# shop-rollback: swap current and previous (run it twice to go forward again)set -euo pipefailbase=/var/www/shopcur=$(readlink "$base/current")prev=$(readlink "$base/previous" || true)if [ -z "$prev" ] || [ ! -d "$prev" ]; then echo "no previous release to roll back to" >&2 exit 1filn -sfn "$prev" "$base/current.new"mv -T "$base/current.new" "$base/current"ln -sfn "$cur" "$base/previous"echo "rolled back to $(basename "$prev")"chmod +x /usr/local/bin/shop-deploy /usr/local/bin/shop-rollbackshop-deploy /srv/shop-srcls -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.htmlcurl -s --cacert /root/pebble-root.pem https://shop.test/ | grep -o '<h1>.*</h1>'deployed 20261004-144553current -> /var/www/shop/releases/20261004-144553/var/www/shop/current/:404.htmlassetsindex.html
/var/www/shop/current/assets/:app.00bf4551.jsapp.00bf4551.js.gzstyle.c58656b0.cssstyle.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 عوض شده) و یک برگشت:
sleep 1sed -i 's/#0b7a75/#7a0b4f/g' /srv/shop-src/style.cssshop-deploy /srv/shop-srccss() { curl -s --cacert /root/pebble-root.pem https://shop.test/ | grep -o 'style\.[0-9a-f]*\.css'; }echo "live css: $(css)"shop-rollbackecho "live css: $(css)"ls /var/www/shop/releases/sed -i 's/#7a0b4f/#0b7a75/g' /srv/shop-src/style.cssdeployed 20261004-144554live css: style.ebed0ab3.cssrolled back to 20261004-144553live css: style.c58656b0.css20261004-14455320261004-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/bin/env bash# shop-check: verify every requirement of the shop deploymentsite=https://shop.testca=/root/pebble-root.pem # on a real server with Let's Encrypt: drop --cacertfails=0c() { curl -s --cacert "$ca" "$@"; }hdr() { c -o /dev/null -D - "$@" | tr -d '\r'; } # response headers onlycheck() { # 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 ]chmod +x /usr/local/bin/shop-checkshop-check; echo "exit code: $?"PASS http -> httpsPASS www -> canonicalPASS home 200 over HTTP/2PASS custom 404 pagePASS HSTSPASS CSPPASS nginx version hiddenPASS HTML no-cachePASS assets 1y immutablePASS assets gzippedPASS headers on assetsPASS API returns JSONPASS API no-storePASS API hides X-Powered-ByPASS dotfiles deniedPASS login limited---failed: 0exit 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) آزمایش میکنیم:
systemctl stop lx-shop-api@3001for 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 -csystemctl start lx-shop-api@3001echo "=== status codes in the shop log:"awk -F'"' '{split($3, f, " "); print f[1]}' /var/log/nginx/shop.access.log | sort | uniq -c | sort -rnecho "=== 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 3echo "=== 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/products0.002 "POST /api/login0.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 زمان هر مرحله را از شروع اندازه میگیرد:
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"donetail -n 2 /var/log/nginx/shop.access.loghttps://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 در لاگ) |
دو درس عملی:
- TLS گران است، پس اتصالها را زنده نگه دار: HTTP/2 همهی فایلهای یک صفحه را روی یک اتصال میفرستد و مرورگر TLS را فقط یک بار انجام میدهد. (روی اینترنت واقعی، TLS ۱.۳ یک رفتوبرگشت و TLS ۱.۲ دو رفتوبرگشت لازم دارد؛ برای کاربری با پینگ ۱۰۰ میلیثانیه این یعنی ۱۰۰ تا ۲۰۰ میلیثانیه.)
- در لاگ
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 |
جدولهای مرجع
Section titled “جدولهای مرجع”| فایل | 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 (۴۰۳) |
اشتباهات رایج
Section titled “اشتباهات رایج”۱) تنظیم HTTPS قبل از وجود گواهی
Section titled “۱) تنظیم HTTPS قبل از وجود گواهی”cp /etc/nginx/sites-available/shop.test /root/shop.test.baksed -i 's|/etc/letsencrypt/live/shop.test/fullchain.pem|/etc/letsencrypt/live/shop.tset/fullchain.pem|' /etc/nginx/sites-available/shop.testnginx -t 2>&1 | cut -c1-160cp /root/shop.test.bak /etc/nginx/sites-available/shop.test2026/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:sysnginx: configuration file /etc/nginx/nginx.conf test failedاگر فایل گواهی نباشد (هنوز گرفته نشده، یا مثل اینجا یک غلط تایپی در مسیر)، کل Nginx شروع یا reload نمیشود؛ نه فقط این سایت. برای همین در قدم ۳ اول فقط HTTP را ساختیم. اگر مجبوری از اول تنظیم کامل را داشته باشی، یک گواهی خودامضای موقت بساز (درس HTTPS) و بعد از گرفتن گواهی واقعی مسیرها را عوض کن.
۲) add_header در یک location و گم شدن هدرهای امنیتی
Section titled “۲) add_header در یک location و گم شدن هدرهای امنیتی”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.testcp /var/www/shop/current/index.html /var/www/shop/current/promo.htmlnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1for 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)'donecp /root/shop.test.bak /etc/nginx/sites-available/shop.testrm /var/www/shop/current/promo.htmlsystemctl reload nginxnginx: 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 یک عمل اتمی در لینوکس است: هر درخواست یا تماماً نسخهی قبلی را میبیند یا تماماً نسخهی جدید).
۴) تمدید بدون reload
Section titled “۴) تمدید بدون reload”certbot گواهی تازه را در /etc/letsencrypt مینویسد، ولی Nginx گواهی را موقع شروع یا reload در حافظه بارگذاری میکند. بدون reload، Nginx همان گواهی قدیمی را تا انقضا نشان میدهد. راهحل: --deploy-hook 'systemctl reload nginx' (قدم ۳؛ certbot آن را در فایل تمدید ذخیره کرد) یا افزونهی --nginx که خودش reload میکند.
برنامهنویسها یک تغییر دادهاند: عنوان صفحه باید «کتابفروشی LoopX» باشد. آن را منتشر کن، با shop-check مطمئن شو همهچیز سالم است، بعد فرض کن مدیر از عنوان خوشش نیامد و برگردان.
دیدن جواب
sleep 1sed -i 's|<h1>کتابفروشی</h1>|<h1>کتابفروشی LoopX</h1>|' /srv/shop-src/index.htmlshop-deploy /srv/shop-srccurl -s --cacert /root/pebble-root.pem https://shop.test/ | grep -o '<h1>.*</h1>'shop-check | tail -n 1shop-rollbackcurl -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.htmldeployed 20261004-144559<h1>کتابفروشی LoopX</h1>failed: 0rolled 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 دیگر، بدون رمز، رمز غلط، درست) را آزمایش کن.
دیدن جواب
mkdir -p /var/www/shop/current/adminecho '<h1>admin panel</h1>' > /var/www/shop/current/admin/index.htmlhtpasswd -bc /etc/nginx/lx-shop.htpasswd boss 'S3cret-Pass' 2>&1chown root:www-data /etc/nginx/lx-shop.htpasswdchmod 640 /etc/nginx/lx-shop.htpasswdcp /etc/nginx/sites-available/shop.test /root/shop.test.baksed -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.testnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1a() { 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.testrm -rf /var/www/shop/current/admin /etc/nginx/lx-shop.htpasswdsystemctl reload nginxAdding password for user bossnginx: configuration file /etc/nginx/nginx.conf test is successfulother IP (127.0.0.2): 403no password: 401wrong password: 401right password: 2003چهار حالت، چهار جواب درست:
- 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/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"donefor 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"; fidonecert=/etc/letsencrypt/live/$domain/fullchain.pemif 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"; fisystemctl 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 ]chmod +x /usr/local/bin/shop-preflightshop-preflight shop.test; echo "exit code: $?"echo "=== simulate a problem:"systemctl disable --now lx-shop-api@3002 2>&1 | grep -c Removedshop-preflight shop.test | grep -E 'FAIL|problem'systemctl enable --now lx-shop-api@3002 2>&1 | grep -c CreatedOK nginx config validOK listening on 80OK listening on 443OK lx-shop-api@3001 active+enabledOK lx-shop-api@3002 active+enabledOK certificate valid > 14 days (Jan 2 14:45:50 2027 GMT)OK certbot.timer activeOK shop.test -> 127.0.0.1 (this server)--- 0 problem(s)exit code: 0=== simulate a problem:1FAIL 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 باید هر دو بدون خطا باشند.
آزمونک
Section titled “آزمونک”چرا در قدم ۳ اول فقط server پورت ۸۰ را ساختیم؟
مرغ و تخممرغ: اول HTTP و webroot، بعد گواهی، بعد HTTPS.
چرا استقرار با symlink و mv -T «اتمی» است؟
و نسخهی قبلی سر جایش میماند تا برگشت در یک لحظه ممکن باشد.
location جدیدی با add_header Cache-Control ساختی و هدرهای HSTS و CSP در آن صفحه نیستند. چرا؟
shop-check چنین خطای بیصدایی را پیدا میکند.
فایدهی --deploy-hook "systemctl reload nginx" در certbot چیست؟
Nginx گواهی را فقط موقع شروع یا reload میخواند.
چرا CSS و JS اسم hashدار دارند؟
درس عملکرد.
یکی از دو نسخهی API خاموش شد. کاربر چه میبیند؟
درس load balancing؛ در قدم ۷ همهی درخواستها را نسخهی سالم جواب داد.
اسکریپت shop-check چه زمانی باید اجرا شود؟
nginx -t فقط syntax را میسنجد، نه رفتار را.
جمعبندی
Section titled “جمعبندی”- ترتیب: 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، APIno-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 | آزمایش تمدید |