عندما تشغل أول تطبيق على سيرفرك بـ Docker Compose فغالباً سوف تنسخ ملف compose.yaml من صفحة المشروع كما هو، وفي هذا الملف سطر ports ينشر منفذ التطبيق على السيرفر، وأحياناً سطر آخر ينشر منفذ قاعدة البيانات Database حتى تتصل بها من جهازك، ومع التطبيق الخامس يصبح على السيرفر عشرة منافذ مفتوحة لا تتذكر أنت لماذا فتحت نصفها. والمشكلة أن هذه المنافذ ليست مفتوحة على السيرفر وحده كما يظن الكثيرون، وإنما على الإنترنت، حتى لو كان جدار الحماية Firewall مفعلاً.
وفي دليل Nginx Proxy Manager ذكرنا أننا سوف نضع NPM وكل التطبيقات التي يخدمها على شبكة Docker مشتركة Docker Network اسمها proxy، وعلى هذه الشبكة يصل NPM إلى كل Container باسمه، وبالتالي لا تحتاج التطبيقات إلى نشر أي منفذ على السيرفر، ويبقى ال Reverse Proxy هو الطريق الوحيد إليها. وهذه الجملة تختصر نمطاً كاملاً نستخدمه في كل أدلة الموقع، سواءً كان ال Reverse Proxy هو NPM أو Traefik أو Caddy، لذلك سوف نشرحه هنا مرة واحدة بالتفصيل، ونبدأ من الأساس: ما هي شبكة Docker، وما أنواعها، ولماذا يعمل هذا النمط.
في هذا المقال سوف نتناول:
- ما الذي يحدث عندما تنشر كل منفذ على السيرفر، ولماذا يتجاوز Docker جدار
ufw. - أنواع شبكات Docker Network Drivers:
bridgeوhostوnoneوoverlayوmacvlanوipvlan، ومتى يناسبك كل منها. - الشبكة التي ينشئها Compose لكل مشروع، وكيف تصل ال Containers إلى بعضها بالاسم.
- الفرق بين
portsوexpose، ومتى لا تنشر أي منفذ أصلاً. - النمط الذي نستخدمه في الموقع: شبكة
proxyمشتركة لل Reverse Proxy والتطبيقات، وشبكة داخلية Internal Network خاصة بكل مشروع لقاعدة بياناته. - الأخطاء الشائعة: خطأ 502 من ال Reverse Proxy، وتعارض الأسماء بين المشاريع، والمخرج الافتراضي Default Gateway عند الاتصال بشبكتين، ونفاد نطاقات العناوين Address Pools، وملاحظات IPv6.
وإذا لم يكن Docker مثبتاً على سيرفرك بعد، فابدأ بدليل تثبيت Docker Engine وDocker Compose على Ubuntu.
ماذا يحدث عندما تنشر كل منفذ؟
لنأخذ مثالاً، فلنفرض أن لديك تطبيقاً بسيطاً مع قاعدة بيانات PostgreSQL، والملف الذي يأتي في توثيق أغلب المشاريع يشبه ما يلي:

services:
app:
image: traefik/whoami:v1.12.0
ports:
- "8080:80"
db:
image: postgres:18.0-alpine
environment:
POSTGRES_PASSWORD: change-me
ports:
- "5432:5432"هذا الملف يعمل، والتطبيق يظهر على http://203.0.113.10:8080، ولكن لاحظ ما فعلته دون أن تقصد:
- الصيغة
"8080:80"لا تحدد عنواناً على السيرفر، لذلك ينشر Docker المنفذ على كل عناوين السيرفر، أي0.0.0.0و[::]، وبالتالي يصل إليه كل من يعرف عنوان السيرفر العام Public IP. - قاعدة البيانات أصبحت كذلك على الإنترنت بالمنفذ 5432، ولا يحميها إلا الباسورد، والتطبيق نفسه لم يكن يحتاج هذا المنفذ أصلاً، فهو يصل إلى قاعدة البيانات من داخل شبكة Docker كما سيأتي.
- كل تطبيق جديد يحتاج منفذاً جديداً، ورقماً يتذكره المستخدم، وشهادة TLS Certificate خاصة به.
وقد يتساءل البعض: ولكني فعلت جدار ufw ولم أسمح إلا بالمنافذ 22 و80 و443، فكيف يصل أحد إلى 5432؟ والإجابة أن توثيق Docker نفسه يقول إن Docker وufw يستخدمان قواعد جدار الحماية بطريقة لا تتوافق، فعندما تنشر منفذاً يحول Docker الحركة Traffic المتجهة إلى ال Container قبل أن تمر على قواعد ufw، وبالتالي يبقى المنفذ مفتوحاً مهما كتبت في ufw. وهذه أشهر مفاجأة يواجهها من ينقل خدماته إلى Docker، وقد شرحناها في دليل تأمين خادم VPS وفي دليل التثبيت.
والصورة التالية تبين الطريق الذي تسلكه الحزمة إلى المنفذ المنشور، ولماذا لا تمر على قواعد ufw:

ports واحداً بدون عنوان يكفي ليضع قاعدة بياناتك على الإنترنت.والحل الأول الذي يخطر على البال هو أن تنشر كل شيء على 127.0.0.1، وهذا أفضل بكثير، ولكنه لا يحل المشكلة كاملة، فال Reverse Proxy الذي يعمل في Container لا يصل إلى 127.0.0.1 الخاص بالسيرفر بسهولة، لأن 127.0.0.1 داخل كل Container يشير إلى ال Container نفسه، وبالتالي تحتاج إلى حيل مثل host.docker.internal أو تشغيل ال Reverse Proxy بشبكة host. والحل الأنسب هو ألا تنشر منافذ التطبيقات أصلاً، وأن تضعها مع ال Reverse Proxy على شبكة Docker واحدة، وحتى نفهم لماذا يعمل هذا الحل نحتاج أن نفهم شبكات Docker أولاً.
ما هي شبكة Docker؟
شبكة Docker هي شبكة افتراضية Virtual Network ينشئها Docker على السيرفر، ويتصل بها ال Container كما يتصل جهاز بمفتاح شبكة Switch بكابل، حيث يأخذ عنوان IP منها ويستطيع أن يكلم كل من يتصل بنفس الشبكة. وكما يتصل الجهاز الواحد بأكثر من شبكة عبر أكثر من كابل، يتصل ال Container بأكثر من شبكة، فيكون له عنوان في كل واحدة منها.
والأهم من العنوان هو الاسم، ففي الشبكات التي تنشئها أنت (وليس الشبكة الافتراضية كما سيأتي) يعمل داخل Docker سيرفر DNS مدمج Embedded DNS Server على العنوان 127.0.0.11، وهو يحول اسم ال Container أو اسم الخدمة Service إلى عنوانها على هذه الشبكة، أما الأسماء الأخرى مثل example.com فيمررها إلى سيرفرات DNS المضبوطة على السيرفر. وبالتالي لا تحتاج أن تعرف عنوان قاعدة البيانات ولا أن تحفظه في إعدادات التطبيق، فيكفي أن تكتب db، وإذا أعاد Docker إنشاء ال Container بعنوان جديد فالاسم يبقى كما هو.
وعند تثبيت Docker سوف تجد ثلاث شبكات جاهزة، وتعرضها بالأمر التالي:
docker network ls --filter type=builtinوالمخرج سوف يكون كما يلي:
NETWORK ID NAME DRIVER SCOPE
7e53e218a864 bridge bridge local
c9369ab16874 host host local
e09463bc286f none null localولاحظ أن عمود DRIVER هو نوع الشبكة، والاسم شيء آخر، فالشبكة التي اسمها bridge هي الشبكة الافتراضية Default Bridge، ونوعها bridge، وكل شبكة تنشئها أنت بالأمر docker network create نوعها bridge أيضاً ما لم تحدد غيره.
أنواع الشبكات Network Drivers
يوفر Docker عدة أنواع من الشبكات، ولكل نوع مشغل Driver يحدد كيف يتصل ال Container بالسيرفر وبغيره، وفيما يلي كل نوع ومتى يناسبك.
والصورة التالية تبين أين يقف ال Container من السيرفر والشبكة في كل نوع منها:

شبكة bridge: الافتراضية والتي تنشئها أنت
شبكة bridge هي النوع الافتراضي، وهي شبكة خاصة على نفس السيرفر، تتصل بها ال Containers فتكلم بعضها، وتخرج إلى الإنترنت عبر السيرفر بترجمة العناوين NAT، ولا يصل إليها أحد من الخارج إلا عبر منفذ تنشره. ولكن هناك فرق كبير بين الشبكة الافتراضية التي اسمها bridge، وبين شبكة bridge تنشئها أنت User-defined Bridge، والفرق يظهر بالمثال التالي.
سوف نشغل Container اسمه db بدون تحديد أي شبكة، فيتصل تلقائياً بالشبكة الافتراضية، ثم نحاول الوصول إليه باسمه من Container آخر على نفس الشبكة:
docker run -d --name db alpine:3.24 sleep 600
docker run --rm alpine:3.24 ping -c 1 dbوالمخرج سوف يكون كما يلي:
ping: bad address 'db'الآن سوف نقوم بإنشاء شبكة خاصة بنا اسمها demo، ونوصل بها نفس ال Container وهو يعمل، ثم نكرر نفس المحاولة من هذه الشبكة:
docker network create demo
docker network connect demo db
docker run --rm --network demo alpine:3.24 ping -c 1 dbوالمخرج سوف يكون كما يلي:
PING db (172.31.0.2): 56 data bytes
64 bytes from 172.31.0.2: seq=0 ttl=64 time=0.309 ms
--- db ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max = 0.309/0.309/0.309 msوهذا هو الفرق الأول، والسبب أن الشبكة الافتراضية لا تحول الأسماء إلى عناوين، فال Containers عليها تتصل ببعضها بالعنوان فقط، إلا إذا استخدمت الخيار --link الذي يصفه التوثيق بأنه قديم Legacy. وبقية الفروق كما يلي:
- العزل Isolation: كل Container تشغله بدون تحديد شبكة يتصل بالشبكة الافتراضية، وبالتالي تستطيع تطبيقات لا علاقة لها ببعضها أن تتصل ببعضها، أما الشبكة التي تنشئها فلا يتصل بها إلا من تضيفه أنت.
- التوصيل أثناء التشغيل: تستطيع أن توصل Container بشبكة تنشئها أو تفصله عنها وهو يعمل، كما فعلنا بالأمر
docker network connectأعلاه، أما فصله عن الشبكة الافتراضية فيحتاج إلى إيقافه وإعادة إنشائه. - الإعدادات: كل شبكة تنشئها لها إعداداتها الخاصة، أما الشبكة الافتراضية فإعداداتها واحدة لكل ال Containers المتصلة بها.
ولذلك يقول التوثيق صراحة إن الشبكة الافتراضية bridge تفصيل قديم في Docker ولا ينصح باستخدامها في الإنتاج Production. والخبر الجيد أنك إذا كنت تستخدم Compose فأنت لا تستخدمها أصلاً، لأن Compose ينشئ شبكة خاصة لكل مشروع كما سيأتي. احذف ما أنشأناه في هذا المثال:
docker rm -f db
docker network rm demoوالصورة التالية تلخص الفرق بين الشبكتين بالمثال الذي نفذناه:

شبكة host وشبكة none
شبكة host تلغي العزل بين ال Container والسيرفر، فيستخدم ال Container شبكة السيرفر مباشرة، والتطبيق الذي يستمع على المنفذ 80 داخله يستمع على المنفذ 80 في السيرفر نفسه. لذلك لا معنى لنشر المنافذ معها، وإذا كتبت -p أو ports فسوف يتجاهلها Docker ويعرض التحذير Published ports are discarded when using host network mode. وهي تعمل على Docker Engine في Linux، وعلى Docker Desktop من الإصدار 4.34 بعد تفعيلها من الإعدادات. ويحتاجها القليل من التطبيقات، مثل التي تكتشف الأجهزة على شبكتك المنزلية بالبث Broadcast أو التي تتعامل مع عدد كبير جداً من المنافذ، أما باقي التطبيقات فلا تستخدمها معها، والسبب أن ال Container يصبح قادراً على الاستماع على أي منفذ في السيرفر دون أن يظهر ذلك في ملف Compose.
أما شبكة none فهي العكس تماماً، حيث تعزل ال Container عزلاً كاملاً عن السيرفر وعن غيره، فلا يبقى له إلا واجهة loopback، وهي مناسبة لمهمة تعالج ملفات في Volume ولا تحتاج أي اتصال، كأداة تحويل أو ضغط تشغلها مرة واحدة.
شبكة overlay: لأكثر من سيرفر
كل ما سبق يعمل على سيرفر واحد، أما شبكة overlay فتربط عدة سيرفرات عليها Docker في شبكة واحدة، فيكلم ال Container على السيرفر الأول Container على السيرفر الثاني باسمه. وهي تعمل مع Docker Swarm، وهو أداة Docker لإدارة مجموعة سيرفرات Cluster، ولن تحتاجها ما دامت كل خدماتك على سيرفر واحد، وهذا حال أغلب من يستضيف تطبيقاته بنفسه.
شبكة macvlan وشبكة ipvlan: عندما تريد عنواناً من شبكتك المنزلية
في المختبر المنزلي Homelab قد تريد أن يظهر Container على شبكة المنزل كأنه جهاز مستقل، بعنوان مثل 192.168.1.50 يراه ال Router وكل الأجهزة، كما يفعل البعض مع Pi-hole أو Home Assistant. وهذا ما تفعله شبكة macvlan، حيث تعطي ال Container عنوان MAC Address خاصاً به، فيبدو على الشبكة كجهاز حقيقي، أما شبكة ipvlan فتعطيه عنوان IP خاصاً به مع نفس عنوان MAC الخاص بالسيرفر، وهي الخيار عندما يرفض ال Switch أو نقطة الوصول اللاسلكية Access Point أكثر من عنوان MAC على نفس المنفذ.
ولكن لاحظ أمرين قبل أن تختار macvlan: الأول أن ال Container على هذه الشبكة لا يستطيع أن يتصل بالسيرفر نفسه مباشرة، وهذا قيد في نواة Linux Kernel وليس في Docker، والثاني أنك تحتاج إلى حجز نطاق من العناوين لا يوزعه سيرفر DHCP في ال Router حتى لا يتكرر العنوان. لذلك إذا كان التطبيق يعمل خلف Reverse Proxy فشبكة bridge أنسب له، واترك macvlan للتطبيقات التي تحتاج فعلاً أن تكون جهازاً على شبكتك.
المقارنة بين الأنواع
| النوع | ماذا يفعل | متى يناسبك |
|---|---|---|
| الشبكة الافتراضية Default Bridge | شبكة bridge واحدة لكل Container لا تحدد له شبكة، والاتصال فيها بالعنوان فقط | للتجربة السريعة بالأمر docker run، ولا ينصح بها في الإنتاج |
| شبكة bridge تنشئها أنت User-defined Bridge | شبكة خاصة على نفس السيرفر مع DNS بالأسماء وعزل عن غيرها | الخيار الطبيعي لكل تطبيق على سيرفر واحد، وهي ما ينشئه Compose |
| شبكة السيرفر Host | ال Container يستخدم شبكة السيرفر مباشرة دون عزل | تطبيقات قليلة تحتاج البث على الشبكة المحلية أو عدداً كبيراً من المنافذ |
| بدون شبكة None | عزل كامل، لا يوجد إلا loopback | مهام تعالج ملفات ولا تحتاج أي اتصال |
| Overlay | شبكة واحدة تمتد على عدة سيرفرات | Docker Swarm وال Clusters |
| Macvlan | عنوان MAC وعنوان IP من شبكتك الفعلية لكل Container | جهاز يجب أن يظهر على شبكة المنزل أو المكتب بعنوانه، مع قيد عدم الاتصال بالسيرفر نفسه |
| IPvlan | عنوان IP من شبكتك الفعلية مع عنوان MAC الخاص بالسيرفر | نفس حالة macvlan عندما يقيد ال Switch عدد عناوين MAC |
كيف يربط Compose خدمات المشروع ببعضها؟
عندما تنفذ docker compose up ينشئ Compose شبكة bridge اسمها <اسم المشروع>_default ويوصل بها كل خدمات الملف، واسم المشروع Project Name هو اسم المجلد الذي فيه الملف ما لم تحدد غيره بالمفتاح name. لنأخذ مثالاً، وهو ملف المثال الأول بعد أن نحذف منه منفذ قاعدة البيانات، ونقصر منفذ التطبيق على السيرفر نفسه، وذلك في المجلد /opt/shop:
services:
app:
image: traefik/whoami:v1.12.0
ports:
- "127.0.0.1:8080:80"
db:
image: postgres:18.0-alpine
environment:
POSTGRES_PASSWORD: change-mecd /opt/shop
docker compose up -d
docker network ls --filter name=shop
docker compose ps --format 'table {{.Name}}\t{{.Service}}\t{{.Ports}}'والمخرج سوف يكون كما يلي:
NETWORK ID NAME DRIVER SCOPE
ca89b52d3da5 shop_default bridge local
NAME SERVICE PORTS
shop-app-1 app 127.0.0.1:8080->80/tcp
shop-db-1 db 5432/tcpفي المخرج أعلاه لاحظ التالي:
- الشبكة اسمها
shop_default، وأسماء ال Containers تبدأ باسم المشروع أيضاً، وهكذا يميز Docker مشروعاً عن آخر على نفس السيرفر. - المنفذ في سطر
appمكتوب بالصيغة127.0.0.1:8080->80/tcp، أي منشور على السيرفر نفسه فقط. - أما
5432/tcpفي سطرdbفليس منشوراً على السيرفر، وإنما هو المنفذ الذي تعلن عنه ال Image، ولا يصل إليه إلا من يتصل بنفس الشبكة، وغياب السهم->هو ما يدلك على ذلك.
الآن سوف نتأكد أن الخدمتين تتصلان ببعضهما بالاسم، وذلك من Container مؤقت نوصله بشبكة المشروع:
docker run --rm --network shop_default alpine:3.24 sh -c 'ping -c 1 db; ping -c 1 app' | grep 'bytes from'والمخرج سوف يكون كما يلي:
64 bytes from 172.31.0.2: seq=0 ttl=64 time=2.611 ms
64 bytes from 172.31.0.3: seq=0 ttl=64 time=0.416 msوبالتالي يكفي أن تكتب في إعداد التطبيق أن قاعدة البيانات هي db على المنفذ 5432، وهذا ما يشرحه توثيق Compose: الاتصال بين الخدمات يستخدم منفذ ال Container، أما المنفذ المنشور على السيرفر فهو للوصول من خارج الشبكة فقط. ولهذا لم يكن هناك أي داع لنشر المنفذ 5432 في المثال الأول.
الشبكات التي تعرفها بنفسك
الشبكة الافتراضية للمشروع تكفي إذا كانت كل الخدمات يجب أن تكلم بعضها، أما إذا أردت أن تفصل بينها فعرف شبكاتك في المفتاح networks أعلى الملف، ثم حدد لكل خدمة الشبكات التي تتصل بها. وهنا ثلاثة إعدادات سوف نستخدمها في النمط الذي نشرحه بعد قليل:
- الإعداد
external: true: الشبكة موجودة مسبقاً ولا يديرها هذا المشروع، فلا ينشئها Compose ولا يحذفها معdocker compose down، ويتوقف بخطأ إذا لم يجدها. - الإعداد
internal: true: شبكة معزولة عن الخارج، فال Containers عليها تكلم بعضها فقط، ولا تخرج إلى الإنترنت. - الإعداد
name: يعطي الشبكة اسماً ثابتاً لا يضيف إليه Compose اسم المشروع.
أسماء إضافية للخدمة Aliases
اسم الخدمة هو اسمها على الشبكة، ولكن قد تحتاج اسماً آخر، كأن يبحث التطبيق عن قاعدة البيانات باسم postgres واسم الخدمة عندك db، أو تريد اسماً لا يتكرر على شبكة مشتركة كما سيأتي في قسم المشكلات. وهنا يأتي دور aliases، وهي أسماء إضافية للخدمة على شبكة محددة، ولاحظ أن الاسم الإضافي يعمل على الشبكة التي عرفته فيها فقط:
services:
web:
image: traefik/whoami:v1.12.0
networks:
proxy:
aliases:
- blog-web
networks:
proxy:
external: trueكيف ترى الشبكات وما عليها؟
هذه الأوامر سوف تحتاجها كثيراً عند حل المشكلات:
docker network ls: قائمة الشبكات ونوع كل منها.docker network inspect proxy: تفاصيل الشبكة، ومنها النطاق Subnet وال Containers المتصلة بها وعنوان كل منها.docker network connect proxy <container>وdocker network disconnect proxy <container>: توصيل Container بشبكة أو فصله عنها وهو يعمل. ولكن تذكر أن هذا التغيير يضيع عندما يعيد Compose إنشاء ال Container، لذلك استخدمه للتجربة، أما الإعداد الدائم فمكانه ملف Compose.
ومخرج docker network inspect طويل، لذلك نفضل أن نطلب منه ال Containers وعناوينها فقط بالصيغة التالية:
docker network inspect proxy --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'متى تنشر منفذاً ومتى لا تنشره؟
في ملف Compose ثلاث حالات للمنفذ، والفرق بينها هو الفرق بين سيرفر آمن وسيرفر مكشوف:
- النشر على كل العناوين
ports: ["8080:80"]: المنفذ مفتوح للإنترنت، ويتجاوزufwكما ذكرنا، وتوثيق Compose يحذر من ذلك صراحة. ولا تستخدم هذه الصيغة إلا مع المنفذين 80 و443 الخاصين بال Reverse Proxy. - النشر على السيرفر وحده
ports: ["127.0.0.1:8080:80"]: المنفذ متاح من السيرفر نفسه فقط، وتصل إليه من جهازك عبر نفق SSH Tunnel. وهذا مناسب لواجهات الإدارة Admin UIs التي لا تريدها على الإنترنت أبداً، ولاحظ أن الإصدارات الأقدم من Docker 28.0 كانت تسمح للأجهزة على نفس الشبكة المحلية بالوصول إلى هذه المنافذ، وهذا سبب إضافي لتحديث Docker. - عدم النشر إطلاقاً: لا يوجد سطر
ports، وال Container يصل إليه فقط من يتصل بنفس شبكة Docker، وهذا ما نريده لكل تطبيق يعمل خلف Reverse Proxy ولكل قاعدة بيانات.
أما expose فلا ينشر أي شيء على السيرفر، وإنما يعلن أن الخدمة تستمع على منفذ معين، وهو توثيق داخل الملف أكثر منه إعداد، فال Containers على نفس الشبكة تصل إلى أي منفذ يستمع عليه التطبيق سواءً كتبته في expose أو لم تكتبه. لذلك إذا رأيت ملفاً فيه expose: ["3000"] فاعلم أن المنفذ غير منشور، وأن ال Reverse Proxy سوف يصل إليه عبر الشبكة.
الشبكة المشتركة proxy: النمط الذي نستخدمه في الموقع
الآن نجمع كل ما سبق في نمط واحد، والفكرة كما يلي:
- شبكة واحدة اسمها
proxyننشئها مرة واحدة خارج أي مشروع. - ال Reverse Proxy (NPM أو Traefik أو Caddy) هو الوحيد الذي ينشر منافذ على كل العناوين، وهي 80 و443 فقط.
- كل تطبيق يخدمه ال Reverse Proxy يتصل بشبكة
proxy، ولا ينشر أي منفذ. - قاعدة بيانات كل تطبيق على شبكة داخلية خاصة بمشروعه، ولا تتصل بشبكة
proxyأبداً. - واجهات الإدارة على
127.0.0.1، أو خلف ال Reverse Proxy بقائمة وصول Access List تسمح بشبكة ال VPN فقط.
والصورة التالية تبين هذا النمط على سيرفر فيه مشروعان:

وقد يتساءل البعض: لماذا شبكة مشتركة يدوية، ولماذا لا يتصل ال Reverse Proxy بشبكة _default الخاصة بكل مشروع؟ والإجابة أن هذا ممكن، ولكن كل تطبيق جديد سوف يحتاج حينها إلى تعديل ملف ال Reverse Proxy وإعادة إنشائه، وسوف يصبح ال Reverse Proxy متصلاً بقواعد بيانات كل المشاريع، أما الشبكة المشتركة فتنشئها مرة واحدة، ويتصل بها كل تطبيق جديد من ملفه هو دون أن تمس ال Reverse Proxy.
إنشاء الشبكة وال Reverse Proxy
أنشئ الشبكة مرة واحدة على السيرفر:
docker network create proxyوسوف نستخدم في المثال Caddy لأن ملف إعداده قصير، والنمط نفسه ينطبق على Nginx Proxy Manager وTraefik كما في دليل كل منهما، وتجد تثبيت Caddy بالتفصيل في دليل Caddy. والملف /opt/proxy/compose.yaml سوف يكون كما يلي:
services:
caddy:
image: caddy:2.11.4
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- proxy
volumes:
caddy_data:
caddy_config:
networks:
proxy:
external: trueوالملف /opt/proxy/Caddyfile:
notes.example.com {
reverse_proxy notes:80
}في الإعداد أعلاه لاحظ التالي:
- المفتاح
proxyتحتnetworksأعلى الملف معexternal: trueيعني أن Compose يستخدم الشبكة التي أنشأناها كما هي، ولا يضيف إلى اسمها اسم المشروع. - السطر
reverse_proxy notes:80يستخدم اسم الخدمةnotesومنفذها داخل ال Container، وليس أي منفذ على السيرفر، وفي NPM تكتب نفس الاسم في حقل Forward Hostname. - المنفذان 80 و443 هما الوحيدان المنشوران على كل العناوين في السيرفر كله.
التطبيق وقاعدة بياناته
سوف نستخدم whoami مكان تطبيقك، فهو تطبيق صغير يعرض عناوينه وتفاصيل الطلب الذي وصله، ومعه قاعدة بيانات PostgreSQL. والملف /opt/notes/compose.yaml سوف يكون كما يلي:
services:
notes:
image: traefik/whoami:v1.12.0
restart: unless-stopped
networks:
- proxy
- backend
db:
image: postgres:18.0-alpine
restart: unless-stopped
environment:
POSTGRES_PASSWORD: change-me
networks:
- backend
networks:
proxy:
external: true
backend:
internal: trueفي الإعداد أعلاه لاحظ التالي:
- لا يوجد سطر
portsفي الملف كله. - التطبيق
notesمتصل بشبكتين:proxyحتى يصل إليه Caddy، وbackendحتى يصل هو إلى قاعدة البيانات. - قاعدة البيانات
dbعلى شبكةbackendفقط، وهذه الشبكةinternal: true، فلا يصل إليها Caddy ولا أي تطبيق آخر على السيرفر، ولا تخرج هي إلى الإنترنت. - الشبكة
backendليستexternal، لذلك ينشئها Compose باسمnotes_backendويحذفها معdocker compose down، وكل مشروع آخر يستطيع أن يسمي شبكتهbackendدون أي تعارض. - كلمة المرور هنا للمثال فقط، أما في الإنتاج فضعها في ملف
.envوولدها بالأمرopenssl rand -hex 32.
الآن شغل المشروعين، ثم اعرض ما على كل شبكة:
cd /opt/proxy && docker compose up -d
cd /opt/notes && docker compose up -d
docker network inspect proxy --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'
docker network inspect notes_backend --format 'internal={{.Internal}} {{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'والمخرج سوف يكون كما يلي:
notes-notes-1 172.31.0.3/16
caddy 172.31.0.2/16
internal=true notes-notes-1 192.168.16.2/20
notes-db-1 192.168.16.3/20ولاحظ أن قاعدة البيانات لا تظهر على شبكة proxy أبداً، وأن التطبيق له عنوانان، واحد في كل شبكة.
لنتأكد أن العزل يعمل فعلاً
أولاً: هل يصل Caddy إلى التطبيق باسمه؟ نختبر ذلك من داخل Container ال Reverse Proxy نفسه، لأن هذا هو الطريق الذي سوف يسلكه كل طلب:
docker exec caddy wget -qO- http://notes/ | head -n 5والمخرج سوف يكون كما يلي:
Hostname: 5ee6b2628a71
IP: 127.0.0.1
IP: ::1
IP: 192.168.16.2
IP: 172.31.0.3ثانياً: هل يصل Caddy إلى قاعدة البيانات؟ يجب ألا يصل، لأنه ليس على شبكة backend:
docker exec caddy ping -c 1 -W 2 dbping: bad address 'db'ثالثاً: هل تخرج قاعدة البيانات إلى الإنترنت؟ يجب ألا تخرج، لأن شبكتها داخلية:
cd /opt/notes
docker compose exec db wget -q -T 5 -O /dev/null https://example.com
docker compose exec db ip routeوالمخرج سوف يكون كما يلي:
wget: bad address 'example.com'
192.168.16.0/20 dev eth0 scope link src 192.168.16.3ولاحظ أن جدول التوجيه Routing Table في قاعدة البيانات فيه سطر واحد لشبكتها، ولا يوجد فيه مخرج افتراضي default via، وهذا معنى internal: true. وهذه الطبقة تحميك في حالتين: إذا تسرب منفذ قاعدة البيانات بالخطأ فلا يوجد له طريق من الخارج، وإذا اخترق أحد قاعدة البيانات فلا يستطيع أن يرسل البيانات منها إلى الإنترنت مباشرة.
internal إذا كان يحتاج الإنترنت، مثل إرسال البريد أو جلب التحديثات أو تسجيل الدخول عبر Google، والسبب أن التطبيق في هذا النمط يخرج إلى الإنترنت عبر شبكة proxy، أما شبكة backend فهي لقاعدة البيانات وأمثالها، مثل Redis، التي لا تحتاج إلا أن يكلمها التطبيق.ماذا لو نسيت إنشاء الشبكة؟
على سيرفر جديد، أو بعد استعادة نسخة احتياطية، قد تنسى الأمر docker network create proxy، وعندها يتوقف docker compose up بالخطأ التالي:
network proxy declared as external, but could not be foundوالسبب أن external: true يعني أن Compose لا ينشئ هذه الشبكة أبداً، والحل أن تنشئها ثم تعيد الأمر، لذلك ضع سطر إنشائها في أول ملاحظات السيرفر أو في سكربت التجهيز، فهو أول ما تحتاجه عند النقل إلى سيرفر جديد.
وماذا عن واجهات الإدارة؟
واجهات مثل Portainer ولوحة NPM نفسها لها طريقان آمنان، الأول أن تنشرها على 127.0.0.1 وتصل إليها بنفق SSH، والثاني أن تضعها على شبكة proxy بدون نشر أي منفذ، ثم تجعل ال Reverse Proxy يقبل طلباتها من شبكة ال VPN فقط، كما في دليل WireGuard. أما نشرها على كل العناوين ثم الاعتماد على كلمة المرور وحدها فلا تقم به، والسبب أن من يتحكم في Portainer يتحكم في السيرفر كله.
التحقق من النجاح
- الأمر
docker ps --format '{{.Names}}\t{{.Ports}}'لا يعرض أي منفذ منشور على0.0.0.0أو[::]إلا 80 و443 الخاصين بال Reverse Proxy، وكل ما سواهما على127.0.0.1أو بدون نشر. - الأمر
sudo ss -ltnpعلى السيرفر يؤكد نفس النتيجة من جهة نظام التشغيل. - الأمر
docker network inspect proxyيعرض ال Reverse Proxy والتطبيقات فقط، ولا يعرض أي قاعدة بيانات. - من Container ال Reverse Proxy يعمل
wget -qO- http://<service>/لكل تطبيق، ويفشلping db. - من جهاز خارج السيرفر لا يستجيب أي منفذ غير 80 و443، وتستطيع التأكد بالأمر
nc -zv 203.0.113.10 5432مثلاً.
مشكلات شائعة وحلولها
خطأ 502 أو 504 من ال Reverse Proxy
أشهر سبب لهذا الخطأ أن التطبيق ليس على شبكة proxy، كأن تنسى إضافتها إلى ملفه، أو تعيد إنشاءه من ملف قديم. ولنكسر الإعداد عمداً حتى نرى الخطأ، حيث سوف نفصل التطبيق عن الشبكة ثم نحاول الوصول إليه من Caddy:
docker network disconnect proxy notes-notes-1
docker exec caddy wget -qO- http://notes/wget: bad address 'notes'وفي المتصفح سوف يظهر 502 Bad Gateway، وفي سجل Caddy سوف تجد السبب، وهو أن الاسم notes لم يعد له عنوان، وتنتهي الرسالة بعبارة i/o timeout أو no such host بحسب سيرفر DNS على جهازك:
docker logs caddy 2>&1 | grep -o '"msg":"dial tcp[^"]*"' | tail -n 1"msg":"dial tcp: lookup notes: i/o timeout"والحل أن تعيد التطبيق إلى الشبكة، والأفضل أن تصحح ملفه ثم تنفذ docker compose up -d، وأسباب 502 الأخرى هي منفذ خاطئ (المنفذ المنشور على السيرفر بدلاً من منفذ ال Container)، أو تطبيق يستمع على 127.0.0.1 داخل ال Container الخاص به بدلاً من 0.0.0.0، وتجد تفصيلها في دليل NPM.
أما الخطأ 504 Gateway Timeout فيظهر غالباً مع Traefik عندما يتصل التطبيق بشبكتين، حيث قد يأخذ Traefik عنوان التطبيق من الشبكة الأخرى التي لا يتصل بها هو، فينتظر حتى تنتهي المهلة Timeout. والحل أن تحدد الشبكة في إعداد مزود Docker بالخيار providers.docker.network: proxy كما في دليل Traefik، أو لكل تطبيق بالوسم traefik.docker.network=proxy.
تعارض الأسماء على الشبكة المشتركة
اسم الخدمة على شبكة المشروع الخاصة لا يتعارض مع غيره، ولكن على شبكة proxy المشتركة تلتقي خدمات كل المشاريع، فإذا كان لديك مشروعان blog وshop وفي كل منهما خدمة اسمها web متصلة بشبكة proxy، فسوف يعيد DNS الخاص بالشبكة العنوانين معاً:
docker exec caddy nslookup webServer: 127.0.0.11
Address: 127.0.0.11:53
Non-authoritative answer:
Name: web
Address: 172.31.0.5
Name: web
Address: 172.31.0.4وبالتالي يذهب الطلب مرة إلى المدونة ومرة إلى المتجر، وهي من أغرب المشكلات حين تواجهها أول مرة، لأن الموقع يعمل ثم يتغير محتواه عند تحديث الصفحة. والحل أن يكون الاسم الذي يستخدمه ال Reverse Proxy فريداً على شبكة proxy، وذلك بأحد طريقين: أن تسمي الخدمة باسم التطبيق نفسه (notes وليس app أو web)، أو أن تضيف لها اسماً فريداً بال aliases كما شرحنا أعلاه، ثم تستخدم blog-web في إعداد ال Reverse Proxy. ولنفس السبب لا تضع قاعدة البيانات على شبكة proxy، فلو فعلت ذلك في مشروعين لأصبح الاسم db يشير إلى قاعدتين مختلفتين.
ال Container على شبكتين والمخرج الافتراضي خاطئ
ال Container المتصل بشبكتين له مخرج افتراضي Default Gateway واحد، ومنه تخرج كل حركة لا تتجه إلى إحدى الشبكتين، والتوثيق يقول إن Docker هو من يختار هذا المخرج، وقد يتغير اختياره كلما تغيرت شبكات ال Container. وفي أغلب التطبيقات لا يهمك من أين تخرج الحركة، ولكن في تطبيق مثل wg-easy الذي يمرر حركة عملاء ال VPN إلى الإنترنت فالأمر مهم، حيث تنجح المصافحة ولا يعمل الإنترنت داخل النفق، كما شرحنا في دليل WireGuard. والحل خياران في Compose: gw_priority (من Compose 2.33.1) لتحديد الشبكة التي يكون منها المخرج الافتراضي، وinterface_name (من Compose 2.36.0) لتثبيت اسم واجهة الشبكة Network Interface لكل شبكة:
services:
vpn:
image: alpine:3.24
command: sleep infinity
networks:
wg:
interface_name: eth0
gw_priority: 1
proxy:
interface_name: eth1
networks:
proxy:
external: true
wg:
ipam:
config:
- subnet: 10.42.42.0/24docker compose exec vpn ip routeوالمخرج سوف يكون كما يلي:
default via 10.42.42.1 dev eth0
10.42.42.0/24 dev eth0 scope link src 10.42.42.2
172.31.0.0/16 dev eth1 scope link src 172.31.0.3ولاحظ أن المخرج الافتراضي عبر eth0 وهي شبكة wg، أما شبكة proxy فلها سطر واحد على eth1 للوصول إلى ال Containers عليها فقط. والقيمة الافتراضية ل gw_priority هي 0، والشبكة صاحبة القيمة الأعلى هي المخرج، لذلك يكفي أن تعطي الشبكة التي تريدها القيمة 1.
نفاد نطاقات العناوين Address Pools
كل شبكة تنشئها، وكل مشروع Compose بشبكته الافتراضية، يأخذ نطاقاً من العناوين، والنطاقات الافتراضية في Docker هي 172.17.0.0/16 حتى 172.31.0.0/16 بحجم /16 لكل شبكة، ثم 192.168.0.0/16 مقسمة إلى شبكات بحجم /20، أي نحو 31 شبكة فقط. فإذا كان على سيرفرك مشاريع كثيرة، وكل مشروع ينشئ شبكتين أو ثلاثاً، فسوف تنفد النطاقات ويفشل إنشاء أي شبكة جديدة بالخطأ all predefined address pools have been fully subnetted.
وهناك مشكلة أخرى في نفس النطاقات تخص المختبر المنزلي وال VPN، وهي أن Docker قد يأخذ نطاقاً مثل 192.168.0.0/20 وشبكة منزلك أو مكتبك على 192.168.1.0/24، فيصبح السيرفر غير قادر على الرد على الأجهزة في هذه الشبكة لأنه يظن أنها داخل Docker. والحل في الحالتين أن تحدد النطاقات بنفسك في /etc/docker/daemon.json بالمفتاح default-address-pools، وبشبكات أصغر بحجم /24 يتسع النطاق الواحد /16 ل 256 شبكة:
sudo tee /etc/docker/daemon.json <<'EOF'
{
"log-driver": "local",
"log-opts": {
"max-size": "20m",
"max-file": "5"
},
"live-restore": true,
"default-address-pools": [
{ "base": "172.17.0.0/16", "size": 24 },
{ "base": "172.18.0.0/16", "size": 24 }
]
}
EOF
sudo dockerd --validate --config-file /etc/docker/daemon.json
sudo systemctl restart dockerفي الإعداد أعلاه لاحظ التالي:
- الملف يكمل إعداد السجلات و
live-restoreمن دليل التثبيت، فإذا كان لديك ملف مختلف فأضف المفتاحdefault-address-poolsإليه ولا تستبدله. - الحجم
24يعني أن كل شبكة تأخذ 254 عنواناً، وهذا يكفي أي مشروع على سيرفر واحد. - اختر نطاقات لا تتداخل مع شبكة المنزل أو المكتب أو ال VPN التي يصل منها المستخدمون.
- الإعداد الجديد يسري على الشبكات الجديدة فقط، أما الموجودة فتبقى على نطاقها حتى تحذفها وتنشئها من جديد، أي
docker compose downثمup -dلكل مشروع، وأعد إنشاءproxyبعد إيقاف كل من يستخدمها.
ملاحظات IPv6
شبكات bridge التي تنشئها لا تحصل على عناوين IPv6 إلا إذا فعلت ذلك بالخيار --ipv6 أو enable_ipv6: true في Compose، وهذا يكفي في أغلب الحالات، لأن ال Reverse Proxy هو من يستقبل الزائر. ولكن انتبه لحالة واحدة: إذا كان لنطاقك سجل AAAA وشبكة ال Reverse Proxy بدون IPv6، فإن Docker يمرر طلبات IPv6 عبر ال userland-proxy إلى عنوان IPv4 الخاص بال Container، وبالتالي يرى ال Reverse Proxy كل زوار IPv6 قادمين من عنوان داخلي واحد، فتضيع عناوينهم الحقيقية في السجلات وفي قوائم الوصول. والحل إما أن تفعل IPv6 في شبكة proxy نفسها، وإما ألا تضيف سجل AAAA حتى تفعله، وتجد أثر ذلك على التطبيقات في دليل تطبيقك خلف Reverse Proxy.
وماذا عن HAProxy أو Reverse Proxy خارج Docker؟
إذا كان ال Reverse Proxy مثبتاً على السيرفر نفسه وليس في Container، كما في طريقة التثبيت من حزمة Ubuntu التي يذكرها دليل HAProxy بديلاً عن ال Container، أو كان يعمل على سيرفر آخر يوزع الطلبات على عدة سيرفرات، فلا يستطيع أن يستخدم أسماء ال Containers، والسبب أن DNS المدمج يعمل داخل شبكات Docker فقط، وفي هذه الحالة انشر منفذ كل تطبيق على 127.0.0.1 فقط واجعل ال Reverse Proxy يتصل به هناك. وإذا كنت تقارن بين هذه الأدوات فراجع المقارنة بين Nginx Proxy Manager وTraefik وHAProxy.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- المنفذ المنشور بدون عنوان مفتوح على الإنترنت ويتجاوز
ufw، فلا تنشر على كل العناوين إلا 80 و443 الخاصين بال Reverse Proxy. - الشبكة الافتراضية
bridgeلا توفر أسماء ولا عزلاً، أما الشبكة التي تنشئها أنت، ومنها شبكة كل مشروع Compose، فتوفر الاثنين. - أنشئ شبكة
proxyمرة واحدة، وصل بها ال Reverse Proxy والتطبيقات فقط بالإعدادexternal: true، دون أي منفذ منشور للتطبيقات. - ضع قاعدة بيانات كل مشروع على شبكة خاصة به بالإعداد
internal: true، ولا تصلها بشبكةproxyأبداً. - اجعل الاسم الذي يستخدمه ال Reverse Proxy فريداً على الشبكة المشتركة، وثبت المخرج الافتراضي ب
gw_priorityعندما يهمك من أين تخرج الحركة. - حدد
default-address-poolsبنطاقات لا تتداخل مع شبكاتك قبل أن تكثر المشاريع على السيرفر.
سجل التحديثات
- أكتوبر 2026: كتابة الدليل واختبار أمثلته على Docker Engine 29.8.0 وDocker Compose v5.5.1.