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?