{"id":2004,"date":"2025-11-17T23:21:03","date_gmt":"2025-11-17T20:21:03","guid":{"rendered":"https:\/\/www.dchost.com\/blog\/containerizing-wordpress-on-one-vps-my-production-docker-playbook-with-traefik-or-nginx\/"},"modified":"2025-11-17T23:21:03","modified_gmt":"2025-11-17T20:21:03","slug":"containerizing-wordpress-on-one-vps-my-production-docker-playbook-with-traefik-or-nginx","status":"publish","type":"post","link":"https:\/\/www.dchost.com\/blog\/en\/containerizing-wordpress-on-one-vps-my-production-docker-playbook-with-traefik-or-nginx\/","title":{"rendered":"Containerizing WordPress on One VPS: My Production Docker Playbook with Traefik or Nginx"},"content":{"rendered":"<div class=\"dchost-blog-content-wrapper\"><p>So there I was, staring at a client\u2019s WooCommerce store late on a Friday evening, feeling that familiar hum of anticipation and mild dread. A flash sale was about to go live, and their site was still on a tiny shared host. You can probably guess what happened the last time: the homepage took forever, the checkout hiccuped, and suddenly my phone was buzzing like a beehive. That\u2019s when I decided to move them to a single <a href=\"https:\/\/www.dchost.com\/vps\">VPS<\/a> and containerize everything. Not to be flashy\u2014just to finally get some control. Ever had that moment when you know rebuilding is less scary than babysitting a fragile setup? That was me.<\/p>\n<p>In this guide, I\u2019ll walk you through how I containerize WordPress with Docker on a single VPS and wire it up behind a reverse proxy\u2014either <strong>Traefik<\/strong> for easy, automated TLS and routing or <strong>Nginx<\/strong> if you prefer the classic way. We\u2019ll cover the architecture, the compose files, the production hardening bits nobody mentions until it\u2019s too late (hello backups and health checks), and a simple process for rolling out updates without drama. My goal is simple: help you build a setup that feels calm on Monday mornings.<\/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_Containerize_WordPress_on_a_Single_VPS\"><span class=\"toc_number toc_depth_1\">1<\/span> Why Containerize WordPress on a Single VPS?<\/a><\/li><li><a href=\"#The_Blueprint_Components_Networks_and_the_Reverse_Proxy_Choice\"><span class=\"toc_number toc_depth_1\">2<\/span> The Blueprint: Components, Networks, and the Reverse Proxy Choice<\/a><\/li><li><a href=\"#Traefik_Path_A_Compose_Stack_That_Just_Gets_Out_of_the_Way\"><span class=\"toc_number toc_depth_1\">3<\/span> Traefik Path: A Compose Stack That Just Gets Out of the Way<\/a><ul><li><a href=\"#The_Compose_file\"><span class=\"toc_number toc_depth_2\">3.1<\/span> The Compose file<\/a><\/li><li><a href=\"#Nginx_app_config_for_WordPress_FPM\"><span class=\"toc_number toc_depth_2\">3.2<\/span> Nginx app config for WordPress + FPM<\/a><\/li><li><a href=\"#Why_Traefik_feels_nice_for_single_VPS\"><span class=\"toc_number toc_depth_2\">3.3<\/span> Why Traefik feels nice for single VPS<\/a><\/li><\/ul><\/li><li><a href=\"#Nginx_Path_The_Classic_Hands-On_Reverse_Proxy\"><span class=\"toc_number toc_depth_1\">4<\/span> Nginx Path: The Classic, Hands-On Reverse Proxy<\/a><ul><li><a href=\"#The_Compose_file-2\"><span class=\"toc_number toc_depth_2\">4.1<\/span> The Compose file<\/a><\/li><li><a href=\"#Nginx_server_with_TLS_and_FastCGI\"><span class=\"toc_number toc_depth_2\">4.2<\/span> Nginx server with TLS and FastCGI<\/a><\/li><\/ul><\/li><li><a href=\"#Production_Ops_TLS_Backups_Security_Monitoring\"><span class=\"toc_number toc_depth_1\">5<\/span> Production Ops: TLS, Backups, Security, Monitoring<\/a><ul><li><a href=\"#TLS_and_ACME_that_you_dont_dread\"><span class=\"toc_number toc_depth_2\">5.1<\/span> TLS and ACME that you don\u2019t dread<\/a><\/li><li><a href=\"#Backups_you_can_restore_in_your_sleep\"><span class=\"toc_number toc_depth_2\">5.2<\/span> Backups you can restore in your sleep<\/a><\/li><li><a href=\"#Security_that_doesnt_slow_you_down\"><span class=\"toc_number toc_depth_2\">5.3<\/span> Security that doesn\u2019t slow you down<\/a><\/li><li><a href=\"#Monitoring_and_logs_that_tell_a_story\"><span class=\"toc_number toc_depth_2\">5.4<\/span> Monitoring and logs that tell a story<\/a><\/li><\/ul><\/li><li><a href=\"#Deploying_Changes_Without_Drama_BlueGreen_on_One_VPS\"><span class=\"toc_number toc_depth_1\">6<\/span> Deploying Changes Without Drama: Blue\/Green on One VPS<\/a><ul><li><a href=\"#Scaling_up_from_here\"><span class=\"toc_number toc_depth_2\">6.1<\/span> Scaling up from here<\/a><\/li><\/ul><\/li><li><a href=\"#A_Few_Notes_That_Make_Real_Life_Easier\"><span class=\"toc_number toc_depth_1\">7<\/span> A Few Notes That Make Real Life Easier<\/a><\/li><li><a href=\"#Putting_It_All_Together_Your_Next_Steps\"><span class=\"toc_number toc_depth_1\">8<\/span> Putting It All Together: Your Next Steps<\/a><\/li><li><a href=\"#Common_Troubleshooting_Moments_and_Quick_Fixes\"><span class=\"toc_number toc_depth_1\">9<\/span> Common Troubleshooting Moments (and Quick Fixes)<\/a><\/li><li><a href=\"#Wrap-Up_A_Calm_Reliable_WordPress_on_One_VPS\"><span class=\"toc_number toc_depth_1\">10<\/span> Wrap-Up: A Calm, Reliable WordPress on One VPS<\/a><\/li><\/ul><\/div>\n<h2 id=\"section-1\"><span id=\"Why_Containerize_WordPress_on_a_Single_VPS\">Why Containerize WordPress on a Single VPS?<\/span><\/h2>\n<p>If you\u2019ve ever upgraded PHP on a live server and felt your palms sweat, containerization is your friend. On a single VPS, Docker gives you a clean way to isolate your WordPress app, database, and caching layers. Think of it like a tidy toolbox: PHP and Nginx aren\u2019t fighting over packages, MySQL has its own corner, and you can swap parts without disturbing the whole bench. The VPS is still one box, sure, but inside it, everything is wrapped and labeled.<\/p>\n<p>In my experience, the three biggest wins are repeatability, safety, and speed. You can rebuild containers from scratch, track your infrastructure in version control, and roll forward or back with much less fuss. It\u2019s not bulletproof\u2014this is still a single machine\u2014but it\u2019s <strong>predictable<\/strong>. And predictability is worth gold when traffic spikes on a Saturday and you\u2019re five minutes from dinner.<\/p>\n<p>Here\u2019s the thing: you also get a path to grow. Start with one site and one VPS. Then add a second site with a new hostname and a few extra lines in your compose. Later, move the database to a managed service or split the proxy to another machine. Containerization doesn\u2019t lock you in; it gives you stepping stones.<\/p>\n<h2 id=\"section-2\"><span id=\"The_Blueprint_Components_Networks_and_the_Reverse_Proxy_Choice\">The Blueprint: Components, Networks, and the Reverse Proxy Choice<\/span><\/h2>\n<p>Let\u2019s sketch the shape of a production-ish WordPress on one VPS. You\u2019ll have a reverse proxy on the front. That could be <strong>Traefik<\/strong> (my usual pick for single-box automation) or <strong>Nginx<\/strong> (rock solid, straightforward, and deeply tunable). Behind that edge, you\u2019ll have the app stack: Nginx (as the web server for WordPress), PHP-FPM, MariaDB (or MySQL), and Redis for object caching. Traefik lives at the perimeter, speaks ACME\/Let\u2019s Encrypt for automatic certificates, and routes to the internal Nginx app. In the Nginx-only variant, the edge proxy is also your app web server and talks directly to PHP-FPM.<\/p>\n<p>Networking is where the sanity comes from. I like to define two Docker networks: a <strong>public-facing network<\/strong> for the reverse proxy and an <strong>internal network<\/strong> for app components. Only the proxy binds to ports 80\/443 on the VPS. Everything else stays tucked away, reachable only to its neighbors. Volumes hold state\u2014mainly your database files and WordPress wp-content. Yes, containers are ephemeral; volumes are not. That separation keeps recoveries boring.<\/p>\n<p>A tiny heads-up: you might be tempted to run everything in one container because \u201cit works.\u201d Resist the urge. Keeping Nginx, PHP-FPM, MariaDB, and Redis separate pays off when you update or troubleshoot. If PHP misbehaves, you don\u2019t want to restart your database. Likewise, the reverse proxy can reload TLS without nudging the app. Small boundaries prevent big outages.<\/p>\n<h2 id=\"section-3\"><span id=\"Traefik_Path_A_Compose_Stack_That_Just_Gets_Out_of_the_Way\">Traefik Path: A Compose Stack That Just Gets Out of the Way<\/span><\/h2>\n<p>Traefik feels like a friendly concierge. It discovers containers via Docker labels, wires routes, and fetches TLS certs automatically. For a single VPS, that saves time and mistakes. I remember the first time I flipped on Traefik\u2019s ACME resolver and saw a new certificate land in its storage file without me touching Certbot. I exhaled. Let\u2019s build that.<\/p>\n<h3><span id=\"The_Compose_file\">The Compose file<\/span><\/h3>\n<p>The following is a baseline you can adapt. It uses Traefik at the edge, an Nginx app container, PHP-FPM, MariaDB, and Redis. I\u2019ve trimmed some options for readability, but the essentials are here.<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">version: '3.9'\n\nnetworks:\n  proxy:\n    external: false\n  internal:\n    external: false\n\nvolumes:\n  db_data:\n  wp_content:\n  traefik_data:\n\nservices:\n  traefik:\n    image: traefik:v2.10\n    command:\n      - --providers.docker=true\n      - --providers.docker.exposedbydefault=false\n      - --entrypoints.web.address=:80\n      - --entrypoints.websecure.address=:443\n      - --certificatesresolvers.le.acme.email=admin@example.com\n      - --certificatesresolvers.le.acme.storage=\/letsencrypt\/acme.json\n      - --certificatesresolvers.le.acme.httpchallenge=true\n      - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web\n      - --log.level=INFO\n    ports:\n      - 80:80\n      - 443:443\n    volumes:\n      - \/var\/run\/docker.sock:\/var\/run\/docker.sock:ro\n      - traefik_data:\/letsencrypt\n    restart: unless-stopped\n    networks:\n      - proxy\n    healthcheck:\n      test: [&quot;CMD&quot;, &quot;traefik&quot;, &quot;version&quot;]\n      interval: 30s\n      timeout: 5s\n      retries: 3\n    labels:\n      - traefik.enable=true\n      # Optional: expose dashboard behind auth if you know what you're doing\n      # - traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)\n      # - traefik.http.routers.dashboard.service=api@internal\n\n  nginx:\n    image: nginx:alpine\n    depends_on:\n      - php\n    volumes:\n      - wp_content:\/var\/www\/html\/wp-content\n      - .\/nginx\/conf.d:\/etc\/nginx\/conf.d:ro\n    restart: unless-stopped\n    networks:\n      - proxy\n      - internal\n    labels:\n      - traefik.enable=true\n      - traefik.http.routers.wp.rule=Host(`example.com`)\n      - traefik.http.routers.wp.entrypoints=websecure\n      - traefik.http.routers.wp.tls.certresolver=le\n      - traefik.http.services.wp.loadbalancer.server.port=80\n      # Force HTTP -&gt; HTTPS redirect\n      - traefik.http.routers.wp-redirect.rule=Host(`example.com`)\n      - traefik.http.routers.wp-redirect.entrypoints=web\n      - traefik.http.routers.wp-redirect.middlewares=redirect-https\n      - traefik.http.middlewares.redirect-https.redirectscheme.scheme=https\n    healthcheck:\n      test: [&quot;CMD-SHELL&quot;, &quot;wget -qO- http:\/\/127.0.0.1\/health || exit 1&quot;]\n      interval: 30s\n      timeout: 5s\n      retries: 3\n\n  php:\n    image: wordpress:php8.2-fpm\n    environment:\n      WORDPRESS_DB_HOST: mariadb:3306\n      WORDPRESS_DB_USER: wpuser\n      WORDPRESS_DB_PASSWORD: supersecret\n      WORDPRESS_DB_NAME: wordpress\n      WORDPRESS_CONFIG_EXTRA: |\n        define('WP_CACHE_KEY_SALT', 'example.com:');\n        define('WP_REDIS_HOST', 'redis');\n        define('WP_REDIS_PORT', 6379);\n    volumes:\n      - wp_content:\/var\/www\/html\/wp-content\n    restart: unless-stopped\n    networks:\n      - internal\n    healthcheck:\n      test: [&quot;CMD-SHELL&quot;, &quot;SCRIPT_NAME=\/ping REQUEST_METHOD=GET cgi-fcgi -bind -connect 127.0.0.1:9000 | grep -q 'pong'&quot;]\n      interval: 30s\n      timeout: 5s\n      retries: 3\n\n  mariadb:\n    image: mariadb:10.11\n    environment:\n      MARIADB_DATABASE: wordpress\n      MARIADB_USER: wpuser\n      MARIADB_PASSWORD: supersecret\n      MARIADB_ROOT_PASSWORD: rootsecret\n    volumes:\n      - db_data:\/var\/lib\/mysql\n    restart: unless-stopped\n    command: ['--innodb-buffer-pool-size=512M', '--character-set-server=utf8mb4', '--collation-server=utf8mb4_unicode_ci']\n    networks:\n      - internal\n    healthcheck:\n      test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -prootsecret | grep -q alive']\n      interval: 30s\n      timeout: 5s\n      retries: 5\n\n  redis:\n    image: redis:7-alpine\n    command: ['redis-server', '--appendonly', 'yes']\n    restart: unless-stopped\n    networks:\n      - internal\n    healthcheck:\n      test: ['CMD', 'redis-cli', 'ping']\n      interval: 30s\n      timeout: 5s\n      retries: 3\n<\/code><\/pre>\n<h3><span id=\"Nginx_app_config_for_WordPress_FPM\">Nginx app config for WordPress + FPM<\/span><\/h3>\n<p>The Nginx container above mounts a conf.d directory. Here\u2019s a minimal config you can drop into <strong>.\/nginx\/conf.d\/site.conf<\/strong>. Notice the tiny health endpoint for Traefik\u2019s check and a few sensible headers.<\/p>\n<pre class=\"language-nginx line-numbers\"><code class=\"language-nginx\">server {\n  listen 80;\n  server_name example.com;\n  root \/var\/www\/html;\n\n  # Simple health endpoint\n  location \/health { return 200; }\n\n  index index.php index.html index.htm;\n\n  location ~* .(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf)$ {\n    access_log off;\n    expires 7d;\n    add_header Cache-Control public;\n    try_files $uri =404;\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 php:9000;\n    fastcgi_read_timeout 300s;\n  }\n\n  client_max_body_size 64m;\n\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;\n}\n<\/code><\/pre>\n<p>I keep this version intentionally plain. Once your site is stable, you can sprinkle in micro-optimizations: gzip or Brotli (if fronted by a CDN, maybe skip), conditional caching rules for static content, and a rate limit on specific paths like xmlrpc.php. Keep an eye on simplicity\u2014future-you will thank you.<\/p>\n<h3><span id=\"Why_Traefik_feels_nice_for_single_VPS\">Why Traefik feels nice for single VPS<\/span><\/h3>\n<p>Traefik\u2019s auto-discovery and ACME integration let you focus on the app. Add a label, commit, deploy, and boom\u2014new hostname works with TLS. If you\u2019re a fan of guardrails, their docs are approachable; skim <a href=\"https:\/\/doc.traefik.io\/traefik\/https\/acme\/\" rel=\"nofollow noopener\" target=\"_blank\">Traefik\u2019s ACME and HTTPS guide<\/a> and the basic <a href=\"https:\/\/docs.docker.com\/compose\/\" rel=\"nofollow noopener\" target=\"_blank\">Docker Compose docs<\/a> if it\u2019s your first time wiring networks and volumes. And when in doubt, I peek at the <a href=\"https:\/\/hub.docker.com\/_\/wordpress\" rel=\"nofollow noopener\" target=\"_blank\">official WordPress Docker image notes<\/a> to remind myself what environment variables are supported in each tag.<\/p>\n<h2 id=\"section-4\"><span id=\"Nginx_Path_The_Classic_Hands-On_Reverse_Proxy\">Nginx Path: The Classic, Hands-On Reverse Proxy<\/span><\/h2>\n<p>There are times when I choose Nginx as the edge on a single VPS. Usually it\u2019s because I want fine-grained caching rules right at the perimeter, I need super specific headers and rewrites, or I\u2019m working in an environment where Traefik isn\u2019t familiar. Nginx never complains; it just serves.<\/p>\n<p>In this variation, there\u2019s no separate app Nginx. The edge Nginx handles both the reverse proxy duties and the app web serving, passing PHP to FPM. TLS can be managed with Certbot or acme.sh mounted into the container, or you can run certificate automation on the host and mount the certs as read-only. Here\u2019s a compact Compose file and a server config.<\/p>\n<h3><span id=\"The_Compose_file-2\">The Compose file<\/span><\/h3>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">version: '3.9'\n\nnetworks:\n  web:\n  internal:\n\nvolumes:\n  db_data:\n  wp_content:\n  certs:\n\nservices:\n  nginx:\n    image: nginx:alpine\n    depends_on:\n      - php\n    ports:\n      - 80:80\n      - 443:443\n    volumes:\n      - .\/nginx\/conf.d:\/etc\/nginx\/conf.d:ro\n      - wp_content:\/var\/www\/html\/wp-content\n      - certs:\/etc\/nginx\/certs:ro\n    restart: unless-stopped\n    networks:\n      - web\n      - internal\n\n  php:\n    image: wordpress:php8.2-fpm\n    environment:\n      WORDPRESS_DB_HOST: mariadb:3306\n      WORDPRESS_DB_USER: wpuser\n      WORDPRESS_DB_PASSWORD: supersecret\n      WORDPRESS_DB_NAME: wordpress\n    volumes:\n      - wp_content:\/var\/www\/html\/wp-content\n    restart: unless-stopped\n    networks:\n      - internal\n\n  mariadb:\n    image: mariadb:10.11\n    environment:\n      MARIADB_DATABASE: wordpress\n      MARIADB_USER: wpuser\n      MARIADB_PASSWORD: supersecret\n      MARIADB_ROOT_PASSWORD: rootsecret\n    volumes:\n      - db_data:\/var\/lib\/mysql\n    restart: unless-stopped\n    networks:\n      - internal\n\n  redis:\n    image: redis:7-alpine\n    restart: unless-stopped\n    networks:\n      - internal\n<\/code><\/pre>\n<h3><span id=\"Nginx_server_with_TLS_and_FastCGI\">Nginx server with TLS and FastCGI<\/span><\/h3>\n<p>This example expects you to populate certs in \/etc\/nginx\/certs. You can do that via Certbot on the host, acme.sh, or a sidecar container that renews and writes to the shared volume. The TLS bit is intentionally basic\u2014no rocket science required.<\/p>\n<pre class=\"language-nginx line-numbers\"><code class=\"language-nginx\">server {\n  listen 80;\n  server_name example.com;\n  return 301 https:\/\/$host$request_uri;\n}\n\nserver {\n  listen 443 ssl http2;\n  server_name example.com;\n\n  ssl_certificate     \/etc\/nginx\/certs\/fullchain.pem;\n  ssl_certificate_key \/etc\/nginx\/certs\/privkey.pem;\n\n  root \/var\/www\/html;\n  index index.php index.html;\n\n  location \/ {\n    try_files $uri $uri\/ \/index.php?$args;\n  }\n\n  location ~* .(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf)$ {\n    access_log off;\n    expires 7d;\n    add_header Cache-Control public;\n    try_files $uri =404;\n  }\n\n  location ~ .php$ {\n    include fastcgi_params;\n    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;\n    fastcgi_pass php:9000;\n    fastcgi_read_timeout 300s;\n  }\n\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;\n}\n<\/code><\/pre>\n<p>With Nginx at the edge, you have one fewer moving piece compared to Traefik. The trade-off is that you\u2019ll do more by hand\u2014especially ACME and virtual hosts if you add multiple domains. For folks who like to read their configs top to bottom and know exactly what\u2019s happening, that\u2019s a feature, not a bug.<\/p>\n<h2 id=\"section-5\"><span id=\"Production_Ops_TLS_Backups_Security_Monitoring\">Production Ops: TLS, Backups, Security, Monitoring<\/span><\/h2>\n<p>Once the app is up, the real game begins: keeping it <strong>boring<\/strong>. Boring in a good way. The kind where updates happen mid-week and you still go to lunch on time. Here\u2019s how I make that happen on a single VPS.<\/p>\n<h3><span id=\"TLS_and_ACME_that_you_dont_dread\">TLS and ACME that you don\u2019t dread<\/span><\/h3>\n<p>If you\u2019re on Traefik, ACME is largely handled by labels and a resolver. Make sure port 80 is open for the HTTP-01 challenge, and confirm your DNS A record points at the VPS. It\u2019s worth five minutes to scan <a href=\"https:\/\/doc.traefik.io\/traefik\/https\/acme\/\" rel=\"nofollow noopener\" target=\"_blank\">Traefik\u2019s ACME docs<\/a> so you know where certs are stored and how renewals are logged. On Nginx, pick a renewal tool you like\u2014Certbot or acme.sh are both fine\u2014and script it so you don\u2019t rely on memory the next time a cert expires.<\/p>\n<h3><span id=\"Backups_you_can_restore_in_your_sleep\">Backups you can restore in your sleep<\/span><\/h3>\n<p>Backups are a story you only care about after the plot twist. I learned this the hard way when a plugin update went sideways and a client asked how fast we could rewind. On a single VPS with Docker, your targets are clear: the database volume and the wp-content volume. My nightly routine is a mysqldump to a timestamped file, plus a tarball of wp-content, both shipped off-server. Weekly, I test a restore to a little throwaway VM or a spare port on the same VPS with the DNS turned off\u2014just to make sure the process is a muscle, not a fantasy.<\/p>\n<p>If you\u2019re curious about resilient, application-consistent database snapshots\u2014especially when you graduate beyond mysqldump\u2014have a look at <a href=\"https:\/\/www.dchost.com\/blog\/en\/uygulama%e2%80%91tutarli-yedekler-nasil-alinir-lvm-snapshot-ve-fsfreeze-ile-mysql-postgresqli-usutmeden-dondurmak\/\">this deep dive on taking application-consistent hot backups with LVM snapshots<\/a>. It pairs nicely with a containerized stack, and it\u2019s saved me more than once.<\/p>\n<h3><span id=\"Security_that_doesnt_slow_you_down\">Security that doesn\u2019t slow you down<\/span><\/h3>\n<p>Think in layers. Only the proxy should bind public ports. Put the database and Redis on the internal network only. Set strong database credentials and avoid exposing phpMyAdmin in the open unless you\u2019ve put it behind auth and IP allowlists. File permissions matter: the container that writes to wp-content should be the only one with write permissions there; everything else reads. Most official images run as non-root internally now, but double-check and set the user when in doubt.<\/p>\n<p>At the proxy layer, add sane security headers, limit request body sizes, and consider a rate limit for login and XML-RPC endpoints. On Nginx it\u2019s a few lines; on Traefik, middlewares make it easy. For WordPress itself, disable file editing in the dashboard, keep themes and plugins lean, and update routinely. The best security control is fewer moving parts\u2014and that includes fewer plugins.<\/p>\n<h3><span id=\"Monitoring_and_logs_that_tell_a_story\">Monitoring and logs that tell a story<\/span><\/h3>\n<p>You don\u2019t need a full SIEM to keep an eye on a single VPS. Start with container health checks and logs. Most issues show up as a failing healthcheck or a growing error log. I like collecting logs locally with rotation and shipping important ones to a simple remote target or a hosted log service. When you\u2019re ready, toss in a lightweight metrics stack or a hosted monitor to watch CPU, memory, and response times. Alerts should be specific: if the database goes down, tell me. Don\u2019t page me for normal traffic spikes\u2014the point of this setup is to handle those calmly.<\/p>\n<h2 id=\"section-6\"><span id=\"Deploying_Changes_Without_Drama_BlueGreen_on_One_VPS\">Deploying Changes Without Drama: Blue\/Green on One VPS<\/span><\/h2>\n<p>Here\u2019s a trick I wish I\u2019d learned sooner. Even on a single VPS, you can do a simple blue\/green release by running a second version of the app on a different internal port and switching the reverse proxy route when it\u2019s ready. With Traefik, that\u2019s as easy as spinning up a second nginx-php pair with labels that point to a different service name, validating on a hidden hostname, then flipping the main router label. With Nginx, you swap upstreams and reload. It\u2019s not high ceremony, but it\u2019s clean.<\/p>\n<p>For WordPress core updates, I prefer baking updates into the image or running updates during a brief maintenance window. For plugin\/theme updates, batch and test. One of my clients once ran twenty-five plugins because \u201cmore features.\u201d We trimmed that by half. The site ran faster, the admin was lighter, and updates stopped feeling like roulette.<\/p>\n<h3><span id=\"Scaling_up_from_here\">Scaling up from here<\/span><\/h3>\n<p>A single VPS can carry a lot if it\u2019s tuned well: Redis object caching, PHP-FPM pool sizing that matches your memory, and a reverse proxy that isn\u2019t doing cartwheels. If you do hit the ceiling, the next moves are predictable: front with a CDN, migrate the database to a managed service, or split the proxy and app across two small VPSes. Because you\u2019ve containerized everything, rehoming pieces is more of a logistics ride than an overhaul.<\/p>\n<h2 id=\"section-7\"><span id=\"A_Few_Notes_That_Make_Real_Life_Easier\">A Few Notes That Make Real Life Easier<\/span><\/h2>\n<p>Let me share a handful of tiny decisions that have saved me hours:<\/p>\n<p>First, keep your .env secrets out of your repo, and if you can, move database passwords to Docker secrets mounted as files your entrypoint reads. It\u2019s not perfection, but it\u2019s better than piling secrets into Git. Second, resist auto-updaters like Watchtower on production WordPress; it\u2019s tempting, but auto-upgrading the wrong thing on a Saturday morning is a bad magic trick. Schedule updates and know what changed. Third, try not to layer too many caches. Redis object caching is great. A CDN can help. A full-page cache inside Nginx can be powerful\u2014but only add it with intention so you don\u2019t chase ghost invalidations.<\/p>\n<p>Finally, document your runbook. \u201cHow do we restore? How do we rotate certs? How do we redeploy?\u201d Even if it\u2019s just a README in your repo, writing it once makes your future self very happy. I keep mine in plain text with copy-paste commands. There\u2019s no prize for doing it from memory.<\/p>\n<h2 id=\"section-8\"><span id=\"Putting_It_All_Together_Your_Next_Steps\">Putting It All Together: Your Next Steps<\/span><\/h2>\n<p>Here\u2019s how I\u2019d approach this tomorrow if I were starting from scratch. Choose Traefik if you want automated routing and certs without fiddling; choose Nginx if you want to handcraft everything and you\u2019re comfortable with ACME tooling. Create two Docker networks\u2014proxy\/web and internal. Stand up the database and Redis first and make sure they\u2019re healthy. Then bring up PHP-FPM and Nginx (or just Nginx+FPM if it\u2019s the classic path). Point your DNS to the VPS, open ports 80\/443, and ensure the ACME flow works. Add a firewall rule to keep everything else closed.<\/p>\n<p>Then install WordPress, lock down the basics, and take your first backup the minute it\u2019s clean. Try restoring to a temporary directory or a separate stack name. Once that\u2019s muscle memory, you\u2019re not stuck\u2014you\u2019re confident. That\u2019s the difference. From there, rinse and repeat: small updates, small tests, small wins.<\/p>\n<h2 id=\"section-9\"><span id=\"Common_Troubleshooting_Moments_and_Quick_Fixes\">Common Troubleshooting Moments (and Quick Fixes)<\/span><\/h2>\n<p>I\u2019ve bumped into a few patterns worth keeping handy:<\/p>\n<p>When ACME fails on Traefik, it\u2019s almost always DNS or port 80. Double-check your A record, ensure no other service is binding port 80, and watch the Traefik logs; they\u2019re usually clear about what\u2019s wrong. If the WordPress container can\u2019t reach the database, test connectivity with a quick ping or mysql client inside the network. Docker DNS is reliable, but typos in service names happen. If uploads fail or are truncated, bump client_max_body_size in Nginx and make sure PHP\u2019s post_max_size and upload_max_filesize are higher than your actual uploads.<\/p>\n<p>For performance worries, start simple: enable Redis object caching, tune PHP-FPM max_children to match your memory, and watch slow logs for a day before reaching for exotica. And if something feels off after an update, roll back fast and investigate calmly. You don\u2019t have to solve a mystery during peak traffic.<\/p>\n<h2 id=\"section-10\"><span id=\"Wrap-Up_A_Calm_Reliable_WordPress_on_One_VPS\">Wrap-Up: A Calm, Reliable WordPress on One VPS<\/span><\/h2>\n<p>Containerizing WordPress on a single VPS isn\u2019t about being trendy. It\u2019s about getting control over the stack so you can sleep a little better. With Docker, Traefik or Nginx, and a few clean habits\u2014backups you\u2019ve tested, minimal plugins, reasonable caching\u2014you can run a production site that behaves even when traffic isn\u2019t polite. I\u2019ve watched nervous teams relax once they saw how predictable updates and rollbacks became. It\u2019s not magic. It\u2019s just putting each piece where it belongs.<\/p>\n<p>If you\u2019re migrating from a tangle of shared hosting or a one-off VPS build, take it step by step. Start with the baseline, get it stable, then layer on the polish. And when in doubt, aim for boring. Boring is fast, boring is safe, and boring gets you home for dinner.<\/p>\n<p>Hope this was helpful. If you have questions or want me to take a look at your compose file, ping me. See you in the next post.<\/p>\n<\/div>","protected":false},"excerpt":{"rendered":"<p>So there I was, staring at a client\u2019s WooCommerce store late on a Friday evening, feeling that familiar hum of anticipation and mild dread. A flash sale was about to go live, and their site was still on a tiny shared host. You can probably guess what happened the last time: the homepage took forever, [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":2005,"comment_status":"","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[26],"tags":[],"class_list":["post-2004","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\/2004","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=2004"}],"version-history":[{"count":0,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/posts\/2004\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/media\/2005"}],"wp:attachment":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/media?parent=2004"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/categories?post=2004"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/tags?post=2004"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}