از صفحهٔ محلی تا اوبونتو؛ اجرای دائمی Node.js و درگاه Nginx
در این درس ۷ بخش
تا اینجا «سلام، سرور!» روی رایانهٔ خودمان کار میکند: مرورگر صفحه را میگیرد، نام را به API میفرستد و پیام را نمایش میدهد. اکنون میخواهیم همین پروژه روی یک اوبونتوی آزمایشی اجرا شود و با بستن پنجرهٔ SSH متوقف نشود. خروجی این درس یک صفحهٔ HTTP عمومی و مرتب است؛ هنوز HTTPS را فعال نکردهایم. مسیر را در چهار ایستگاه میسنجیم: رسیدن فایلها، پاسخ مستقیم Node، اجرای سرویس و پاسخ از Nginx. اگر یک ایستگاه نتیجهٔ درست ندارد، به مرحلهٔ بعد نمیرویم؛ چند تغییر همزمان تشخیص علت را سخت میکند.
پیش از انتقال؛ دستگاه، مسیر و حساب را مشخص کنید
این درس برای یک ماشین تازه با Ubuntu Server 24.04 LTS و حساب مدیریتی دارای sudo نوشته شده است. ماشین مجازی هم برای تمرین محلی مناسب است، ولی برای ادامهٔ Let’s Encrypt باید دامنهٔ واقعی و دسترسی عمومی داشته باشید. لپتاپ شما «رایانهٔ محلی» و اوبونتوی مقصد «سرور» است؛ PowerShell روی اولی و Bash در نشست SSH روی دومی اجرا میشود. اگر هنوز ورود SSH یا هویت میزبان را بررسی نکردهاید، ابتدا درس آمادهسازی را انجام دهید: https://www.darsavand.com/courses/nodejs-backend-ubuntu-deployment?lesson=664e927d-1cdb-4c87-bba5-c48ce0b3184c
مسیر باینری Node در این آموزش /opt/node-current/bin/node است. اگر این فایل را ندارید، درس نصب رسمی را کامل کنید: https://www.darsavand.com/courses/nodejs-backend-ubuntu-deployment?lesson=dd7977ff-4b0f-41a0-a222-050ce5693620
پوشهٔ hello-http روی ویندوز باید همان فایلهای کامل درسهای قبل را داشته باشد؛ بهویژه پوشهٔ public با سه فایل صفحه، ظاهر و رفتار. پوشهٔ خالی همنام کافی نیست. در نمونهٔ فرمانها، labadmin حساب شما، 203.0.113.10 یک IP مستنداتی و عدد 22 فقط نمونهٔ پورت SSH است؛ هر سه را با اطلاعات واقعی آزمایشگاه عوض کنید. دامنهٔ hello.example.com نیز نمونه است و گواهی برای آن نمیگیریم. پیش از ادامه، از پنل ارائهدهنده امکان کنسول بازیابی را پیدا کنید و نشست SSH فعلی را باز نگه دارید؛ این کار راه بررسی خطای ارتباطی بعدی را حفظ میکند.
# روی ویندوز، PowerShell؛ در پوشهٔ والد hello-http اجرا کنید.
$target = 'labadmin@203.0.113.10'
$sshPort = 22
Get-Item .\hello-http\package.json -ErrorAction Stop
ssh -p $sshPort $target 'test ! -e ~/hello-http && test ! -L ~/hello-http && mkdir ~/hello-http'
if ($LASTEXITCODE -ne 0) { throw 'پوشهٔ مقصد ساخته نشد؛ توقف و بررسی کنید.' }
scp -P $sshPort .\hello-http\package.json .\hello-http\app.mjs .\hello-http\server.mjs .\hello-http\app.test.mjs .\hello-http\.env.example .\hello-http\.gitignore .\hello-http\README.md "${target}:hello-http/"
if ($LASTEXITCODE -ne 0) { throw 'انتقال فایلهای اصلی ناموفق بود.' }
scp -r -P $sshPort .\hello-http\public "${target}:hello-http/"
if ($LASTEXITCODE -ne 0) { throw 'انتقال پوشهٔ public ناموفق بود.' }scp انتقال فایل روی ارتباط SSH است. در آن، گزینهٔ بزرگ P پورت را مشخص میکند، درحالیکه ssh از p کوچک استفاده میکند. فرمان نخست عمداً اگر پوشهٔ مقصد موجود باشد متوقف میشود؛ آموزش اجازه نمیدهد فایل پروژهٔ قبلی را بیخبر جایگزین کنیم. اگر انتقال نیمهکاره شد، ابتدا فایلهای موجود را بررسی کنید و دلیل خطا را بخوانید؛ دوبارهزدن کورکورانهٔ بلوک از اول راه بازیابی نیست. بررسی LASTEXITCODE در PowerShell مانع ادامهٔ خودکار پس از شکست فرمان قبلی میشود. پوشهٔ node_modules یا فایل .env واقعی را منتقل نمیکنیم؛ پروژه وابستگی خارجی ندارد و تنظیم سرور را جدا خواهیم ساخت.
اوبونتو را بررسی و نسخهٔ اجرا را نصب کنید
# روی اوبونتو، در همان نشست SSH مدیریتی
whoami
cat /etc/os-release
/opt/node-current/bin/node --version
find "$HOME/hello-http" -maxdepth 2 -type f -print
sudo -v
sudo ss -lntp '( sport = :3000 )'
sudo apt update
sudo apt install nginx ufw nano dnsutilsفهرست فایل باید public/index.html و public/style.css و public/app.js را نیز نشان دهد. خروجی Node باید شاخهٔ پشتیبانیشدهٔ سازگار با پروژه باشد؛ در این مسیر از Node 24 LTS با اصلاحیهٔ مناسب استفاده میکنیم. در خروجی ss، اگر فرایندی روی پورت ۳۰۰۰ گوش میدهد، همینجا توقف کنید. ممکن است پروژهٔ دورهٔ قبلی هنوز فعال باشد؛ همهٔ فرایندهای Node را نبندید. یک ماشین تازه انتخاب کنید یا تغییر پورت را آگاهانه در برنامه، فایل محیطی و Nginx یکسان انجام دهید. apt update فهرست بستهها را تازه میکند؛ این دستور با ارتقای بیبرنامهٔ همهٔ نرمافزارهای سرور تفاوت دارد.
کاربر مدیریتی برای نصب از sudo استفاده میکند؛ خود برنامه به چنین اختیاری نیاز ندارد. helloapp یک حساب سیستمی مخصوص اجرای برنامه است، بدون ورود تعاملی و بدون عضویت sudo. مالک فایل کد، root خواهد بود؛ پس خرابی احتمالی برنامه بهتنهایی مجوز بازنویسی کدش را نمیدهد. پوشهٔ /opt/darsavand-hello محل نسخهٔ نصبشده و /etc/darsavand-hello محل تنظیم اجراست. بلوک زیر فقط برای نصب تازه است و همهٔ مقصدهای مهم را پیش از اولین نوشتن بررسی میکند. وجود مسیر یا حساب قبلی، دلیل بررسی است؛ آن را با حذف خودکار یا chmod 777 حل نکنید.
# روی اوبونتو؛ بلوک نصب تازه، یکجا اجرا شود.
(
set -eu
SRC="$HOME/hello-http"
for file in package.json app.mjs server.mjs app.test.mjs public/index.html public/style.css public/app.js; do
test -f "$SRC/$file"
done
if getent passwd helloapp >/dev/null || getent group helloapp >/dev/null; then
printf 'helloapp already exists; stop and inspect\n' >&2; exit 1
fi
for path in /opt/darsavand-hello /etc/darsavand-hello /etc/systemd/system/darsavand-hello.service; do
if sudo test -e "$path" || sudo test -L "$path"; then
printf 'Destination exists: %s\n' "$path" >&2; exit 1
fi
done
sudo adduser --system --group --no-create-home --home /nonexistent --shell /usr/sbin/nologin helloapp
sudo mkdir /opt/darsavand-hello
sudo cp -R -- "$SRC"/. /opt/darsavand-hello/
sudo chown -R root:root /opt/darsavand-hello
sudo find /opt/darsavand-hello -type d -exec chmod 0755 {} +
sudo find /opt/darsavand-hello -type f -exec chmod 0644 {} +
sudo install -d -o root -g root -m 0700 /etc/darsavand-hello
printf 'PORT=3000\n' | sudo tee /etc/darsavand-hello/app.env >/dev/null
sudo chmod 0600 /etc/darsavand-hello/app.env
sudo -u helloapp /opt/node-current/bin/node --test /opt/darsavand-hello/app.test.mjs
)انتظار داریم آزمونها موفق شوند و هیچ خروجی شکستخوردهای نبینیم. test -f وجود فایل را بررسی میکند؛ موفقیت آن تضمین درستبودن محتوای فایل نیست و به همین دلیل آزمون برنامه نیز اجرا میشود. فایل app.env فعلاً فقط PORT دارد؛ فایل محیطی محل کد جاوااسکریپت نیست. مدیر systemd با دسترسی خودش این فایل را میخواند و مقدار را هنگام ایجاد فرایند تحویل میدهد، پس لازم نیست helloapp بتواند خود فایل را باز کند. اگر نصب در میانه شکست خورد، همان خطا و وضعیت مقصد را ثبت کنید؛ بلوک با خروج غیرصفر ادامهٔ مراحلش را قطع میکند، اما تراکنش خودکار با برگشت کامل نیست.
systemd؛ برنامه پس از قطع SSH هم آماده بماند
فرایند برنامه همان اجرای فعلی Node است؛ سرویس، دستور نگهداری و اجرای آن توسط سیستم عامل است. برای این برنامه فایل واحد سرویس میسازیم. در اوبونتو sudo nano /etc/systemd/system/darsavand-hello.service را اجرا کنید و محتوای کامل زیر را داخل فایل قرار دهید. این بلوک محتویات فایل است، نه فرمانی که مستقیم در ترمینال تایپ شود. در nano با Ctrl+O، سپس Enter ذخیره و با Ctrl+X خارج شوید. مسیر ExecStart مطلق است تا سرویس به PATH ترمینال شما وابسته نباشد. Restart=on-failure تلاش دوباره پس از شکست را تنظیم میکند؛ جای رفع علت خطا یا سامانهٔ پایش را نمیگیرد.
[Unit]
Description=Darsavand Hello HTTP teaching project
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=helloapp
Group=helloapp
WorkingDirectory=/opt/darsavand-hello
EnvironmentFile=/etc/darsavand-hello/app.env
ExecStart=/opt/node-current/bin/node /opt/darsavand-hello/server.mjs
Restart=on-failure
RestartSec=3
TimeoutStopSec=5
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target# روی اوبونتو، پس از ذخیرهٔ فایل سرویس
(
set -eu
sudo systemd-analyze verify /etc/systemd/system/darsavand-hello.service
sudo systemctl daemon-reload
sudo systemctl enable --now darsavand-hello.service
sudo systemctl status darsavand-hello.service --no-pager
curl --fail --show-error --silent --max-time 10 http://127.0.0.1:3000/api/status
sudo ss -lntp '( sport = :3000 )'
)daemon-reload تعریف تازهٔ سرویس را به مدیر معرفی میکند؛ enable برای شروع در راهاندازیهای بعدی است و --now شروع همین لحظه را نیز درخواست میکند. status باید active (running) نشان دهد و curl شیء #json با ok برابر true و service برابر darsavand-hello برگرداند. در ss باید 127.0.0.1:3000 ببینید، نه درگاهی عمومی روی همهٔ رابطها. اگر status سبز است ولی پاسخ درست ندارید، همین پاسخ را معیار عملکرد قرار دهید. برای شکست، sudo journalctl -u darsavand-hello.service -n 40 --no-pager را بخوانید: پیدانشدن باینری، پوشه یا فایل public را از خطای اشغال پورت جدا کنید.
Nginx؛ یک نشانی مشترک برای صفحه و API
Nginx در این پروژه درگاه عمومی است و درخواست را به Node محلی میرساند؛ این نقش «پراکسی معکوس» نام دارد. مرورگر هم صفحه و هم /api/greet را از یک نام دامنه میخواهد، بنابراین #fetch-http-json صفحه به نشانی دیگری فرستاده نمیشود و راهحل CORS لازم نداریم. پس از اطمینان از خالیبودن نام فایل، با sudo nano /etc/nginx/sites-available/darsavand-hello فایل زیر را بسازید. اگر این فایل یا پیوند فعال آن از قبل وجود دارد، توقف و بررسی کنید. hello.example.com را پیش از ذخیره با دامنهٔ واقعی خود عوض کنید. مسیر proxy_pass عمداً پسوند اضافه ندارد تا نام مسیر API تغییر نکند.
server {
listen 80;
listen [::]:80;
server_name hello.example.com;
client_max_body_size 2k;
access_log /var/log/nginx/darsavand-hello.access.log;
error_log /var/log/nginx/darsavand-hello.error.log;
location / {
proxy_pass http://127.0.0.1:3000;
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 $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 3s;
proxy_read_timeout 15s;
proxy_send_timeout 15s;
}
}# روی اوبونتو؛ DOMAIN را دوباره برای همین شل واقعی کنید.
DOMAIN='hello.example.com'
(
set -eu
LINK=/etc/nginx/sites-enabled/darsavand-hello
if sudo test -e "$LINK" || sudo test -L "$LINK"; then
printf 'Site link exists; stop and inspect\n' >&2; exit 1
fi
sudo ln -s /etc/nginx/sites-available/darsavand-hello "$LINK"
sudo nginx -t
sudo systemctl reload nginx
curl --fail --show-error --silent --max-time 10 -H "Host: $DOMAIN" http://127.0.0.1/api/status
)اگر nginx -t خطا داد، reload اجرا نمیشود؛ خطای فایل و شمارهٔ خط را اصلاح و بررسی نحو را دوباره انجام دهید. پس از رفع علت، فقط sudo nginx -t و سپس sudo systemctl reload nginx را اجرا کنید؛ بلوک ساخت پیوند را از ابتدا تکرار نکنید، چون پیوند اکنون وجود دارد. این آزمون، درستبودن اتصال به برنامه را ثابت نمیکند، به همین دلیل curl جداگانه لازم است. پاسخ ۵۰۲ معمولاً یعنی Nginx به برنامهٔ داخلی پاسخگو نرسیده؛ ابتدا سرویس و پورت ۳۰۰۰ را بررسی کنید. دیدن صفحهٔ پیشفرض Nginx میتواند از نام میزبان اشتباه یا فعالنشدن سایت باشد. سقف بدنه در Nginx دو کیلوبایت است، ولی قرارداد برنامه ورودی JSON بزرگتر از ۱۰۲۴ بایت را رد میکند؛ این دو بررسی در دو لایه انجام میشوند و #validation-contracts برنامه همچنان لازم است.
دامنه و فایروال؛ آخرین درِ ورودی را باز کنید
در پنل DNS دامنه، رکورد A برای زیرنام انتخابی را به IPv4 واقعی سرور بدهید. رکورد AAAA فقط وقتی درست است که IPv6 واقعاً به همین میزبان برسد؛ آدرس قدیمی باعث شکست بخشی از درخواستها میشود. DNS آدرس را پیدا میکند و فایروال اجازهٔ عبور میدهد؛ هیچکدام برنامه را اجرا نمیکنند. dig +short A hello.example.com و dig +short AAAA hello.example.com را با نام واقعی اجرا و نتیجه را با پنل سرور مقایسه کنید. در فایروال ابری ارائهدهنده نیز پورت SSH واقعی و ورودی TCP روی ۸۰ و ۴۴۳ لازماند. برای این تمرین پورت ۳۰۰۰ عمومی باز نمیشود.
# روی اوبونتو، داخل نشست SSH فعلی؛ ابتدا پورت همین اتصال را مجاز کنید.
(
set -eu
read -r CLIENT_IP CLIENT_PORT SERVER_IP SSH_PORT <<< "${SSH_CONNECTION:?Run from the SSH session}"
case "$SSH_PORT" in ''|*[!0-9]*) printf 'Cannot identify SSH port\n' >&2; exit 1 ;; esac
test "$SSH_PORT" -ge 1
test "$SSH_PORT" -le 65535
printf 'Current SSH port: %s\n' "$SSH_PORT"
sudo ufw status verbose
sudo ufw allow "${SSH_PORT}/tcp"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw enable
sudo ufw status verbose
)این بلوک فقط برای آزمایشگاه تازه است؛ سیاست یک سرور اشتراکی را با آن جایگزین نکنید. پیش از پاسخ به پرسش فعالسازی، خروجی پورت SSH را با اتصال خود تطبیق دهید. نشست اول را نبندید؛ از PowerShell دوم ورود تازه با همان حساب و پورت را آزمایش کنید. موفقیت اتصال قدیمی بهتنهایی کافی نیست، چون ارتباط برقرارشده میتواند با قانون تازه رفتار متفاوتی داشته باشد. سپس http://نامواقعیدامنه را در مرورگر باز کنید، یک نام ساختگی وارد و پیام API را ببینید. اگر از داخل سرور سالم و از بیرون ناموفق است، DNS، فایروال ابری و UFW را لایهبهلایه بررسی کنید؛ خاموشکردن همهٔ فایروالها تشخیص مناسبی نیست.
تمرین با پاسخ و ایستگاه تحویل
برای تحویل، پنج شاهد ثبت کنید: نسخهٔ Node، نتیجهٔ آزمون کد، وضعیت سرویس، پاسخ /api/status از پورت داخلی و پاسخ همان مسیر با نام دامنه از اینترنت. پاسخ صفحه باید پیام فارسی را پس از ارسال فرم نشان دهد و تغییر ظاهر نباید فایل محلی file:// باشد. تمرین: اگر curl داخلی پاسخ دارد اما Nginx خطای ۵۰۲ میدهد، چه کنید؟ پاسخ: مسیر proxy_pass و پورت سرویس را کنار هم بخوانید و لاگ خطای Nginx را بررسی کنید؛ عوضکردن DNS کمکی به این اتصال داخلی نمیکند. تمرین دوم: اگر صفحه باز است اما /api/greet خطای ۴۲۲ میدهد، قرارداد نام و دادهٔ درخواست را بررسی کنید؛ این شکست برنامه با بستهبودن پورت فرق دارد.
منابع رسمی برای بررسی بیشتر
Ubuntu — User management: https://ubuntu.com/server/docs/how-to/security/user-management/
Ubuntu — Install Nginx: https://ubuntu.com/server/docs/how-to/web-services/install-nginx/
Ubuntu — Firewall: https://ubuntu.com/server/docs/how-to/security/firewalls/
Nginx — Proxy module: https://nginx.org/en/docs/http/ngx_http_proxy_module.html
systemd — Environment and execution: https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html
systemd — Service: https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html
تألیف و مثالهای آموزشی: تحریریه درسآوند.
متن درسهای عمومی را بدون ورود بخوانید.