{"id":1549,"date":"2025-11-08T19:05:46","date_gmt":"2025-11-08T16:05:46","guid":{"rendered":"https:\/\/www.dchost.com\/blog\/my-no%e2%80%91drama-playbook-hosting-wordpress-on-a-vps-with-docker-nginx-mariadb-redis-and-lets-encrypt-with-docker%e2%80%91compose-persistent-volumes\/"},"modified":"2025-11-08T19:05:46","modified_gmt":"2025-11-08T16:05:46","slug":"my-no%e2%80%91drama-playbook-hosting-wordpress-on-a-vps-with-docker-nginx-mariadb-redis-and-lets-encrypt-with-docker%e2%80%91compose-persistent-volumes","status":"publish","type":"post","link":"https:\/\/www.dchost.com\/blog\/en\/my-no%e2%80%91drama-playbook-hosting-wordpress-on-a-vps-with-docker-nginx-mariadb-redis-and-lets-encrypt-with-docker%e2%80%91compose-persistent-volumes\/","title":{"rendered":"My No\u2011Drama Playbook: Hosting WordPress on a VPS with Docker, Nginx, MariaDB, Redis, and Let\u2019s Encrypt (with docker\u2011compose + Persistent Volumes)"},"content":{"rendered":"<div class=\"dchost-blog-content-wrapper\"><p>So there I was again, staring at a sluggish WordPress site on a tiny <a href=\"https:\/\/www.dchost.com\/vps\">VPS<\/a>, thinking, there has to be a cleaner way to run this without it turning into a weekend project every time something needs updating. I\u2019d done the classic LAMP installs, the one\u2011off tweaks, the frantic plugin audits after a spike in CPU. Fun in a nostalgic way, sure, but what I really wanted was a setup I could bring up from scratch in minutes, keep tidy with versioned configs, and scale up without changing the whole machine. That\u2019s when Docker + docker\u2011compose became my quiet sidekick.<\/p>\n<p>Ever had that moment when updates feel risky? Like, \u201cIf I change this one PHP setting, will I break the site?\u201d Or, \u201cIf I move servers, what gets left behind?\u201d The thing I love about containerizing WordPress is that it gives you a simple promise: bake your logic into compose files, keep your data on persistent volumes, and keep Nginx, MariaDB, and Redis in their lanes. When you do that, patches feel routine, migrations are less scary, and SSL renewals stop waking you up at 2 a.m.<\/p>\n<p>In this post, I\u2019ll walk you through the whole playbook: the why behind this architecture, how I lay out docker\u2011compose with Nginx, PHP\u2011FPM, MariaDB, Redis, and Let\u2019s Encrypt, how I keep data safe with persistent volumes, and a few performance and security touches that reduce drama. Think of it like us sitting down with coffee, opening a terminal, and building something solid together.<\/p>\n<div id=\"toc_container\" class=\"toc_transparent no_bullets\"><p class=\"toc_title\">\u0130&ccedil;indekiler<\/p><ul class=\"toc_list\"><li><a href=\"#Why_Docker_on_a_VPS_for_WordPress\"><span class=\"toc_number toc_depth_1\">1<\/span> Why Docker on a VPS for WordPress?<\/a><\/li><li><a href=\"#The_little_architecture_that_could_Nginx_PHPFPM_MariaDB_Redis\"><span class=\"toc_number toc_depth_1\">2<\/span> The little architecture that could: Nginx, PHP\u2011FPM, MariaDB, Redis<\/a><\/li><li><a href=\"#Prereqs_and_quick_prep_on_the_VPS\"><span class=\"toc_number toc_depth_1\">3<\/span> Prereqs and quick prep on the VPS<\/a><ul><li><a href=\"#Domain_DNS_and_ports\"><span class=\"toc_number toc_depth_2\">3.1<\/span> Domain, DNS, and ports<\/a><\/li><li><a href=\"#Install_Docker_and_dockercompose_plugin\"><span class=\"toc_number toc_depth_2\">3.2<\/span> Install Docker and docker\u2011compose plugin<\/a><\/li><\/ul><\/li><li><a href=\"#Folder_layout_and_dockercomposeyml\"><span class=\"toc_number toc_depth_1\">4<\/span> Folder layout and docker\u2011compose.yml<\/a><ul><li><a href=\"#dockercomposeyml\"><span class=\"toc_number toc_depth_2\">4.1<\/span> docker\u2011compose.yml<\/a><\/li><\/ul><\/li><li><a href=\"#Nginx_config_and_first_run\"><span class=\"toc_number toc_depth_1\">5<\/span> Nginx config and first run<\/a><ul><li><a href=\"#Nginx_server_block\"><span class=\"toc_number toc_depth_2\">5.1<\/span> Nginx server block<\/a><\/li><li><a href=\"#Issue_the_first_TLS_cert\"><span class=\"toc_number toc_depth_2\">5.2<\/span> Issue the first TLS cert<\/a><\/li><\/ul><\/li><li><a href=\"#Persistent_volumes_where_your_data_really_lives\"><span class=\"toc_number toc_depth_1\">6<\/span> Persistent volumes: where your data really lives<\/a><ul><li><a href=\"#Backups_you_actually_trust\"><span class=\"toc_number toc_depth_2\">6.1<\/span> Backups you actually trust<\/a><\/li><\/ul><\/li><li><a href=\"#Redis_Object_Cache_and_performance_niceties\"><span class=\"toc_number toc_depth_1\">7<\/span> Redis Object Cache and performance niceties<\/a><ul><li><a href=\"#Uploads_timeouts_and_other_qualityoflife_tweaks\"><span class=\"toc_number toc_depth_2\">7.1<\/span> Uploads, timeouts, and other quality\u2011of\u2011life tweaks<\/a><\/li><\/ul><\/li><li><a href=\"#Security_hygiene_without_drama\"><span class=\"toc_number toc_depth_1\">8<\/span> Security hygiene without drama<\/a><\/li><li><a href=\"#Updates_without_breaking_your_weekend\"><span class=\"toc_number toc_depth_1\">9<\/span> Updates without breaking your weekend<\/a><\/li><li><a href=\"#Monitoring_and_troubleshooting_like_a_calm_pro\"><span class=\"toc_number toc_depth_1\">10<\/span> Monitoring and troubleshooting like a calm pro<\/a><\/li><li><a href=\"#Extra_niceties_I_reach_for\"><span class=\"toc_number toc_depth_1\">11<\/span> Extra niceties I reach for<\/a><\/li><li><a href=\"#Stepbystep_first_install_recap\"><span class=\"toc_number toc_depth_1\">12<\/span> Step\u2011by\u2011step first install recap<\/a><\/li><li><a href=\"#Troubleshooting_the_top_three_gotchas\"><span class=\"toc_number toc_depth_1\">13<\/span> Troubleshooting the top three gotchas<\/a><ul><li><a href=\"#1_Certbot_cant_validate_the_domain\"><span class=\"toc_number toc_depth_2\">13.1<\/span> 1) Certbot can\u2019t validate the domain<\/a><\/li><li><a href=\"#2_WordPress_installer_cant_connect_to_the_database\"><span class=\"toc_number toc_depth_2\">13.2<\/span> 2) WordPress installer can\u2019t connect to the database<\/a><\/li><li><a href=\"#3_Redis_plugin_says_object_cache_not_running\"><span class=\"toc_number toc_depth_2\">13.3<\/span> 3) Redis plugin says \u201cobject cache not running\u201d<\/a><\/li><\/ul><\/li><li><a href=\"#Wrapup_a_calm_stack_you_can_trust\"><span class=\"toc_number toc_depth_1\">14<\/span> Wrap\u2011up: a calm stack you can trust<\/a><\/li><\/ul><\/div>\n<h2 id=\"section-1\"><span id=\"Why_Docker_on_a_VPS_for_WordPress\">Why Docker on a VPS for WordPress?<\/span><\/h2>\n<p>I\u2019ll start with a quick story. One of my clients had a classic \u201cpets, not cattle\u201d VPS: everything hand\u2011tuned over two years, and nobody dared touch PHP or Nginx configs because the last time someone did, the homepage 500\u2019d. When we moved them to Docker with compose, the first big win was psychological: configs lived in version control, the runtime was predictable, and every container had one job. If we changed PHP settings, we changed the PHP\u2011FPM container. If we tightened Nginx, we touched only Nginx. That clarity matters.<\/p>\n<p>There are a few quiet advantages here. First, portability: if you ever switch VPS providers, you copy your compose files and your volumes and you\u2019re home by dinner. Second, separation of concerns: WordPress code stays with PHP\u2011FPM, database state stays in MariaDB\u2019s volume, cache logic stays in Redis, and TLS material stays where Nginx expects it. Third, speed of recovery: want to test a new Nginx config? Spin up a staging stack on a different port in seconds without colliding with the main site.<\/p>\n<p>Could you do this in a traditional setup? Of course. But here\u2019s the thing: containers bake repeatability into your day\u2011to\u2011day. Changes become explicit. Backups become a policy, not a hope. And when you use persistent volumes well, the \u201cstateless vs. stateful\u201d boundary lines up with how you think about risk.<\/p>\n<h2 id=\"section-2\"><span id=\"The_little_architecture_that_could_Nginx_PHPFPM_MariaDB_Redis\">The little architecture that could: Nginx, PHP\u2011FPM, MariaDB, Redis<\/span><\/h2>\n<p>Let\u2019s get the lay of the land. Nginx is our front door and traffic cop. It terminates TLS, serves static files, and forwards PHP requests upstream to the WordPress container running PHP\u2011FPM. MariaDB stores your posts, pages, settings\u2014basically the soul of your site. Redis keeps hot objects in memory so WordPress doesn\u2019t hit the database on every page load. And Let\u2019s Encrypt gives us free SSL, renewed automatically, because no one wants to be that person with the expired cert banner.<\/p>\n<p>Think of it like a cozy caf\u00e9. Nginx is the barista, greeting requests and directing them quickly. PHP\u2011FPM is the kitchen where WordPress lives, assembling pages on demand. MariaDB is the pantry with everything stored neatly. Redis is the heat lamp\u2014keeping frequently requested bits warm so they\u2019re ready fast. And Let\u2019s Encrypt is the lock and alarm on the front door. When the roles are this clear, debugging is less of a guessing game.<\/p>\n<p>In my experience, the biggest trap is letting logs and config sprawl across the host. With compose, we\u2019ll keep configs in an easy folder structure, mount them into containers, and put data in named volumes. That way, your repo tracks \u201chow it runs,\u201d and your volumes store \u201cwhat it knows.\u201d<\/p>\n<h2 id=\"section-3\"><span id=\"Prereqs_and_quick_prep_on_the_VPS\">Prereqs and quick prep on the VPS<\/span><\/h2>\n<h3><span id=\"Domain_DNS_and_ports\">Domain, DNS, and ports<\/span><\/h3>\n<p>Before you touch Docker, make sure your domain\u2019s A (and AAAA if you\u2019re using IPv6) records point to the VPS. Only ports 80 and 443 need to be publicly exposed. Everything else can live on Docker\u2019s internal network. If you use a cloud firewall or ufw, allow those two and keep SSH locked down.<\/p>\n<h3><span id=\"Install_Docker_and_dockercompose_plugin\">Install Docker and docker\u2011compose plugin<\/span><\/h3>\n<p>Most modern distros have up\u2011to\u2011date packages, or you can use Docker\u2019s official repos. If you\u2019re new to compose, the official <a href=\"https:\/\/docs.docker.com\/compose\/\" rel=\"nofollow noopener\" target=\"_blank\">docker\u2011compose docs<\/a> are tidy and worth a skim.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\"># Ubuntu example\nsudo apt update\nsudo apt install -y ca-certificates curl gnupg lsb-release\nsudo install -m 0755 -d \/etc\/apt\/keyrings\ncurl -fsSL https:\/\/download.docker.com\/linux\/ubuntu\/gpg | sudo gpg --dearmor -o \/etc\/apt\/keyrings\/docker.gpg\n\necho \n  &quot;deb [arch=$(dpkg --print-architecture) signed-by=\/etc\/apt\/keyrings\/docker.gpg] \n  https:\/\/download.docker.com\/linux\/ubuntu $(lsb_release -cs) stable&quot; | \n  sudo tee \/etc\/apt\/sources.list.d\/docker.list &gt; \/dev\/null\n\nsudo apt update\nsudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin\n\nsudo usermod -aG docker $USER\n# Re-login or `newgrp docker` to apply<\/code><\/pre>\n<h2 id=\"section-4\"><span id=\"Folder_layout_and_dockercomposeyml\">Folder layout and docker\u2011compose.yml<\/span><\/h2>\n<p>I like a simple working directory so future me can remember what I did. Something like this:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">~\/wp-stack\/\n  docker-compose.yml\n  nginx\/\n    conf.d\/\n      site.conf\n  data\/  # this is just a home for named volumes metadata if you ever bind-mount\n<\/code><\/pre>\n<p>We\u2019ll use named volumes for persistence so Docker manages the storage paths. If you prefer bind mounts for backups, that\u2019s fine too, but named volumes are tidy and let you move hosts without caring where the files live on disk.<\/p>\n<h3><span id=\"dockercomposeyml\">docker\u2011compose.yml<\/span><\/h3>\n<p>Here\u2019s a clean starting point. Replace example.com with your domain and set real passwords.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">version: '3.9'\n\nservices:\n  nginx:\n    image: nginx:alpine\n    depends_on:\n      - wordpress\n    ports:\n      - '80:80'\n      - '443:443'\n    volumes:\n      - .\/nginx\/conf.d:\/etc\/nginx\/conf.d:ro\n      - wp_data:\/var\/www\/html\n      - letsencrypt:\/etc\/letsencrypt\n      - acme:\/var\/lib\/letsencrypt\n      - nginx_logs:\/var\/log\/nginx\n    networks:\n      - web\n\n  wordpress:\n    image: wordpress:php8.2-fpm\n    environment:\n      - WORDPRESS_DB_HOST=db:3306\n      - WORDPRESS_DB_NAME=wordpress\n      - WORDPRESS_DB_USER=wpuser\n      - WORDPRESS_DB_PASSWORD=supersecret\n      - WP_REDIS_HOST=redis\n      - WP_REDIS_PORT=6379\n    volumes:\n      - wp_data:\/var\/www\/html\n    depends_on:\n      - db\n    networks:\n      - web\n\n  db:\n    image: mariadb:10.11\n    environment:\n      - MARIADB_DATABASE=wordpress\n      - MARIADB_USER=wpuser\n      - MARIADB_PASSWORD=supersecret\n      - MARIADB_ROOT_PASSWORD=evenmoresecret\n    command: ['mysqld', '--innodb-file-per-table=1', '--character-set-server=utf8mb4', '--collation-server=utf8mb4_unicode_ci']\n    volumes:\n      - db_data:\/var\/lib\/mysql\n    networks:\n      - web\n\n  redis:\n    image: redis:alpine\n    command: ['redis-server', '--appendonly', 'yes']\n    volumes:\n      - redis_data:\/data\n    networks:\n      - web\n\n  certbot:\n    image: certbot\/certbot\n    volumes:\n      - wp_data:\/var\/www\/html\n      - letsencrypt:\/etc\/letsencrypt\n      - acme:\/var\/lib\/letsencrypt\n    entrypoint: \/bin\/sh\n    networks:\n      - web\n\nnetworks:\n  web:\n    driver: bridge\n\nvolumes:\n  wp_data:\n  db_data:\n  redis_data:\n  letsencrypt:\n  acme:\n  nginx_logs:\n<\/code><\/pre>\n<p>A couple of quiet but important details: the wordpress service uses the PHP\u2011FPM variant of the WordPress image, not Apache. Nginx will be our web server, and it will pass PHP to wordpress:9000. Redis persists data to disk so you keep your cache across restarts. MariaDB gets a named volume so your data lives beyond container lifetimes. And logs have a dedicated mount so you can rotate them on the host if you prefer.<\/p>\n<p>Want to skim the official image docs later? The <a href=\"https:\/\/hub.docker.com\/_\/wordpress\" rel=\"nofollow noopener\" target=\"_blank\">WordPress Docker image page<\/a> is a nice reference.<\/p>\n<h2 id=\"section-5\"><span id=\"Nginx_config_and_first_run\">Nginx config and first run<\/span><\/h2>\n<h3><span id=\"Nginx_server_block\">Nginx server block<\/span><\/h3>\n<p>Let\u2019s drop a sane Nginx config into nginx\/conf.d\/site.conf. We\u2019ll serve HTTP first so we can issue our initial Let\u2019s Encrypt cert via webroot. Then we\u2019ll flip the switch to 443 with TLS.<\/p>\n<pre class=\"language-nginx line-numbers\"><code class=\"language-nginx\">server {\n    listen 80;\n    listen [::]:80;\n    server_name example.com www.example.com;\n\n    root \/var\/www\/html;\n    index index.php index.html index.htm;\n\n    # ACME challenge for Let's Encrypt\n    location ^~ \/.well-known\/acme-challenge\/ {\n        allow all;\n        default_type 'text\/plain';\n    }\n\n    location \/ {\n        try_files $uri $uri\/ \/index.php?$args;\n    }\n\n    location ~ .php$ {\n        include fastcgi_params;\n        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;\n        fastcgi_pass wordpress:9000;\n        fastcgi_read_timeout 60s;\n    }\n\n    # Basic security hardening\n    client_max_body_size 64m;\n    add_header X-Frame-Options SAMEORIGIN always;\n    add_header X-Content-Type-Options nosniff always;\n    add_header Referrer-Policy no-referrer-when-downgrade always;\n}\n<\/code><\/pre>\n<p>Bring the stack up so Nginx can serve the ACME challenge over HTTP:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">docker compose up -d<\/code><\/pre>\n<h3><span id=\"Issue_the_first_TLS_cert\">Issue the first TLS cert<\/span><\/h3>\n<p>With DNS pointing to the VPS and port 80 open, we\u2019ll generate certs using the certbot container and the webroot method. Replace emails and domains first.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">docker compose run --rm certbot \n  certbot certonly --webroot \n  -w \/var\/www\/html \n  -d example.com -d www.example.com \n  --email you@example.com --agree-tos --no-eff-email<\/code><\/pre>\n<p>If that succeeds, your certs land in the letsencrypt volume. Now we add the TLS server block and redirect HTTP to HTTPS. Update nginx\/conf.d\/site.conf to something like this:<\/p>\n<pre class=\"language-nginx line-numbers\"><code class=\"language-nginx\">server {\n    listen 80;\n    listen [::]:80;\n    server_name example.com www.example.com;\n    return 301 https:\/\/$host$request_uri;\n}\n\nserver {\n    listen 443 ssl http2;\n    listen [::]:443 ssl http2;\n    server_name example.com www.example.com;\n\n    root \/var\/www\/html;\n    index index.php index.html index.htm;\n\n    ssl_certificate \/etc\/letsencrypt\/live\/example.com\/fullchain.pem;\n    ssl_certificate_key \/etc\/letsencrypt\/live\/example.com\/privkey.pem;\n\n    # ACME challenge even on HTTPS (renewals might hit both)\n    location ^~ \/.well-known\/acme-challenge\/ {\n        allow all;\n        default_type 'text\/plain';\n    }\n\n    location \/ {\n        try_files $uri $uri\/ \/index.php?$args;\n    }\n\n    location ~* .(?:css|js|jpg|jpeg|gif|png|svg|ico|webp|avif)$ {\n        expires 7d;\n        add_header Cache-Control &quot;public, max-age=604800, immutable&quot;;\n        try_files $uri $uri\/ \/index.php?$args;\n    }\n\n    location ~ .php$ {\n        include fastcgi_params;\n        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;\n        fastcgi_pass wordpress:9000;\n        fastcgi_read_timeout 60s;\n    }\n\n    # A few helpful headers\n    add_header X-Frame-Options SAMEORIGIN always;\n    add_header X-Content-Type-Options nosniff always;\n    add_header Referrer-Policy no-referrer-when-downgrade always;\n}\n<\/code><\/pre>\n<p>Reload Nginx by recreating the container:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">docker compose up -d nginx<\/code><\/pre>\n<p>For automated renewals, a simple monthly (or more frequent) cron hit works. The certbot container shares the Let\u2019s Encrypt volumes, so renewals write to the same place. The webroot must still be reachable over HTTP\/HTTPS during renewal.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\"># Crontab example: run daily at 3:17\n17 3 * * * docker compose run --rm certbot certbot renew --quiet &amp;&amp; docker compose exec -T nginx nginx -s reload<\/code><\/pre>\n<p>If you want a friendlier walkthrough of Certbot options, the official <a href=\"https:\/\/certbot.eff.org\/\" rel=\"nofollow noopener\" target=\"_blank\">Certbot site<\/a> is a nice refresher.<\/p>\n<h2 id=\"section-6\"><span id=\"Persistent_volumes_where_your_data_really_lives\">Persistent volumes: where your data really lives<\/span><\/h2>\n<p>Here\u2019s where the calm comes from. WordPress core, themes, and plugins live in wp_data. Your database state is in db_data. Redis keeps its cache in redis_data. Certificates live in letsencrypt and acme. If you ever migrate hosts, you can snapshot those volumes and move them along with your compose files, and it all snaps back into place like Lego.<\/p>\n<p>I remember a weekend when a client wanted to move from a bursty VPS to a quieter one with more predictable CPU. We tarred up the named volumes, copied them to the new server, brought up the exact same docker\u2011compose.yml, updated DNS, and that was that. No hand\u2011installing PHP extensions, no guessing which ini file had the upload limit. Everything lived next to the repo with the exact config we\u2019d been running.<\/p>\n<h3><span id=\"Backups_you_actually_trust\">Backups you actually trust<\/span><\/h3>\n<p>There are many ways to do backups here. My rule: separate concerns. Snapshot db_data frequently (dump logical backups too), archive wp_data regularly (especially if you accept uploads), and keep your Let\u2019s Encrypt material safe. If you prefer object storage, I\u2019ve had a great time using restic to push encrypted snapshots offsite. I wrote a friendly, step\u2011by\u2011step deep dive on that if you want a blueprint: <a href=\"https:\/\/www.dchost.com\/blog\/en\/restic-ve-borg-ile-s3-uyumlu-uzak-yedekleme-surumleme-sifreleme-ve-saklama-ne-zaman-nasil\/\">Offsite Backups Without the Drama with Restic\/Borg to S3\u2011Compatible Storage<\/a>.<\/p>\n<p>For quick, pragmatic backups, a pair of commands goes a long way. Dump the database and archive wp_data. You can wrap this into a cron or GitHub Action if your host allows it.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\"># DB dump\ndocker compose exec -T db mariadb-dump -u wpuser -psupersecret --databases wordpress | gzip &gt; \/backups\/wp-$(date +%F).sql.gz\n\n# WordPress files (themes, plugins, uploads)\ndocker run --rm \n  -v $(docker volume inspect --format '{{.Mountpoint}}' $(basename $(pwd))_wp_data):\/src \n  -v \/backups:\/dest \n  alpine sh -c &quot;cd \/src &amp;&amp; tar czf \/dest\/wp-files-$(date +%F).tgz .&quot;<\/code><\/pre>\n<p>If you use bind mounts instead of named volumes, backups are even simpler from the host, but you trade a bit of portability. Pick what matches your team\u2019s habits.<\/p>\n<h2 id=\"section-7\"><span id=\"Redis_Object_Cache_and_performance_niceties\">Redis Object Cache and performance niceties<\/span><\/h2>\n<p>Turning on Redis object caching is a tiny change that pays off immediately on dynamic sites. Inside WordPress, you can install the \u201cRedis Object Cache\u201d plugin, point it at redis:6379, and click enable. Because we already set WP_REDIS_HOST and WP_REDIS_PORT in the environment, it usually Just Works once the plugin is active. I remember flipping this on for a busy WooCommerce shop and watching queries per page drop dramatically, which also calmed down CPU spikes during promos.<\/p>\n<p>Beyond Redis, a few under\u2011the\u2011radar wins add up. First, let Nginx set far\u2011future caching headers for static assets like CSS, JS, and images\u2014your users\u2019 browsers won\u2019t ask for them every time. Second, keep PHP\u2011FPM\u2019s worker counts sensible; the defaults are fine on small boxes, but if you have more cores, you can nudge pm.max_children and friends via php.ini to match your traffic. Third, be mindful of heavyweight plugins or page builders; the containers won\u2019t save you from inefficient code, but they make it easier to isolate and measure.<\/p>\n<p>If you\u2019re into the nitty\u2011gritty of HTTP speedups later, once your stack is stable you can explore enabling HTTP\/2 and even HTTP\/3\/QUIC on Nginx and your CDN. That\u2019s a story for another day, but keep it in the back of your mind as a tasteful final polish when the basics are steady.<\/p>\n<h3><span id=\"Uploads_timeouts_and_other_qualityoflife_tweaks\">Uploads, timeouts, and other quality\u2011of\u2011life tweaks<\/span><\/h3>\n<p>In Nginx, we bumped client_max_body_size to 64m as a practical default. Adjust as needed for big uploads. For media heavy sites, consider offloading large media to object storage to keep your VPS snappy; it also lightens backups and reduces disk pressure. On the PHP side, adjust post_max_size and upload_max_filesize via a custom ini if you hit limits. Because this is Docker, you can mount a php.ini into the WordPress container and keep those changes versioned along with your compose file.<\/p>\n<h2 id=\"section-8\"><span id=\"Security_hygiene_without_drama\">Security hygiene without drama<\/span><\/h2>\n<p>Security is mostly a game of guardrails you barely notice day\u2011to\u2011day. Keep SSH locked down to keys, let only ports 80 and 443 through, and patch your base images when you bump versions in compose. If you ever expose phpMyAdmin or similar, put it behind authentication or a VPN. Also, don\u2019t run everything as root on the host\u2014Docker will keep most of your surface area inside containers, which helps a lot.<\/p>\n<p>For TLS, we already have Let\u2019s Encrypt running via webroot. You can tighten ciphers and enable HSTS after you confirm everything is working. The important part is making renewal automatic, and having Nginx reload quietly when certs change. If you ever change domains or add subdomains, just re\u2011issue with certbot and reload.<\/p>\n<p>I\u2019ve seen people try to \u201cbake certs\u201d into images\u2014resist that urge. Certificates are secrets and should live on volumes, not inside images, for both security and flexibility.<\/p>\n<h2 id=\"section-9\"><span id=\"Updates_without_breaking_your_weekend\">Updates without breaking your weekend<\/span><\/h2>\n<p>The calm way to update? Bump your image tags one component at a time, recreate the service, and watch logs. Start with non\u2011DB components like nginx and wordpress. Then, when you\u2019re ready, schedule a MariaDB major version bump and take a fresh dump first. If something misbehaves, you can roll the tag back and re\u2011up in under a minute.<\/p>\n<p>WordPress core and plugins still update inside wp_data as usual. Because that volume persists, you don\u2019t lose your changes when containers restart. When you need to test a new theme or plugin stack, clone the directory, run a staging compose on an alternate port, and point a staging subdomain there. Pressure test, then roll changes to production without surprises.<\/p>\n<h2 id=\"section-10\"><span id=\"Monitoring_and_troubleshooting_like_a_calm_pro\">Monitoring and troubleshooting like a calm pro<\/span><\/h2>\n<p>When something feels off, start with container logs. Nginx logs land in the nginx_logs volume. PHP\u2011FPM and WordPress logs will appear in docker logs for the wordpress service unless you change it. MariaDB logs often tell you if a slow query is dragging a page down. Redis logs are usually quiet unless memory is tight.<\/p>\n<p>In a pinch, I attach a shell to the WordPress container and run wp\u2011cli to inspect the site, clear caches, or nudge a plugin. If you don\u2019t have wp\u2011cli, a quick docker exec with curl against the localhost endpoint can show you whether the app is responsive without going through the network edge.<\/p>\n<p>If you\u2019re the dashboard type, it\u2019s easy to glue on basic uptime and system metrics with lightweight tools later. For now, keep an eye on CPU, RAM, and disk, and watch your error logs after changes. The best troubleshooting tip I can give you is to change one thing at a time and keep your configs in git so you can diff what changed when.<\/p>\n<h2 id=\"section-11\"><span id=\"Extra_niceties_I_reach_for\">Extra niceties I reach for<\/span><\/h2>\n<p>Once the foundation is solid, a couple of tasteful additions make life even easier. A staging compose file with a different project name and ports is great for testing PHP upgrades. A periodic database dump job inside a tiny alpine container keeps backups fresh even if you forget. And when traffic grows, moving MariaDB to its own VPS with the same volume strategy is straightforward\u2014you just point WORDPRESS_DB_HOST to the new endpoint.<\/p>\n<p>Another quiet win: set up proper asset optimization and a CDN when the time is right. Keep your origin (this VPS) lean, and let the edge serve the heavy static bits. Your Redis cache will thank you, and your visitors on slow networks will feel the difference.<\/p>\n<h2 id=\"section-12\"><span id=\"Stepbystep_first_install_recap\">Step\u2011by\u2011step first install recap<\/span><\/h2>\n<p>Let me stitch it all together in a calm flow you can follow without second\u2011guessing yourself. First, point DNS to the VPS, open ports 80 and 443, and install Docker with the compose plugin. Second, create the working directory, drop in the docker\u2011compose.yml and the Nginx site.conf with a plain HTTP server block. Third, bring the stack up and confirm you can load the domain over HTTP. Fourth, run the certbot container with the webroot method to issue your certs. Fifth, switch Nginx to the HTTPS server block with http2, set caching headers, and reload. Sixth, log into WordPress at \/wp\u2011admin and install the Redis Object Cache plugin; enable it and watch query counts drop. Seventh, schedule cert renewals and back up db_data and wp_data on a cadence that matches your content changes.<\/p>\n<p>And finally, take a breath. The hard part is behind you. From here on, updates are just a tag change and a compose up away.<\/p>\n<h2 id=\"section-13\"><span id=\"Troubleshooting_the_top_three_gotchas\">Troubleshooting the top three gotchas<\/span><\/h2>\n<h3><span id=\"1_Certbot_cant_validate_the_domain\">1) Certbot can\u2019t validate the domain<\/span><\/h3>\n<p>Nine times out of ten, DNS wasn\u2019t updated or a proxy is in front rewriting paths. Hit http:\/\/yourdomain\/.well-known\/acme-challenge\/test.txt and confirm you can serve a file from \/var\/www\/html\/.well-known\/acme-challenge. If you can\u2019t, double\u2011check the Nginx location block and that port 80 is open.<\/p>\n<h3><span id=\"2_WordPress_installer_cant_connect_to_the_database\">2) WordPress installer can\u2019t connect to the database<\/span><\/h3>\n<p>Make sure WORDPRESS_DB_HOST points to db:3306 and that the MariaDB variables match exactly in both services. If you changed the database name mid\u2011flight, the installer will complain until you align everything or create the database manually.<\/p>\n<h3><span id=\"3_Redis_plugin_says_object_cache_not_running\">3) Redis plugin says \u201cobject cache not running\u201d<\/span><\/h3>\n<p>Confirm the plugin is installed and enabled, and that WP_REDIS_HOST is redis in the wordpress service. If you attached Redis late, try flushing the cache from the plugin and watch docker logs for the redis service for any permission or memory issues.<\/p>\n<h2 id=\"section-14\"><span id=\"Wrapup_a_calm_stack_you_can_trust\">Wrap\u2011up: a calm stack you can trust<\/span><\/h2>\n<p>I\u2019ve been down just about every <a href=\"https:\/\/www.dchost.com\/wordpress-hosting\">WordPress hosting<\/a> path you can imagine, and this one keeps me sane. Docker and compose make the moving parts explicit, Nginx and PHP\u2011FPM do their jobs without drama, MariaDB sits on a durable volume, Redis keeps things snappy, and Let\u2019s Encrypt removes a whole category of operational anxiety. The best part is how portable it feels\u2014you\u2019re not married to a particular VPS forever, and migrations feel like a checklist, not a cliff.<\/p>\n<p>If you only remember three things, let it be this: keep configs versioned, keep state on persistent volumes, and make backups a habit rather than a hope. When you get the basics right, optimization becomes fun rather than urgent.<\/p>\n<p>Hope this walkthrough helped you build something solid. If you try this stack and run into an edge case I didn\u2019t cover, drop a note and I\u2019ll happily add a section in a future update. Until then, enjoy the quiet joy of a WordPress that just\u2026 runs.<\/p>\n<\/div>","protected":false},"excerpt":{"rendered":"<p>So there I was again, staring at a sluggish WordPress site on a tiny VPS, thinking, there has to be a cleaner way to run this without it turning into a weekend project every time something needs updating. I\u2019d done the classic LAMP installs, the one\u2011off tweaks, the frantic plugin audits after a spike in [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":1550,"comment_status":"","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[26],"tags":[],"class_list":["post-1549","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-teknoloji"],"_links":{"self":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/posts\/1549","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/comments?post=1549"}],"version-history":[{"count":0,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/posts\/1549\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/media\/1550"}],"wp:attachment":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/media?parent=1549"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/categories?post=1549"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/tags?post=1549"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}