0

I’m running a WordPress site (https://example.com) behind Traefik on a VPS using rootless Docker (Docker daemon socket is /run/user/1000/docker.sock).

Everything works fine except updating plugins from wp-admin. Any plugin update (not just one specific plugin) fails with errors like:

Updating Plugin ACF Content Analysis for Yoast SEO (1/24) Downloading update from https://downloads.wordpress.org/plugin/acf-content-analysis-for-yoast-seo.3.2.zip… An error occurred while updating ACF Content Analysis for Yoast SEO: Download failed. Destination directory for file streaming does not exist or is not writable.

Later attempts produced variations like:

PCLZIP_ERR_MISSING_FILE (-4) : Missing archive file '/var/www/html/wp-content/upgrade/…zip'

Could not create directory ...

Could not create the upgrade-temp-backup directory

Finally: The update cannot be installed because some files could not be copied. This is usually due to inconsistent file permissions.

So basically: all plugin updates fail at different filesystem steps (download, unpack, backup, or copy).

Environment

  • Ubuntu VPS
  • Docker rootless (runs as non-root user, socket at
  • /run/user/1000/docker.sock)
  • Traefik reverse proxy + Cloudflare DNS-01
  • WordPress wordpress:php8.3-fpm behind nginx:alpine
  • MariaDB 11.8
  • Codebase bind-mounted from host: ./html:/var/www/html

Traefik compose (relevant parts):

services: traefik: image: traefik:latest container_name: traefik restart: unless-stopped security_opt: - no-new-privileges:true command: - --global.checkNewVersion=true - --global.sendAnonymousUsage=false # Providers - --providers.docker=true - --providers.docker.endpoint=unix:///var/run/docker.sock - --providers.docker.exposedByDefault=false - --providers.docker.network=web - --providers.file.directory=/dynamic - --providers.file.watch=true # Entrypoints - --entrypoints.web.address=:80 - --entrypoints.web.http.redirections.entryPoint.to=websecure - --entrypoints.web.http.redirections.entryPoint.scheme=https - --entrypoints.websecure.address=:443 - --entrypoints.websecure.http.tls=true - --entrypoints.websecure.http.tls.certResolver=cfdns # TLS Options - --entrypoints.websecure.http.tls.options=default # API - --api.insecure=true - --api.dashboard=true # Logging - --log.level=INFO - --accessLog=true # ACME via Cloudflare DNS - [email protected] - --certificatesResolvers.cfdns.acme.storage=/letsencrypt/acme.json - --certificatesResolvers.cfdns.acme.keyType=EC256 - --certificatesResolvers.cfdns.acme.dnsChallenge.delayBeforeCheck=30 - --certificatesResolvers.cfdns.acme.dnsChallenge.resolvers=1.1.1.1:53,8.8.8.8:53 - --certificatesResolvers.cfdns.acme.dnsChallenge.provider=cloudflare - --ping=true ports: - "80:80" - "443:443" - "127.0.0.1:8080:8080" environment: - CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN} volumes: - /run/user/1000/docker.sock:/var/run/docker.sock:ro - ./dynamic:/dynamic:ro - ./letsencrypt:/letsencrypt - ./logs:/var/log/traefik networks: - web networks: web: external: true 

Dynamic Traefik config (security headers + TLS options):

http: middlewares: security-headers: headers: stsSeconds: 31536000 stsIncludeSubdomains: true stsPreload: true forceSTSHeader: true contentTypeNosniff: true referrerPolicy: no-referrer-when-downgrade browserXssFilter: true permissionsPolicy: >- geolocation=(), microphone=(), camera=(), fullscreen=(self) gzip: compress: {} rate-limit: rateLimit: average: 100 burst: 50 tls: options: default: minVersion: VersionTLS12 cipherSuites: - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 sniStrict: true curvePreferences: - CurveP521 - CurveP384 

WordPress stack (example) compose:

networks: web: # external, only for Traefik <-> nginx external: true example_net: # internal, unique to this stack external: false volumes: redisdata: nginx_cache: {} services: db: image: mariadb:11.8 restart: unless-stopped environment: MARIADB_DATABASE: ${MARIADB_DATABASE} MARIADB_USER: ${MARIADB_USER} MARIADB_PASSWORD: ${MARIADB_PASSWORD} MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD} command: ["--transaction-isolation=READ-COMMITTED", "--binlog_expire_logs_seconds=2592000", "--innodb_buffer_pool_size=512M"] volumes: - ./db:/var/lib/mysql networks: [example_net] healthcheck: test: ["CMD-SHELL", "mariadb-admin ping -uroot -p${MARIADB_ROOT_PASSWORD} --silent"] interval: 10s timeout: 5s retries: 5 wordpress: image: wordpress:php8.3-fpm restart: unless-stopped environment: WORDPRESS_DB_HOST: db:3306 WORDPRESS_DB_NAME: ${MARIADB_DATABASE} WORDPRESS_DB_USER: ${MARIADB_USER} WORDPRESS_DB_PASSWORD: ${MARIADB_PASSWORD} WP_REDIS_HOST: redis WP_REDIS_PORT: 6379 volumes: - ./html:/var/www/html # bind mount from host depends_on: db: condition: service_healthy networks: [example_net] nginx: image: nginx:alpine restart: unless-stopped depends_on: - wordpress volumes: - ./html:/var/www/html:ro - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./cache:/var/cache/nginx - nginx_cache:/var/cache/nginx networks: [web, example_net] labels: - "traefik.enable=true" - "traefik.docker.network=web" # HTTP → HTTPS redirect - "traefik.http.middlewares.example-redirect.redirectscheme.scheme=https" - "traefik.http.middlewares.example-redirect.redirectscheme.permanent=true" - "traefik.http.routers.example-https.middlewares=security-headers@file,gzip@file,rate-limit@file" # HTTP router - "traefik.http.routers.example-http.rule=Host(`example.com`) || Host(`www.example.com`)" - "traefik.http.routers.example-http.entrypoints=web" - "traefik.http.routers.example-http.middlewares=example-redirect" - "traefik.http.routers.example-http.service=example" # HTTPS router - "traefik.http.routers.example-https.rule=Host(`example.com`) || Host(`www.example.com`)" - "traefik.http.routers.example-https.entrypoints=websecure" - "traefik.http.routers.example-https.tls=true" - "traefik.http.routers.example-https.tls.certresolver=cfdns" - "traefik.http.routers.example-https.service=example" # Explicit service (points at Nginx in the container) - "traefik.http.services.example.loadbalancer.server.port=80" redis: image: redis:7.4-alpine restart: unless-stopped command: ["redis-server", "--save", "", "--appendonly", "no"] volumes: - redisdata:/data networks: [example_net] 

Nginx virtual host:

fastcgi_cache_key "$scheme$request_method$host$request_uri"; server { listen 80; server_name _; root /var/www/html; index index.php index.html; # Skip cache for logged-in, POST, search, preview, feeds set $skip_cache 0; if ($request_method = POST) { set $skip_cache 1; } if ($query_string != "") { set $skip_cache 1; } if ($http_cookie ~* "comment_author|wordpress_logged_in|wp-postpass") { set $skip_cache 1; } if ($request_uri ~* "/wp-admin/|/wp-login.php|/xmlrpc.php|/feed|/preview=") { set $skip_cache 1; } location / { try_files $uri $uri/ /index.php?$args; } location ~ \.php$ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_pass wordpress:9000; fastcgi_cache WPFC; fastcgi_cache_valid 200 301 302 10m; fastcgi_cache_min_uses 2; add_header X-FastCGI-Cache $upstream_cache_status; fastcgi_no_cache $skip_cache; fastcgi_cache_bypass $skip_cache; fastcgi_read_timeout 120s; fastcgi_buffers 16 16k; fastcgi_buffer_size 32k; } location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|webp|avif)$ { expires 30d; access_log off; } client_max_body_size 64m; } 

What I already tried On the host (as the deploy user that runs rootless Docker), I tried:

Changing ownership of the bind-mounted folder:

sudo chown -R 33:33 ./html # www-data sudo chown -R deploy:deploy ./html 

Adjusting permissions:

find html -type d -exec chmod 755 {} \; find html -type f -exec chmod 644 {} \; chmod 777 html/wp-content chmod -R 777 html/wp-content/plugins chmod -R 777 html/wp-content/upgrade chmod -R 777 html/wp-content/upgrade-temp-backup chmod -R 777 html/wp-content/uploads 

Fixing wp-config.php to ensure:

define('FS_METHOD', 'direct'); 

is set before require_once ABSPATH . 'wp-settings.php';

Checking WP_TEMP_DIR (removed any old values from previous hosting).

Even after these changes, any plugin update still fails, often with:

The update cannot be installed because some files could not be copied. This is usually due to inconsistent file permissions.

So this affects all plugins, not just ACF/Yoast.

Question

Given that this is:

  • WordPress in wordpress:php8.3-fpm
  • Behind Nginx and Traefik
  • Running in rootless Docker
  • With the WP files bind-mounted from the host (./html:/var/www/html)

What is the correct, safe, and permanent way to configure file ownership/permissions (or volumes) so that:

WordPress plugin/theme/core updates from wp-admin work reliably for all plugins, and

I don’t have to chmod 777 everything or manually delete/reinstall plugins every time?

In particular, is there a recommended pattern for WordPress + rootless Docker (named volumes vs bind mounts, which user should own /var/www/html, etc.) that avoids these “Destination directory not writable / inconsistent permissions” errors altogether?

1

1 Answer 1

0

In particular, is there a recommended pattern for WordPress + rootless Docker (named volumes vs bind mounts, which user should own /var/www/html, etc.) that avoids these “Destination directory not writable / inconsistent permissions” errors altogether?

While you're asking for a recommendation of a pattern, what you actually ask about is the opposite of a pattern: You have a highly individual development problem of an application that (I must admit) is labour intensive to containerize, especially if your question is really asking about creating production containers here.

I'd recommend you divide your problem to conquer it:

  1. Find out which requirements you have for development. Defer any requirements for production: You are already prepared for that as you have decided to containerize.

  2. Setup your project for your development.

  3. Learn about the gaps your development system has. This is normal, if it would not have had any gaps, no development would be necessary.

  4. Address those gaps by bridging over them from right-to-left, where left is your development system and everything to the right from there is everything else:

     manual development (edit) -> build automation -> packaging -> staging* *staging is a quality gate (you say this revision OK otherwise you're not going further on to the right ->) -> deployment (at least one real one) -> run -> destroy 

In context of your question: While you are developing your WordPress container manually (step one, edit), you have found out that in it's development form for running it (step five in my roughly drawn sketch, run) requires ongoing code changes.

Therefore, the ongoing changes need to be accounted for in the development version as otherwise you would develop around and not the application.

Where do we have seen this before? Right, installing the plugin dependencies works exactly the same as installing the main dependency (WordPress). That is downloading and unzipping a zip file, a problem longtime solved.

So the pattern is, we put this into the build of the project. Depending on how you manage your build, you put this into the appropriate files. From the little files you've shared for your early development project, I've inferred that you so far have only one configuration file to configure one docker composition in which no Dockerfile is referenced. You also have not shared a Makefile therefore I'd assume you do not use a build manager yet.

As the build manager is central in any development project, I'd suggest you start by deciding which build manager you want to use and then by instrumenting it with the instructions to obtain your projects dependencies.

The problem with the mounts you can defer because this is only a problem on the target system, but unless you do not have your project with a managed build, it makes little sense to address it in terms of software development.

As written earlier, this deferring is fine, because we focus on the development now after the planning has already been done.

There is another reason: The not managed build you currently have makes any target - static or dynamic - a moving target already. As build is the second step above and you are currently at the first step (edit), this is the next thing you have to do if you want to get over the existing gap towards destroying a really running application.

Sign up to request clarification or add additional context in comments.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.