You clone a repository onto a new Ubuntu VPS or spin up a Docker container, run composer install, open the browser or execute an Artisan command, and get hit with a fatal crash:
The stream or file "/var/www/html/storage/logs/laravel.log" could not be opened in append mode: Failed to open stream: Permission denied The exception occurred while attempting to log: The stream or file ... In many cases, the browser screen goes entirely blank or throws a 500 Internal Server Error without rendering an exception page at all. That happens because Laravel crashed while trying to write the error itself to storage/logs/laravel.log.
The most common advice found in old forum threads is to run chmod -R 777 storage. Do not do this on any server connected to the internet. Making your storage directory world-writable allows any local process—or an attacker exploiting an arbitrary file upload vulnerability—to execute code directly within your web root.
Here is why this error happens and how to fix it permanently using correct Linux group ownership.
Quick Diagnostic Reference
Identify where the error is occurring to apply the right fix:
| Environment / Trigger | Underlying Cause | Target Solution |
|---|---|---|
| Fresh Ubuntu / Debian deployment (Nginx/Apache) | Web server user (www-data) does not own or have write access to storage | Fix 1 |
| Error appears only after running Artisan CLI commands | CLI ran as root, creating log files www-data cannot write to | Fix 2 |
| Docker container or Laravel Sail volume mount | Host UID (1000) mismatches container PHP-FPM user UID (33 or 82) | Fix 3 |
| Rocky Linux / AlmaLinux / RHEL / CentOS | SELinux security context blocks web server file writes | Fix 4 |
| Error returns at midnight every day | Daily log rotation creates new log files with restrictive 0644 umask | Fix 5 |
1. The Production Fix for Ubuntu / Debian (Nginx & Apache)
In modern web servers, two different users interact with your project:
- Your deployer/SSH user (e.g.,
ubuntu,deploy, or your personal account): needs permission to edit code and run Git/Composer commands. - The web server process (
www-datafor Nginx + PHP-FPM or Apache): needs write access to write session files, cached Blade templates, uploaded assets, and log entries.
Instead of making everything world-writable, add your deployment user to the www-data group, transfer group ownership of storage and bootstrap/cache, and set directory inheritance.
Run these commands inside your project root (/var/www/your-app):
# 1. Add your current user to the web server group sudo usermod -a -G www-data $USER # 2. Assign user ownership to you, and group ownership to www-data sudo chown -R $USER:www-data storage bootstrap/cache # 3. Give both user and group full read/write permissions sudo chmod -R 775 storage bootstrap/cache # 4. Set the SGID (Set Group ID) bit on directories sudo find storage bootstrap/cache -type d -exec chmod g+s {} + Why the chmod g+s command is essential:
Normally, when Laravel creates a new folder inside storage/framework/views/ or storage/framework/cache/, it assigns ownership based on whichever process created it. The chmod g+s (Set Group ID) command forces every new subfolder created in the future to automatically inherit the www-data group, preventing permissions from breaking later on.
2. The CLI vs. Web Server Collision (The Midnight / Artisan Bug)
A frequent scenario: your site works fine, you SSH into the server to run php artisan queue:work or php artisan migrate as root (or with sudo), and suddenly the entire site goes down with a Permission denied error.
Why this happens:
When you run an Artisan command as root, Monolog writes to storage/logs/laravel.log as the root user. The log file ownership changes to root:root with -rw-r--r-- permissions. Minutes later, a regular website visitor hits your API. PHP-FPM attempts to log an event as www-data, gets blocked by the root-owned log file, and throws a 500 crash.
(Note: If a browser request triggers this crash during an API call, it often disguises itself as a CORS error because the 500 response fails before attaching headers. See our guide on Fixing Laravel CORS Preflight Errors for diagnosing those crashes).
How to fix it:
First, restore ownership of the affected log file:
sudo chown -R $USER:www-data storage/logs/ sudo chmod -R 775 storage/logs/ Second, never run production Artisan commands as root. Run scheduled jobs and queue workers under the www-data user:
# In your Crontab or Supervisor configuration, run as www-data: sudo -u www-data php /var/www/your-app/artisan schedule:run 3. Fixing Docker & Container Volume Mounts
When running Laravel inside Docker (or customized Docker Compose stacks), you often mount a local directory from your host machine into /var/www/html inside the container:
volumes: - ./:/var/www/html On Linux host systems, file ownership is managed by numerical User IDs (UID) and Group IDs (GID). Your host machine user is typically 1000:1000, while the Alpine or Debian PHP-FPM user inside the container is typically 33:33 (Debian/Ubuntu) or 82:82 (Alpine).
Because the container process does not match the UID of the host folder owner, PHP cannot write to storage/logs.
The Clean Fix in Docker Compose:
Ensure your container runs PHP-FPM under your host machine's user ID by defining user in docker-compose.yml:
services: app: build: context: . dockerfile: Dockerfile user: "${UID:-1000}:${GID:-1000}" volumes: - ./:/var/www/html If your Dockerfile runs PHP-FPM directly as www-data, add this build step to your Dockerfile to synchronize the container's internal UID with your host user:
ARG UID=1000 ARG GID=1000 RUN usermod -u ${UID} www-data && groupmod -g ${GID} www-data 4. SELinux Blocking Access (RHEL / AlmaLinux / Rocky Linux)
If you are running an enterprise Linux distribution (Red Hat, AlmaLinux, Rocky Linux, or Fedora) with SELinux in Enforcing mode, standard Linux permissions (chmod and chown) are not enough.
Even if you run chmod -R 777, SELinux's mandatory access control layer intercepts Apache or Nginx (httpd_t) and blocks it from writing to files labeled as generic user content (user_home_t or default_t).
Check if SELinux is actively blocking your web server:
sestatus If Current mode is enforcing, apply the correct SELinux security context flag (httpd_sys_rw_content_t) to the writable directories:
# 1. Update the SELinux policy definition permanently sudo semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/your-app/storage(/.*)?" sudo semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/your-app/bootstrap/cache(/.*)?" # 2. Relabel the directory tree immediately sudo restorecon -Rv /var/www/your-app/storage sudo restorecon -Rv /var/www/your-app/bootstrap/cache 5. Configure Explicit File Permissions in config/logging.php
If your project uses the daily logging channel instead of single, Monolog creates a brand-new log file every night at midnight (laravel-2026-10-24.log).
If PHP-FPM's internal umask defaults to restrictive permissions, newly spawned daily log files can lock out CLI workers.
Open config/logging.php, scroll to your active channel (usually single or daily), and explicitly declare the permission mode:
'channels' => [ 'daily' => [ 'driver' => 'daily', 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'days' => 14, 'permission' => 0664, // Ensures both owner and group can read/write ], 'single' => [ 'driver' => 'single', 'path' => storage_path('logs/laravel.log'), 'level' => env('LOG_LEVEL', 'debug'), 'permission' => 0664, ], ], Then clear configuration cache so the logging driver registers the permission parameters:
php artisan config:clear Verification: Confirm Your Permissions
Verify the current state of your files in the terminal:
ls -la storage/ ls -la storage/logs/ A healthy output should display your user as the owner, www-data as the group, and drwxrwsr-x permissions on directories:
drwxrwsr-x 5 ubuntu www-data 4096 Oct 24 22:30 . drwxr-xr-x 12 ubuntu www-data 4096 Oct 24 22:00 .. drwxrwsr-x 2 ubuntu www-data 4096 Oct 24 22:30 app drwxrwsr-x 6 ubuntu www-data 4096 Oct 24 22:30 framework drwxrwsr-x 2 ubuntu www-data 4096 Oct 24 22:30 logs Notice the s in drwxrwsr-x: that confirms your SGID bit is active and every log or cache file written by background jobs or HTTP requests will preserve group write access.


