Skip to content

ডকার দিয়ে ওয়ার্ডপ্রেস সেলফ-হোস্টিং: Apache নাকি PHP-FPM, সঙ্গে Redis অবজেক্ট ক্যাশ

ডকারে ওয়ার্ডপ্রেস নিয়ে বেশিরভাগ টিউটোরিয়াল "উঠে গেছে" পর্যন্ত এসেই থেমে যায়। এই লেখাটা আরেকটু দূর যায়: ভার্সন পিন করা, কম্পাইল করা PHP এক্সটেনশন দিয়ে সত্যিকারের অবজেক্ট ক্যাশ, এমন রিভার্স প্রক্সি যেটা সত্যিই TLS টার্মিনেট করে, আর অফিসিয়াল ইমেজের সেই দু-তিনটি আচরণ, যেগুলো প্রথম দিনে নয়, সমস্যায় ফেলে ত্রিশ দিনের মাথায়।

২ আগস্ট ২০২৬-এ যখন দেখেছি, তখন এগুলোই ছিল চলতি ভার্সন। পিন করে নিন। :latest দিয়ে প্রোডাকশনে যাবেন না।

উপাদানট্যাগমন্তব্য
WordPresswordpress:7.0.2-php8.5-apacheঅথবা 7.0.2-php8.5-fpm-alpine
PHP8.5ওয়ার্ডপ্রেস ইমেজের ভেতরেই আছে
MariaDBmariadb:12.3lts এখন 12.3.2-তে গিয়ে দাঁড়ায়
Redisredis:8.10-alpineশুধু সার্ভার, ডিস্কে কিছু রাখে না
nginxnginx:1.30.4-alpineকেবল কনটেইনার হিসেবে চালালে
Redis Object Cache2.8.0প্লাগইন, সঙ্গে Predis 2.4.0 আসে
phpredis6.3.0ঐচ্ছিক, ধাপ ৩ দেখুন
WP-CLIwordpress:cli-php8.5সাইডকার, দরকার হলে চালাবেন

ওয়ার্ডপ্রেস 7.1 আসছে ১৯ আগস্ট ২০২৬-এ, কাজেই ট্যাগের 7.0.2 অংশটা শিগগিরই বদলাবে ধরে নিন।

স্ট্যাক

কাজটা করে তিনটা কনটেইনার। বাইরে থেকে একটিমাত্র পোর্ট ছাড়া আর কিছুতেই হাত দেওয়া যায় না, আর সেই পোর্টটাও খোলা থাকে শুধু লুপব্যাকে।

   [ nginx, terminating TLS ]
              |
              v
      127.0.0.1:8080
              |
   ===========|=================== docker
              v
        [ wp_app ]  wordpress + php
              |
              |  service names on wp_default
     +--------+--------+
     |                 |
 [ wp_db ]      [ wp_redis ]
   :3306             :6379

ডেটাবেস আর Redis কোনো পোর্টই খোলে না। অন্য কনটেইনার থেকে সার্ভিসের নাম ধরে, অর্থাৎ db আর redis দিয়ে এদের পাওয়া যায়। আর কোথাও থেকে নয়।

দুটি সিদ্ধান্ত

PHP চলবে কীভাবে। এক কনটেইনারে mod_php সহ Apache, নাকি PHP-FPM। Apache মানে একটি ইমেজ, একটি প্রসেস, বাড়তি কনফিগ ফাইল নেই। FPM দেয় আলাদা একটি ওয়েব সার্ভার, যেটা PHP-তে না ছুঁয়েই স্ট্যাটিক ফাইল সার্ভ করতে পারে। বিনিময়ে যোগ হয় একটি nginx কনফিগ, যেটির দেখাশোনা এখন আপনার দায়িত্ব।

nginx চলবে কোথায়। হোস্টে, নাকি চার নম্বর কনটেইনার হিসেবে।

                     edge = nginx on the host      edge = nginx in compose
  A) apache image    publish 127.0.0.1:8080        proxy_pass wordpress:80
                     proxy_pass to it              4 containers
                     3 containers  <-- start here

  B) fpm image       publish 127.0.0.1:9000        fastcgi_pass wordpress:9000
                     fastcgi_pass to it            4 containers
                     3 containers

হোস্টের nginx দিয়ে A দিয়েই শুরু করুন। এতে জিনিসপত্র সবচেয়ে কম, ভাঙার জায়গাও কম, আর সার্টিফিকেট নবায়নের কাজটা হোস্টের certbot নিজেই সেরে রাখে, আপনার মনে রাখতে হয় না। প্রতি লোকেশনে আলাদা ক্যাশিং বা রেট লিমিট দরকার হলে, কিংবা স্ট্যাটিক ফাইল এত বেশি সার্ভ করতে হলে যে সেগুলো PHP-র বাইরে রাখাটাই লাভজনক, তখন B-তে যাবেন।

১ থেকে ৫ নম্বর ধাপ সব ক্ষেত্রেই এক।

যা যা লাগবে

Compose v2 প্লাগইনসহ Docker Engine 27 বা তার পরের ভার্সন। লাগবে এটুকুই।

প্রজেক্টটা এমন জায়গায় রাখুন যেখানে nginx ঢুকতে পারে। হোস্টের nginx যদি সরাসরি ./data/wordpress পড়ে (পথ B), তাহলে /root/wp-তে রেখে লাভ নেই, যত chmod-ই করুন কাজ হবে না, কারণ /root-এর পারমিশনই 700/srv/wp বা /opt/wp ব্যবহার করুন।

bash
sudo mkdir -p /srv/wp && cd /srv/wp

ধাপ ১: কাঠামো

/srv/wp/
├─ .env                  # secrets and sizing
├─ compose.yaml
├─ Dockerfile            # adds phpredis
├─ config/
│  ├─ php.ini            # PHP + opcache overrides
│  └─ nginx.conf         # only if nginx runs as a container
├─ backup/
└─ data/
   ├─ db/                # MariaDB datadir
   └─ wordpress/         # core + wp-content
bash
mkdir -p config backup data/db data/wordpress

Redis-এর জন্য আলাদা ডেটা ডিরেক্টরি নেই। কেন, সেটি ৪ নম্বর ধাপে।

ধাপ ২: .env

bash
# Database
MARIADB_ROOT_PASSWORD=change_me_root
MARIADB_DATABASE=wordpress
MARIADB_USER=wordpress
MARIADB_PASSWORD=change_me_user
WORDPRESS_TABLE_PREFIX=wp_

# Where the stack listens on the host (loopback only)
HTTP_BIND=127.0.0.1:8080

# WordPress memory
WP_MEMORY_LIMIT=256M
WP_MAX_MEMORY_LIMIT=512M

# Redis
REDIS_MAXMEMORY=256mb
WP_REDIS_PREFIX=wp

পাসওয়ার্ড নিজে টাইপ না করে জেনারেট করে নিন:

bash
openssl rand -base64 24

এরপর chmod 600 .env দিন আর ফাইলটা .gitignore-এ ঢুকিয়ে দিন।

দ্বিতীয় একটি ওয়ার্ডপ্রেস সাইট চালু করার দিন থেকেই WP_REDIS_PREFIX-এর দরকার পড়ে। প্রিফিক্স ছাড়া একই Redis দুই সাইটে ভাগ করলে একে অন্যের ক্যাশ কী পড়তে শুরু করে। তখন সাইটে যা ঘটতে থাকে, তার কারণ খুঁজে বের করা রীতিমতো কঠিন হয়ে পড়ে।

ধাপ ৩: Redis ক্লায়েন্ট বেছে নেওয়া

এই ধাপটা বাদ দিলেও চলে। রাখলাম, কারণ PHP দুইভাবে Redis-এর সঙ্গে কথা বলতে পারে, আর অনেক গাইডেই কঠিন পথটাকে বাধ্যতামূলক বলে চালিয়ে দেওয়া হয়।

অফিসিয়াল ওয়ার্ডপ্রেস ইমেজে bcmath, exif, gd, intl, mysqli, zip আর imagick দেওয়া থাকে। Redis এক্সটেনশন থাকে না। তাতে অসুবিধা নেই। Redis Object Cache প্লাগইনের সঙ্গেই Predis আসে, খাঁটি PHP-তে লেখা একটি Redis ক্লায়েন্ট, আর এক্সটেনশন না পেলে প্লাগইন নিজে থেকেই সেটি ধরে নেয়।

                     phpredis (PECL)        Predis (bundled)
  client language    C extension            PHP
  in stock image     no, must build         yes, ships with plugin
  custom Dockerfile  required               not needed
  rebuild on every   yes                    no
    upstream update
  plugin support     yes                    yes, the default fallback

ইমেজটা যেমন আছে তেমনই রাখুন। আগে ক্যাশটা চালু করুন, তারপর প্লাগইনের Metrics ট্যাব খুলে দেখুন প্রতি রিকোয়েস্টে কত মিলিসেকেন্ড যাচ্ছে। ওই সংখ্যাটি নিয়ে খটকা লাগলে তখন ফিরে এসে এক্সটেনশন কম্পাইল করবেন। সাধারণ পরিচিতিমূলক সাইটে পার্থক্য টেরই পাবেন না। কিন্তু চল্লিশটা প্লাগইনওয়ালা WooCommerce, যেখানে প্রতি পেজে কয়েকশো wp_cache_get কল হয়, সেখানে পাবেন।

নিজের ইমেজ বানানোর খরচ Dockerfile লেখা নয়। খরচ হলো, এখন থেকে ওয়ার্ডপ্রেস বা PHP-র প্রতিটা সিকিউরিটি প্যাচের পর আপনাকেই আবার বিল্ড দিতে হবে। কী কিনছেন জেনে কিনুন।

লাগলে compose.yaml-এর পাশে একটি Dockerfile রাখুন।

Apache (ডেবিয়ান বেস, PHPIZE_DEPS আগে থেকেই আছে):

dockerfile
FROM wordpress:7.0.2-php8.5-apache

RUN set -eux; \
    pecl install redis-6.3.0; \
    docker-php-ext-enable redis; \
    rm -rf /tmp/pear

Alpine-এ FPM (বিল্ড টুল আগে থেকে থাকে না, তাই যোগ করে কাজ শেষে ফেলে দিতে হয়):

dockerfile
FROM wordpress:7.0.2-php8.5-fpm-alpine

RUN set -eux; \
    apk add --no-cache --virtual .build-deps $PHPIZE_DEPS; \
    pecl install redis-6.3.0; \
    docker-php-ext-enable redis; \
    apk del .build-deps; \
    rm -rf /tmp/pear

তারপর পরের ধাপে image:-এর জায়গায় build: বসিয়ে দিন। প্লাগইন এক্সটেনশনটা নিজেই খুঁজে নেয়। চাইলে define('WP_REDIS_CLIENT', 'phpredis'); বা 'predis' দিয়ে ঠিক করেও দিতে পারেন।

ধাপ ৪: config/php.ini

ini
upload_max_filesize = 64M
post_max_size = 64M
memory_limit = 256M
max_execution_time = 300
max_input_time = 300
max_input_vars = 3000

; the base image sets 4000 files and revalidate_freq=2,
; both too tight for a plugin-heavy production site
opcache.memory_consumption = 192
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.revalidate_freq = 60

অনেকে এগুলো এনভায়রনমেন্ট ভেরিয়েবল হিসেবে পাঠিয়ে কনটেইনারের command বদলে ini ফাইলে লিখে নেয়। ওই পথে যাবেন না। command বদলানো মানে এখন আপনাকে এক লাইনের একটি শেল কমান্ড মেইনটেইন করতে হবে, যেটা শেষ হতেই হবে docker-entrypoint.sh apache2-foreground দিয়ে। ওখানে একটি টাইপো হলেই সেই এন্ট্রিপয়েন্টটা হারাবেন যেটা ওয়ার্ডপ্রেস খুলে বসায় আর wp-config.php লেখে। তার চেয়ে ফাইল মাউন্ট করা এক লাইনের YAML, আর প্রতিটা ইমেজ আপগ্রেডেও টিকে থাকে।

মাউন্টের নামে zz- থাকায় PHP ফাইলটা সবার শেষে পড়ে, ফলে ইমেজের নিজের opcache-recommended.ini-কে টপকে এটিই কার্যকর হয়।

ধাপ ৫: compose.yaml

এটি পথ A: Apache, লুপব্যাকে খোলা, সামনে হোস্টের nginx প্রক্সি হিসেবে বসবে।

yaml
name: wp

services:
  db:
    image: mariadb:12.3
    container_name: wp_db
    restart: unless-stopped
    environment:
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:?missing in .env}
      MARIADB_DATABASE: ${MARIADB_DATABASE:-wordpress}
      MARIADB_USER: ${MARIADB_USER:-wordpress}
      MARIADB_PASSWORD: ${MARIADB_PASSWORD:?missing in .env}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - ./data/db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 12
      start_period: 60s

  redis:
    image: redis:8.10-alpine
    container_name: wp_redis
    restart: unless-stopped
    command: >
      redis-server
      --save ""
      --appendonly no
      --maxmemory ${REDIS_MAXMEMORY:-256mb}
      --maxmemory-policy allkeys-lru
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5

  wordpress:
    image: wordpress:7.0.2-php8.5-apache
    # only if you compiled phpredis in step 3:
    # build:
    #   context: .
    #   dockerfile: Dockerfile
    # image: wp-wordpress:local
    container_name: wp_app
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    ports:
      - "${HTTP_BIND:-127.0.0.1:8080}:80"
    environment:
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_NAME: ${MARIADB_DATABASE:-wordpress}
      WORDPRESS_DB_USER: ${MARIADB_USER:-wordpress}
      WORDPRESS_DB_PASSWORD: ${MARIADB_PASSWORD:?missing in .env}
      WORDPRESS_TABLE_PREFIX: ${WORDPRESS_TABLE_PREFIX:-wp_}
      WORDPRESS_CONFIG_EXTRA: |
        define('WP_REDIS_HOST', 'redis');
        define('WP_REDIS_PORT', 6379);
        define('WP_REDIS_PREFIX', '${WP_REDIS_PREFIX:-wp}');
        define('WP_REDIS_DATABASE', 0);
        define('WP_REDIS_TIMEOUT', 1);
        define('WP_REDIS_READ_TIMEOUT', 1);
        define('WP_CACHE', true);
        define('WP_MEMORY_LIMIT', '${WP_MEMORY_LIMIT:-256M}');
        define('WP_MAX_MEMORY_LIMIT', '${WP_MAX_MEMORY_LIMIT:-512M}');
        define('DISALLOW_FILE_EDIT', true);
        define('FS_METHOD', 'direct');
    volumes:
      - ./data/wordpress:/var/www/html
      - ./config/php.ini:/usr/local/etc/php/conf.d/zz-wordpress.ini:ro

  wpcli:
    image: wordpress:cli-php8.5
    profiles: [tools]
    user: "33:33"
    depends_on:
      db:
        condition: service_healthy
    environment:
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_NAME: ${MARIADB_DATABASE:-wordpress}
      WORDPRESS_DB_USER: ${MARIADB_USER:-wordpress}
      WORDPRESS_DB_PASSWORD: ${MARIADB_PASSWORD:?missing in .env}
      WORDPRESS_TABLE_PREFIX: ${WORDPRESS_TABLE_PREFIX:-wp_}
    volumes:
      - ./data/wordpress:/var/www/html

networks: ব্লক নেই। Compose নিজেই সব সার্ভিসকে wp_default নামের একটি নেটওয়ার্কে বসায় আর প্রত্যেককে সার্ভিসের নামেই একটি DNS নাম দেয়। এ কারণেই WORDPRESS_DB_HOST হলো db:3306। এখানে হাতে নেটওয়ার্ক লিখে বাড়তি কিছু পাওয়া যায় না।

পাঁচটা জিনিস আলাদা করে বলে রাখি।

বাইন্ড অ্যাড্রেসটা ঐচ্ছিক নয়। শুধু "8080:80" লিখলে পোর্টটা সব ইন্টারফেসে খুলে যায়। Docker আবার আপনার নিয়মের আগেই নিজের iptables নিয়ম বসিয়ে রাখে, ফলে ufw নির্দ্বিধায় জানাবে ৮০৮০ বন্ধ আছে, অথচ পুরো ইন্টারনেট তখন আপনার ওয়ার্ডপ্রেস সাদা HTTP-তে পড়ছে। ঠিক করার উপায় 127.0.0.1:8080:80

${VAR:?message} কোনো ভেরিয়েবল না পেলে আপনার লেখা বার্তা দেখিয়ে docker compose up থামিয়ে দেয়। এটি না থাকলে .env-এ একটি টাইপোর ফল হয় ফাঁকা পাসওয়ার্ডের ডেটাবেস।

Redis-এর কোনো ভলিউম নেই, --save "", --appendonly no। অবজেক্ট ক্যাশের ডেটা তো মূল ডেটা নয়, বানিয়ে নেওয়া। ডিস্কে রাখলে রিস্টার্টের পর ক্যাশটা গরম পাবেন, বদলে সারাজীবন fork করে ডিস্কে লেখার খরচ দিতে হবে। ঠান্ডা ক্যাশ কয়েক সেকেন্ডেই ভরে যায়। পরে যদি সেশন বা কিউয়ের জন্য Redis ব্যবহার করেন, তখন হিসাবটা বদলাবে।

--maxmemory-policy allkeys-lru অপশনটাই আসল। Redis-এর ডিফল্ট noeviction, অর্থাৎ ক্যাশ ভরে গেলে পুরনো কী চুপচাপ ফেলে না দিয়ে সে লেখাই বন্ধ করে দেয়, আর ওয়ার্ডপ্রেস তখন ক্যাশ এরর দিতে থাকে।

হেলথচেক চলে ১০ সেকেন্ড পরপর, ১২০ সেকেন্ড নয়। depends_on: service_healthy একটি চেক পাস করার অপেক্ষায় বসে থাকে, তাই ইন্টারভাল বড় রাখলে স্টার্টআপও তত ধীর হয়। দু-মিনিট ধরে স্ট্যাক ভাঙা দেখানোর মধ্যে কোনো কৃতিত্ব নেই।

পুরনো কোনো কনফিগ থেকে db_data ইত্যাদি ঘোষণা করা volumes: ব্লক টেনে এনে থাকলে সেটি মুছে দিন। ./data/db বাইন্ড-মাউন্ট করলে ওই নাম দেওয়া ভলিউমগুলো এমনিতেই অব্যবহৃত পড়ে থাকে।

ধাপ ৬: হোস্টে nginx

nginx আর certbot ইনস্টল করুন, তারপর একটি vhost বসিয়ে দিন:

bash
sudo apt install nginx certbot python3-certbot-nginx
nginx
# /etc/nginx/sites-available/example.com
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    client_max_body_size 64m;    # keep in sync with post_max_size

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        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_set_header X-Forwarded-Host  $host;
        proxy_read_timeout 300s;
    }
}
bash
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d example.com -d www.example.com

আসল হেডারটা হলো X-Forwarded-Proto। এটি দেখেই ওয়ার্ডপ্রেস ঠিক করে URL-এ http:// লিখবে না https://, আর অফিসিয়াল ইমেজের wp-config আগে থেকেই এটি পড়ে (দেখুন ধাপ ৮)। হেডারটা বাদ দিলে হয় মিক্সড-কনটেন্টের সতর্কবার্তা পাবেন, নয়তো রিডাইরেক্টের চক্করে পড়বেন।

"৩০ মেগাবাইটের ভিডিসেটি ৯০% গিয়ে আটকে যায়" এই সমস্যার সবচেয়ে বড় কারণ client_max_body_size আর post_max_size-এর গরমিল। PHP জিনিসটা দেখার আগেই nginx রিকোয়েস্ট বডি ফিরিয়ে দেয়, তাই শুধু PHP-র লিমিট বাড়িয়ে কোনো লাভ হয় না।

Certbot ফাইলটা নিজে থেকে বদলে 443 ব্লক আর একটি রিডাইরেক্ট যোগ করে দেয়, সঙ্গে নবায়নের টাইমারও বসায়। systemctl list-timers | grep certbot দিয়ে দেখে নিন।

ধাপ ৬, পথ B: PHP-FPM

কম্পোজ ফাইলে দুটি বদল। বিল্ডটা FPM-এর Dockerfile-এ তাক করান, আর HTTP-র বদলে FastCGI খুলুন:

yaml
  wordpress:
    image: wordpress:7.0.2-php8.5-fpm-alpine
    # or build: from the fpm-alpine Dockerfile in step 3
    container_name: wp_app
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    ports:
      - "127.0.0.1:9000:9000"         # FastCGI, loopback only, no exceptions
    environment:
      # unchanged from route A
      ...
    volumes:
      - ./data/wordpress:/var/www/html
      - ./config/php.ini:/usr/local/etc/php/conf.d/zz-wordpress.ini:ro

৯০০০ পোর্ট HTTP বলে না, বলে FastCGI। FastCGI-তে না আছে কোনো লগইন, না TLS, আর যে-ই ওখানে পৌঁছাতে পারে সে যা খুশি PHP চালাতে পারে। কখনো যদি দেখেন লুপব্যাক ছাড়াই 9000:9000 লিখে ফেলছেন, তখনই থেমে যান।

এবার স্ট্যাটিক ফাইলগুলো হোস্টের nginx নিজেই সার্ভ করবে, কনটেইনারে যাবে শুধু PHP:

nginx
# /etc/nginx/sites-available/example.com
server {
    listen 80;
    server_name example.com;

    root /srv/wp/data/wordpress;      # the HOST path
    index index.php;
    client_max_body_size 64m;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        try_files $uri =404;
        include fastcgi_params;
        fastcgi_pass 127.0.0.1:9000;

        # the container sees these files at /var/www/html, not at the host path
        fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT   /var/www/html;
        fastcgi_param PATH_INFO       $fastcgi_path_info;

        fastcgi_read_timeout 300;
        fastcgi_buffers 16 16k;
        fastcgi_buffer_size 32k;
    }

    location ~* \.(js|css|png|jpe?g|gif|ico|svg|webp|avif|woff2?)$ {
        expires 30d;
        add_header Cache-Control "public";
        access_log off;
        try_files $uri =404;
    }

    # never execute PHP that arrived through the media uploader
    location ^~ /wp-content/uploads/ {
        location ~ \.php$ { deny all; }
    }

    location = /favicon.ico { log_not_found off; access_log off; }
    location = /robots.txt  { log_not_found off; access_log off; allow all; }
    location ~ /\.          { deny all; }
    location = /xmlrpc.php  { deny all; }   # drop this line if you use the WP mobile app
}

ওই দুটি fastcgi_param ওভাররাইডই পুরো খেলাটা, আর এগুলো বাদ পড়ে বলেই বেশিরভাগ মানুষ এই সেটআপ মাঝপথে ছেড়ে দেয়। nginx SCRIPT_FILENAME হিসাব করে নিজের root থেকে, অর্থাৎ /srv/wp/data/wordpress ধরে। PHP-FPM তখন কনটেইনারের ভেতরে ওই পথটা খুঁজতে যায়, সেখানে তো নেই, তাই ফেরত দেয় "Primary script unknown", নয়তো একেবারে ফাঁকা সাদা পেজ, আর nginx-এর লগেও কাজের কিছু থাকে না। ফাইল দুটিই আসলে এক, শুধু দুই জায়গায় দুই পথে বসানো, আর কোনটা কোনটার সঙ্গে মেলে সেটি একমাত্র আপনিই জানেন।

এই পথ নিয়ে ছোট দুটি কথা। PHP লোকেশনের ভেতরে try_files $uri =404 দিলে path-info দিয়ে কোড চালানোর সেই পুরনো ফাঁকটা বন্ধ হয়, যেটা ২০১৩ সালের কনফিগগুলো এখনো বয়ে বেড়াচ্ছে। আর এখানে HTTPS ধরার কায়দাটা আলাদা: রিভার্স প্রক্সিই নেই, তাই X-Forwarded-Proto-ও নেই। certbot TLS ব্লক বসিয়ে দিলে ডেবিয়ানের fastcgi_params সরাসরি HTTPS=on পাঠায়, ওয়ার্ডপ্রেস সেটি নিজেই বুঝে নেয়।

ধাপ ৬, বিকল্প: nginx কনটেইনার হিসেবে

হোস্টে nginx নেই, আর সবকিছু ডকারেই রাখতে চান, তাহলে এই পথ। চার নম্বর একটি সার্ভিস যোগ করুন আর wordpress থেকে পোর্ট খোলা বন্ধ করুন:

yaml
  nginx:
    image: nginx:1.30.4-alpine
    container_name: wp_nginx
    restart: unless-stopped
    depends_on: [wordpress]
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./data/wordpress:/var/www/html:ro   # only needed for the fpm variant
      - ./config/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./config/certs:/etc/nginx/certs:ro  # if you have certs already

Apache ইমেজ হলে config/nginx.conf হবে ধাপ ৬-এর প্রক্সি কনফিগটাই, শুধু 127.0.0.1:8080-এর জায়গায় proxy_pass http://wordpress:80;। FPM ইমেজ হলে হবে FastCGI কনফিগ, সঙ্গে fastcgi_pass wordpress:9000; আর root /var/www/html;। পথ নতুন করে মেলানোর দরকার নেই, কারণ ডকারের ভেতরে দুই কনটেইনারের কাছেই পথটা এক।

TLS নিয়ে এখানে ধোঁয়াশা রাখবেন না। কনটেইনারে nginx তখনই মানায়, যখন সার্টিফিকেট আগে থেকেই ডিস্কে আছে, কিংবা সাইটটা অফিসের ভেতরের আর সাদা HTTP-তেই চলে, কিংবা সামনে অন্য কিছু আপনার হয়ে TLS টার্মিনেট করছে। এই কনটেইনারের ভেতরে Let's Encrypt-এর অটো নবায়ন চালাতে গেলে লাগবে একটি certbot সাইডকার, শেয়ার করা ওয়েবরুট আর একটি রিলোড হুক। এতটা ঝামেলায় যেতে না চাইলে হোস্টের nginx ব্যবহার করুন, নয়তো এই সার্ভিসের বদলে Caddy বা Traefik নিন, ওরা ACME নিজেরাই সামলায়।

ধাপ ৭: প্রথম চালু

bash
docker compose up -d
docker compose logs -f wordpress

ধাপ ৩-এ নিজের ইমেজ বেছে থাকলে তবেই আগে docker compose build চালাবেন।

খেয়াল রাখবেন দুটি লাইনের দিকে:

WordPress not found in /var/www/html - copying now...
No 'wp-config.php' found in /var/www/html, but 'WORDPRESS_...' variables supplied; copying ...

চালু হওয়ার ধাপগুলো মোটামুটি এভাবে এগোয়:

t=0s    db starting        health: starting
t~20s   db healthy         healthcheck.sh --connect passes
t~20s   redis healthy
t~21s   wordpress starts   unpack core -> generate wp-config.php

nginx-কে টানার আগে দেখে নিন স্ট্যাকটা লুপব্যাকে সাড়া দিচ্ছে কি না। কোন দিকটা গোলমাল করছে, সেটি ধরার এটিই দ্রুততম উপায়:

bash
curl -I http://127.0.0.1:8080          # route A
# HTTP/1.1 302 Found
# Location: http://127.0.0.1:8080/wp-admin/install.php

এরপর ডোমেইনটা খুলে পাঁচ মিনিটের ইনস্টলেশনটা সেরে ফেলুন।

ধাপ ৮: ইমেজ wp-config.php-তে কী লেখে

দরকার পড়ার আগেই তিনটি কথা জেনে রাখা ভালো।

সল্ট একবারই তৈরি হয়। ফাইলটা যখন প্রথম বানানো হয়, তখনই /dev/urandom থেকে auth কী আর সল্ট এসে হুবহু ফাইলে বসে যায়। প্রতিবার চালু হওয়ার সময় এগুলো এনভায়রনমেন্ট থেকে পড়া হয় না। নিজের হাতে রাখতে চাইলে প্রথম চালু করার আগেই WORDPRESS_AUTH_KEY, WORDPRESS_SECURE_AUTH_KEY, WORDPRESS_LOGGED_IN_KEY, WORDPRESS_NONCE_KEY, WORDPRESS_AUTH_SALT, WORDPRESS_SECURE_AUTH_SALT, WORDPRESS_LOGGED_IN_SALT আর WORDPRESS_NONCE_SALT বসিয়ে নিন। পরে এগুলো বদলালে সবাই লগআউট হয়ে যায়, আর সাইট হ্যাক হওয়ার পর ঠিক এটিই আপনার দরকার।

বাকি সব সরাসরি পড়া হয়। WORDPRESS_CONFIG_EXTRA আর WORDPRESS_DB_* ভেরিয়েবলগুলো প্রতি রিকোয়েস্টেই getenv_docker() নামের একটি হেল্পারের ভেতর দিয়ে যায়, আর বাড়তি ব্লকটা চলে eval() দিয়ে। তাই WORDPRESS_CONFIG_EXTRA বদলে docker compose up -d দিলেই সঙ্গে সঙ্গে কাজ হয়। wp-config.php মুছতে হয় না।

রিভার্স প্রক্সির ব্যবস্থাটা আগে থেকেই করা আছে। নতুন করে যোগ করতে হবে না:

php
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strpos($_SERVER['HTTP_X_FORWARDED_PROTO'], 'https') !== false) {
    $_SERVER['HTTPS'] = 'on';
}

তারপরও যদি HTTPS রিডাইরেক্ট থামতেই না চায়, তাহলে গোলমালটা সামনের দিকে: nginx হেডারটাই পাঠাচ্ছে না।

ধাপ ৯: অবজেক্ট ক্যাশ চালু করা

ইমেজ যেমন আছে তেমন রেখেই, তিনটা কমান্ড:

bash
docker compose run --rm wpcli plugin install redis-cache --activate
docker compose run --rm wpcli redis enable
docker compose run --rm wpcli redis status

কোন ক্লায়েন্ট বেছে নিয়েছে সেটি redis status বলে দেয়:

Status:      Connected
Client:      Predis (v2.4.0)
Drop-in:     Valid

Client: PhpRedis (v6.3.0) মানে এক্সটেনশনটা আছে এবং কাজেও লাগছে। দুটির যেকোনো একটি দেখালেই বুঝবেন ক্যাশ চলছে। এক্সটেনশনটা আলাদা করে দেখতে চাইলে docker compose exec wordpress php -m | grep -x redis চালান। ইমেজ যেমন আছে তেমন থাকলে এটি কিছুই দেখাবে না, সেটিই স্বাভাবিক।

redis enable দিলে wp-content/object-cache.php ফাইলটা জায়গামতো বসে যায়। পুরো ব্যাপারটাই ওই ফাইলটির উপর দাঁড়িয়ে আছে। সেটি না থাকলে WP_CACHE আর WP_REDIS_* ধ্রুবকগুলো কিছুই করে না। শুধু প্লাগইন ইনস্টল করে চালু করে দিলেই হয় না, ড্রপ-ইন ফাইলটা থাকতেই হবে। এ কারণেই wp-content-এ লেখা না গেলে ক্যাশটা চুপচাপ বসে থাকে, কোনো এররও দেয় না।

wpcli সার্ভিসের uid-টা শুধু সাজিয়ে রাখার জন্য নয়। CLI ইমেজটা Alpine-এর উপরে, সেখানে www-data-র uid ৮২। আর Apache-ওয়ালা ওয়ার্ডপ্রেস ইমেজ ডেবিয়ানের উপরে, সেখানে uid ৩৩। ভুল uid দিয়ে WP-CLI চালালে সে যত ফাইল লিখবে, PHP তার একটিও পড়তে পারবে না। নিজের বেস ইমেজ দেখে মিলিয়ে নিন:

wordpress:...-apache      Debian    www-data = 33   -> user: "33:33"
wordpress:...-fpm         Debian    www-data = 33   -> user: "33:33"
wordpress:...-fpm-alpine  Alpine    www-data = 82   -> user: "82:82"

Redis-এ কী জমছে কি না দেখে নিন, তারপর সাইটটা কয়েকবার লোড করে সংখ্যাগুলো বাড়তে দেখুন:

bash
docker compose exec redis redis-cli info keyspace
# db0:keys=412,expires=7,avg_ttl=0

docker compose exec redis redis-cli info stats | grep keyspace
# keyspace_hits:18342
# keyspace_misses:611

কাজ শুরু করলে একটি রিকোয়েস্ট কোন পথে যায়:

  request
     |
     v
 [ nginx ] -> [ apache | php-fpm ]
                  |
                  v
          [ PHP: wp_cache_get ]
                  |
                  +-- HIT  --> render from Redis, ~2 SQL queries
                  |
                  +-- MISS --> MariaDB --> wp_cache_set --> render

একদিন ট্রাফিক পাওয়ার পরও হিট রেট ৯০ শতাংশের নিচে থাকলে সেটি সাধারণত ওয়ার্ডপ্রেসের দোষ নয়, ক্যাশ থেকে কী বাদ পড়ে যাওয়ার সমস্যা। দেখুন used_memory maxmemory-তে গিয়ে আটকে আছে কি না, থাকলে REDIS_MAXMEMORY বাড়িয়ে দিন।

অবজেক্ট ক্যাশ আর পেজ ক্যাশ এক জিনিস নয়। এটি প্রতিটা রিকোয়েস্ট থেকে বারবার হওয়া ডেটাবেস কোয়েরিগুলো সরিয়ে দেয়, লগইন করা ব্যবহারকারী আর অ্যাডমিন প্যানেলের রিকোয়েস্টসহ। কিন্তু ওয়ার্ডপ্রেসকে PHP চালু করা থেকে ঠেকাতে পারে না। পুরো পেজ ক্যাশ করার কাজটি nginx বা আলাদা প্লাগইনের, আর সেটি সম্পূর্ণ আলাদা সিদ্ধান্ত।

ধাপ ১০: ব্যাকআপ

MariaDB চালু রেখে ডেটাডিরে tar চালাবেন না। আর্কাইভ বানানোর সময়ও ./data/db-তে লেখালেখি চলছে, ফলে যে কপিটা ফেরত পাবেন সেটি ঠিকঠাক না-ও হতে পারে। ডেটাবেস ডাম্প করুন, আর ফাইলগুলো আলাদা করে আর্কাইভ করুন।

bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
source .env
stamp=$(date +%Y%m%d-%H%M)

docker compose exec -T db \
  mariadb-dump --single-transaction --quick --no-tablespaces \
  -u root -p"$MARIADB_ROOT_PASSWORD" "$MARIADB_DATABASE" \
  | gzip > "backup/db-$stamp.sql.gz"

tar -czf "backup/files-$stamp.tar.gz" -C data/wordpress wp-content

find backup -name '*.gz' -mtime +14 -delete

--single-transaction দিলে InnoDB-তে কাউকে আটকে না রেখেই একটি নির্ভরযোগ্য স্ন্যাপশট পাওয়া যায়। MariaDB 11 থেকে কমান্ডটার নাম mariadb-dumpmysqldump এখনো সিমলিংক হিসেবে আছে, তবে চিরকাল থাকবে না।

ফিরিয়ে আনতে:

bash
gunzip -c backup/db-20260802-1130.sql.gz | \
  docker compose exec -T db mariadb -u root -p"$MARIADB_ROOT_PASSWORD" wordpress

আর্কাইভ করার মতো জিনিস আসলে শুধু wp-content। কোর ফাইল তো ইমেজ থেকেই আসে, আর সল্টগুলো বাদ দিলে wp-config.php কম্পোজ ফাইল দেখেই আবার বানিয়ে নেওয়া যায়। সল্টগুলোর ব্যাকআপ একবার নিয়ে রেখে দিন।

ধাপ ১১: হালনাগাদ

এই জায়গাটিতেই বেশিরভাগ মানুষ ধাক্কা খান। এন্ট্রিপয়েন্টের শর্তটা একবার দেখুন:

bash
if [ ! -e index.php ] && [ ! -e wp-includes/version.php ]; then
    echo >&2 "WordPress not found in $PWD - copying now..."

ইমেজ থেকে কোর ফাইল কপি হয় শুধু তখনই, যখন ডিরেক্টরিটা ফাঁকা./data/wordpress-এ একবার ইনস্টল বসে গেলে wordpress:7.1-php8.5-apache টানলে নতুন PHP বিল্ড, নতুন Apache আর নতুন এক্সটেনশন পাবেন ঠিকই, কিন্তু ওয়ার্ডপ্রেসের কোর ফাইলগুলো যেখানে ছিল সেখানেই পড়ে থাকবে।

তাই আপডেট আসলে দুই ভাগে ভাগ হয়ে যায়:

Image tag  -> PHP, Apache/FPM, extensions, base OS
             docker compose pull && docker compose up -d
             (add `docker compose build --pull` if you use the custom image)

WP core    -> dashboard, or:
             docker compose run --rm wpcli core update
             docker compose run --rm wpcli core update-db
             docker compose run --rm wpcli plugin update --all

MariaDB-র বড় ভার্সন বদলাতে MARIADB_AUTO_UPGRADE: "1" লাগে, উপরের কম্পোজ ফাইলে সেটি দেওয়াই আছে। পুরনো ডেটাডির পেলে এটি নিজেই mariadb-upgrade চালিয়ে নেয়। তবু আগে একটি ডাম্প নিয়ে রাখবেন, আর একবারে এক ভার্সন করে এগোবেন।

সমস্যা সমাধান

উপসর্গসম্ভাব্য কারণ
প্রথম চালুতেই "Error establishing a database connection"আগের কোনো দফায় ./data/db অন্য পাসওয়ার্ড দিয়ে তৈরি হয়েছিল। MariaDB ইমেজ কেবল ফাঁকা ডেটাডিরেই ক্রেডেনশিয়াল বসায়। হয় সেটি মুছে ফেলুন, নয়তো হাতে পাসওয়ার্ড রিসেট করুন
হোস্টের nginx থেকে 502 Bad Gateway127.0.0.1:8080-এ কিছুই শুনছে না। docker compose ps আর ss -ltnp | grep 8080 দেখুন
"Primary script unknown", পথ BSCRIPT_FILENAME এখনো হোস্টের পথ দেখাচ্ছে। এটিকে /var/www/html$fastcgi_script_name করে দিন
স্ট্যাটিক ফাইলে nginx থেকে 403, পথ Bnginx ./data/wordpress পর্যন্ত যেতে পারছে না। 700 অনুমতির প্যারেন্ট ডিরেক্টরি থেকে প্রজেক্টটি সরান
সাইট লোড হয় কিন্তু সব URL http://nginx X-Forwarded-Proto বসাচ্ছে না
অসীম HTTPS রিডাইরেক্টউপরেরটির মতোই কারণ
আপলোড সীমা এখনো 2M দেখাচ্ছেini মাউন্ট হয়নি, নয়তো /usr/local/etc/php/conf.d/-এর বাইরে মাউন্ট হয়েছে। docker compose exec wordpress php -i | grep upload_max দেখুন
আপলোড মাঝপথে থেমে যায়nginx-এর client_max_body_size PHP-র post_max_size-এর চেয়ে ছোট
অবজেক্ট ক্যাশ চালুর পর সাদা পর্দাপুরনো wp-content/object-cache.php। সেটি মুছে আবার wpcli redis enable চালান
প্লাগইন সক্রিয়, কিন্তু Redis-এ কিছুই নেইড্রপ-ইনটি কখনো লেখাই হয়নি। redis status বলবে ড্রপ-ইন বৈধ নয়। দেখুন wp-content-এ লেখা যায় কি না
redis status বলছে "Not connected"WP_REDIS_HOST ভুল, নয়তো কনটেইনারগুলো আলাদা কম্পোজ প্রজেক্টে আছে
নিজস্ব ইমেজ বানিয়েছেন তবু redis status এখনো Predis বলছেকনটেইনারটি এখনো পুরনো ইমেজেই চলছে। docker compose up -d --force-recreate wordpress
প্লাগইন ইনস্টলে FTP ক্রেডেনশিয়াল চাইছেFS_METHOD direct-এ সেট করা নেই, নয়তো PHP ইউজার wp-content-এ লিখতে পারছে না
নতুন ইমেজ টেনেছেন, ওয়ার্ডপ্রেসের সংস্করণ বদলায়নিএটিই প্রত্যাশিত। WP-CLI বা ড্যাশবোর্ড দিয়ে কোর হালনাগাদ করুন
ক্যাশ হিট রেট ৬০ শতাংশের আশেপাশে আটকে আছেRedis maxmemory-তে পৌঁছে গিয়ে কী সরাচ্ছে। REDIS_MAXMEMORY বাড়ান

এরপর কী করবেন

ধাপ ১০-এর ব্যাকআপ স্ক্রিপ্টটা ক্রনে বসিয়ে দিন, আর দরকার পড়ার আগেই একটি আলাদা ডিরেক্টরিতে রিস্টোর করে দেখে নিন কাজ করে কি না। তারপর মেপে দেখুন: Query Monitor ইনস্টল করে অবজেক্ট ক্যাশ বন্ধ রেখে সবচেয়ে ধীর পেজটার কোয়েরি সংখ্যা লিখে রাখুন, এরপর ক্যাশ চালু করে আবার দেখুন। এতক্ষণের পরিশ্রম কাজে লেগেছে কি না, তার একমাত্র প্রমাণ ওই সংখ্যাটাই।

এরপরের কাজটা হলো, সার্ভারের অবস্থা খারাপ হলে কোনো ভিজিটরের কাছ থেকে জানার আগেই নিজে টের পাওয়া। Prometheus, Node Exporter আর Grafana ঠিক এই ধরনের একটি সার্ভারেই সেই ব্যবস্থাটা করে দেয়। শুধু Node Exporter ড্যাশবোর্ড দেখেই বুঝে যাবেন Redis-এর মাপটা আসলে ঠিক আছে কি না।

সেলফ-হোস্টিং নিয়েই আমার বেশিরভাগ কাজ। গত দশ বছরে ক্লায়েন্টদের জন্য ৩০টিরও বেশি ওপেন সোর্স প্ল্যাটফর্ম বসিয়েছি আর চালিয়েছি। নিজে এসব ঝামেলায় যেতে না চাইলে একবার কথা বলুন