مقاله

SSL و Let's Encrypt برای Laravel — HTTPS رایگان روی Nginx

SSL و Let's Encrypt برای Laravel — HTTPS رایگان روی Nginx

Certbot، گواهی رایگان، تمدید خودکار و redirect HTTP به HTTPS — checklist قبل از production.

بدون HTTPS مرورگر «ناامن» نشان می‌دهد و cookie session روی اتصال رمزنگاری‌نشده جابه‌جا می‌شود. Let's Encrypt گواهی رایگان می‌دهد و Certbot آن را می‌گیرد، روی وب‌سرور نصب و خودکار تمدید می‌کند. بعد از آن، Laravel باید بداند آدرس اصلی سایت https است.

خلاصه: HTTPS در یک سایت Laravel از چهار لایه ساخته می‌شود: DNS که دامنه را به IP سرور می‌رساند، وب‌سرور (Nginx یا Apache) که اتصال TLS را برقرار می‌کند، گواهی که Let's Encrypt بعد از تأیید مالکیت دامنه صادر می‌کند، و تنظیمات Laravel که لینک‌ها، redirectها و cookieها را با https می‌سازد. Laravel خودش گواهی را نصب یا رمزنگاری را انجام نمی‌دهد. این مقاله راهنمای اجرایی روی سرور Ubuntu است؛ اگر توضیح غیرفنی SSL برای مدیر کسب‌وکار را می‌خواهید، گواهی SSL برای کسب‌وکار و برای مقایسهٔ گواهی رایگان و پولی، آیا SSL رایگان کافی است؟ را بخوانید.

پیش‌نیاز

  • رکورد A دامنه (و اگر دارید، AAAA برای IPv6) به IP همین سرور اشاره می‌کند. AAAA قدیمی که به سرور دیگری اشاره می‌کند یکی از دلایل رایج شکست تأیید است.
  • Nginx یا Apache برای همان دامنه روی پورت 80 جواب می‌دهد و server_name (یا ServerName/ServerAlias) هر دو نسخهٔ با و بدون www را دارد — پیکربندی Nginx برای Laravel
  • پورت 80 و 443 در firewall سرور و firewall دیتاسنتر باز است. روش پیش‌فرض Certbot (HTTP-01) از طریق پورت 80 مالکیت دامنه را بررسی می‌کند.
  • اگر دامنه پشت CDN یا proxy است، ممکن است درخواست تأیید به سرور شما نرسد؛ قبل از صدور، تنظیمات آن سرویس را بررسی کنید یا از روش DNS-01 استفاده کنید.
  • اگر برای دامنه رکورد CAA تعریف کرده‌اید، باید اجازهٔ صدور به letsencrypt.org را بدهد.
  • قبل از هر تغییر، از پیکربندی فعلی وب‌سرور نسخهٔ پشتیبان بگیرید.
dig +short A example.com
dig +short AAAA example.com
curl -I http://example.com

نصب Certbot (Ubuntu)

راهنمای رسمی Certbot برای بیشتر کاربران نصب از طریق snap را توصیه می‌کند. اگر نسخهٔ apt از قبل نصب است، اول آن را حذف کنید تا دستور certbot به نسخهٔ snap اشاره کند:

sudo apt-get remove certbot
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/local/bin/certbot

Nginx:

sudo certbot --nginx -d example.com -d www.example.com

Apache:

sudo certbot --apache -d example.com -d www.example.com

با plugin وب‌سرور، Certbot مالکیت دامنه را تأیید می‌کند، block مربوط به SSL را به پیکربندی اضافه می‌کند و می‌تواند redirect از HTTP به HTTPS هم بسازد. اگر ترجیح می‌دهید پیکربندی را خودتان ویرایش کنید، از certonly استفاده کنید (مثلاً sudo certbot certonly --nginx). در پیکربندی دستی Nginx، برای ssl_certificate فایل fullchain.pem را بدهید، نه cert.pem؛ نبودِ گواهی میانی (intermediate) باعث می‌شود بعضی مرورگرها و کلاینت‌ها، به‌خصوص روی موبایل، خطای گواهی بدهند در حالی که مرورگر دسکتاپ شما مشکلی نشان نمی‌دهد.

ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

بعد از هر تغییر دستی، قبل از reload پیکربندی را تست کنید:

sudo nginx -t && sudo systemctl reload nginx
# Apache:
sudo apachectl configtest && sudo systemctl reload apache2

تمدید خودکار

sudo certbot renew --dry-run

بستهٔ Certbot یک systemd timer یا cron برای تمدید نصب می‌کند و لازم نیست خودتان cron بسازید. --dry-run تمدید را با سرور staging شبیه‌سازی می‌کند و گواهی واقعی را عوض نمی‌کند. برای اطمینان از اینکه تمدید واقعاً برنامه‌ریزی شده:

systemctl list-timers | grep certbot
sudo certbot certificates   # دامنه‌ها، مسیر فایل‌ها و تاریخ انقضا

در نصب snap، نام timer معمولاً snap.certbot.renew.timer است. با plugin Nginx یا Apache، وب‌سرور بعد از تمدید موفق خودکار reload می‌شود؛ اگر گواهی را برای سرویس دیگری هم استفاده می‌کنید، یک deploy hook در /etc/letsencrypt/renewal-hooks/deploy/ لازم است.

دو تغییر اخیر را هم در نظر بگیرید. Let's Encrypt از ژوئن ۲۰۲۵ ایمیل هشدار انقضا نمی‌فرستد، پس پایش انقضا باید از طرف خودتان باشد (مثلاً یک سرویس uptime که تاریخ گواهی را چک کند). همچنین طبق اعلام Let's Encrypt، عمر پیش‌فرض گواهی از ۹۰ روز به ۶۴ روز (از فوریه ۲۰۲۷) و بعد ۴۵ روز (از فوریه ۲۰۲۸) کاهش می‌یابد. اگر جایی تمدید را با یک عدد ثابت روز (مثلاً هر ۶۰ روز) در اسکریپت یا cron خودتان نوشته‌اید، آن را حذف کنید و به تمدید خودکار Certbot تکیه کنید.

تنظیم Laravel

APP_URL=https://example.com
SESSION_SECURE_COOKIE=true

بعد از تغییر .env در production، php artisan config:cache را دوباره اجرا کنید. SESSION_SECURE_COOKIE=true باعث می‌شود cookie session فقط روی HTTPS ارسال شود؛ آن را وقتی فعال کنید که کل سایت روی HTTPS است، وگرنه ورود روی نسخهٔ HTTP کار نمی‌کند.

اگر TLS روی خود Nginx/Apache همان سرور تمام می‌شود و درخواست مستقیم به PHP-FPM می‌رسد، معمولاً تنظیم دیگری لازم نیست. اگر بین کاربر و Laravel یک load balancer، reverse proxy یا CDN است که TLS را تمام می‌کند و با HTTP به سرور می‌رسد، Laravel باید به headerهای X-Forwarded-* آن proxy اعتماد کند؛ در Laravel 12 این کار در bootstrap/app.php انجام می‌شود:

->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(at: ['10.0.0.10']); // IP یا رنج proxy خودتان
})

at: '*' یعنی اعتماد به هر proxy؛ فقط وقتی از آن استفاده کنید که سرور جز از طریق proxy قابل دسترس نیست. اگر نمی‌توانید proxy را درست معرفی کنید، به‌عنوان راه آخر می‌شود در AppServiceProvider با URL::forceHttps() (یا URL::forceScheme('https')) ساخت URLها را اجباری کرد؛ این فقط لینک‌های تولیدشده را عوض می‌کند و تشخیص $request->secure() را درست نمی‌کند.

یک آدرس اصلی — redirect و canonical

سایت باید فقط با یک نسخه در دسترس باشد: مثلاً https://example.com. نسخه‌های http://، http://www. و https://www. با redirect دائمی (301) به همان نسخه بروند، و APP_URL هم دقیقاً همان نسخه باشد تا canonical، sitemap و لینک‌های داخلی که Laravel می‌سازد با آدرسی که کاربر می‌بیند یکی باشند. redirect را در وب‌سرور انجام دهید، نه هم در وب‌سرور و هم در Laravel، تا حلقهٔ redirect ساخته نشود.

server {
    listen 443 ssl;
    server_name www.example.com;
    # ssl_certificate ... (همان گواهی که www را هم پوشش می‌دهد)
    return 301 https://example.com$request_uri;
}

HSTS (اختیاری پس از اطمینان)

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

قبل از فعال‌سازی مطمئن شوید HTTPS پایدار است — rollback سخت.

mixed content

asset با http:// — از asset() و APP_URL https استفاده کنید. Vite ASSET_URL در production.

mixed content یعنی صفحه با HTTPS باز شده ولی یک عکس، اسکریپت یا فونت با http:// لود می‌شود. مرورگر اسکریپت‌های HTTP را مسدود می‌کند و قفل آدرس‌بار را ناقص نشان می‌دهد. منابع رایج: لینک‌های مطلق قدیمی در محتوای دیتابیس، تنظیمات ذخیره‌شده با آدرس http، و اسکریپت‌های شخص ثالث. در DevTools مرورگر، تب Console و Network هشدار «Mixed Content» را با آدرس دقیق نشان می‌دهد.

wildcard certificate

DNS challenge — certbot certonly --manual یا plugin DNS provider. برای subdomain زیاد.

گواهی wildcard (*.example.com) فقط با روش DNS-01 صادر می‌شود. روش --manual هر بار رکورد TXT دستی می‌خواهد و خودکار تمدید نمی‌شود؛ برای تمدید خودکار باید plugin DNS مخصوص ارائه‌دهندهٔ DNS خودتان را نصب کنید (pluginهای DNS به‌طور پیش‌فرض همراه Certbot نصب نمی‌شوند).

خطاهای رایج صدور و رفع آن‌ها

  • شکست challenge (Timeout یا 404 روی /.well-known/acme-challenge/): DNS هنوز به سرور قبلی اشاره می‌کند، پورت 80 بسته است، رکورد AAAA اشتباه است، یا یک redirect/قانون وب‌سرور درخواست را به Laravel یا جای دیگر می‌فرستد. با curl -I http://example.com/.well-known/acme-challenge/test ببینید درخواست به کدام سرور و با چه پاسخی می‌رسد.
  • «no valid A records found» یا خطای DNS: رکورد هنوز منتشر نشده یا اشتباه است؛ با dig از بیرون سرور بررسی کنید.
  • دامنه در گواهی نیست (NET::ERR_CERT_COMMON_NAME_INVALID): گواهی برای example.com گرفته شده ولی کاربر www.example.com را باز کرده. هر دو را با -d بدهید.
  • rate limit: Let's Encrypt برای تعداد صدور محدودیت دارد. برای آزمایش از --dry-run یا --staging استفاده کنید، نه درخواست پشت‌سرهم گواهی واقعی.
  • خطای زنجیره یا گواهی فقط در بعضی دستگاه‌ها: به‌جای fullchain.pem، cert.pem تنظیم شده؛ یا ساعت دستگاه کاربر اشتباه است.
  • گواهی منقضی با وجود Certbot: timer غیرفعال است، تمدید به‌خاطر تغییر DNS یا بسته شدن پورت 80 شکست خورده، یا وب‌سرور بعد از تمدید reload نشده و هنوز گواهی قدیمی را از حافظه می‌دهد.

بررسی بعد از راه‌اندازی

curl -I http://example.com            # باید 301 به https://example.com
curl -I https://www.example.com       # باید 301 به https://example.com
curl -vI https://example.com 2>&1 | grep -iE "subject|issuer|expire"

echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates
  • صفحه با قفل کامل باز می‌شود و Console مرورگر هشدار mixed content ندارد
  • ورود و خروج کاربر و فرم‌ها روی HTTPS کار می‌کنند (cookie و CSRF)
  • لینک‌هایی که Laravel می‌سازد (ایمیل‌ها، sitemap، canonical) با https:// و دامنهٔ اصلی شروع می‌شوند
  • sudo certbot renew --dry-run موفق است و timer تمدید فعال است
  • یک یادآور یا مانیتور برای تاریخ انقضا دارید

امنیت تکمیلی

چک‌لیست امنیت — headerها، CSP.

HTTPS فقط مسیر بین مرورگر و سرور را رمزنگاری می‌کند. آسیب‌پذیری کد، رمز ضعیف پنل، پکیج قدیمی یا سرور به‌روزنشده را برطرف نمی‌کند، و به‌تنهایی رتبهٔ بهتر در گوگل را هم تضمین نمی‌کند؛ پیش‌نیاز است، نه کل امنیت.

اشتباهات

  • APP_URL هنوز http
  • گواهی منقضی — renew fail بدون alert
  • staging روی production cert
  • تنظیم cert.pem به‌جای fullchain.pem
  • redirect هم در Nginx و هم در Laravel، و حلقهٔ redirect پشت proxy
  • فعال کردن HSTS با includeSubDomains قبل از اینکه همهٔ زیردامنه‌ها HTTPS داشته باشند
  • تغییر پیکربندی بدون nginx -t یا apachectl configtest

جمع‌بندی

SSL گام اجباری production. ترتیب امن: DNS و پورت 80 را بررسی کنید، با Certbot گواهی بگیرید، APP_URL و proxy را در Laravel درست کنید، یک نسخهٔ اصلی با redirect 301 داشته باشید، و تمدید را با --dry-run و پایش انقضا مطمئن کنید. Deploy کامل: Ubuntu + Nginx. Docker: Compose با reverse proxy جدا.

راه‌اندازی HTTPS

اشتراک‌گذاری