File System and Private Files
|
This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production. This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages. |
Every Drupal site handles files: hero images for exhibitions, PDF catalogues, audio guides, and documents that must never
be public. Drupal does not treat a file as a path on disk. It addresses a file through a URI whose scheme picks the
storage (public://, private://, temporary://) and records every file it manages as a file entity with a usage
count. This page explains the schemes and the stream wrappers behind them, the settings that point them to a directory,
managed and unmanaged files, how Drupal decides who may download a private file, how uploads are validated and their
names sanitised, how temporary files are cleaned up, and how to keep the files consistent when the site runs on more than
one machine. It was checked against the official documentation on 2026-10-30 and written for Drupal 11.4.x with PHP 8.3
or later. Drupal 10 reaches its end of life on 2026-12-09, so new sites should not start on it, and Drupal 12.0.0 is
scheduled for the week of 2026-12-07; the version notes below say what changes there.
The running example is the Meridian Museum: exhibition images are public, while scanned loan agreements and unpublished catalogue drafts are private. Related pages are Media and the Media Library, Display modes and image styles, settings.php and environments, Security hardening, Cron, queues and maintenance, Docker and containers and Backups and disaster recovery.
Schemes and stream wrappers
PHP lets code open scheme://path locations through stream wrappers. Drupal builds on that: a URI such as
public://exhibitions/hero.jpg is turned into a real location (a local path, an HTTP URL, an object-store key) by the
stream wrapper registered for the public scheme. Code that always works with URIs and never with absolute paths keeps
working when the storage moves.
| Scheme | Where it points | Typical use |
|---|---|---|
|
|
Images, CSS and JavaScript aggregates, image-style derivatives, anything every visitor may fetch directly from the web server |
|
The directory set with |
Files whose download Drupal must authorise: loan agreements, member-only PDFs, drafts |
|
The system temporary directory ( |
Uploads in progress, export files, scratch data; not meant to survive a request or a server restart |
|
A directory below the public path used for generated assets such as aggregated CSS and JavaScript |
Built by the asset system; you rarely write to it |
The base classes are Drupal\Core\StreamWrapper\PublicStream, PrivateStream and TemporaryStream, and a wrapper is
registered by tagging a service with stream_wrapper (the scheme is a tag attribute). The File system settings page at
/admin/config/media/file-system shows the resolved public, private and temporary paths, and the status report warns
when a configured directory is missing or not writable.
Because public:// files are served by the web server without starting PHP, nothing in Drupal can refuse them: anyone
who knows or guesses the URL gets the file. Use the public scheme only for content that is meant to be public.
Configuring the directories
The three paths are settings, not configuration: they live in settings.php (see
settings.php and environments) so that they can differ per
environment and are not exported with the configuration.
<?php
// web/sites/default/settings.php
// Public files: the default is sites/default/files; change it only when you must.
$settings['file_public_path'] = 'sites/default/files';
// Private files: outside the web root. In a project created from drupal/recommended-project
// the web root is web/, so ../private sits next to it.
$settings['file_private_path'] = $app_root . '/../private';
// Temporary files: defaults to the PHP temporary directory.
$settings['file_temp_path'] = '/tmp';
// Permissions given to directories and files Drupal creates.
$settings['file_chmod_directory'] = 0775;
$settings['file_chmod_file'] = 0664;
Create the directory before pointing to it, and make it writable by the web server user only:
mkdir -p private/loans
# Owned by the account PHP-FPM runs as; no access for others.
chmod 750 private
ddev drush cache:rebuild
ddev drush status-report --severity=1 # or open /admin/reports/status
Source: Drush core:status and File module documentation
Common pitfalls with these settings:
-
The private path does not exist yet. Drupal does not create it. Until the setting is present and the directory is writable, the
private://scheme is simply unavailable, and the field storage form hides the "Private files" choice. -
The private path is inside the web root. Drupal writes an
.htaccessthat denies direct access when it can, but that file is honoured by Apache only. On nginx, or whenAllowOverrideisNone, the directory is open. Keep it outside the web root, or add an explicit deny rule. -
A changed path is not picked up. Rebuild caches after changing
file_private_path, because the stream wrapper registry and cached URLs hold the old value. -
Different values on different machines. Every web server of the same site must see the same files at the same paths; see Scaling out.
-
Containers. In DDEV the project root is mounted at
/var/www/html, so../privateresolves next toweb/and persists with the project. In an image-based deployment the directory must be a mounted volume or remote storage, otherwise uploads vanish with the container.
A web-server rule that keeps a misplaced private directory closed:
# Only needed when the private directory is below the web root.
location ~ ^/sites/default/private/ {
deny all;
return 403;
}
Source: Web server configuration
Managed and unmanaged files
Drupal distinguishes two ways of handling files.
Managed files are recorded in the database. The file module defines the file content entity (table file_managed)
with the fields uri, filename, filemime, filesize, status and uid, plus a file_usage table that records
which entity uses which file. Upload widgets (managed_file, image and file fields, the Media Library) always create
managed files. Because the file is an entity it has an owner, can be loaded in Views and JSON:API, fires entity hooks and
takes part in access checks.
Unmanaged files are plain files on disk that Drupal does not track: a generated sitemap, a cached export, a log. Use the
file_system service for them and take responsibility for cleaning up.
The file system service
The file_system service (Drupal\Core\File\FileSystemInterface) is the API for working with files on disk. Its
important methods are:
| Method | Purpose |
|---|---|
|
Checks that a directory exists and is writable; with |
|
Writes a string to a URI and returns the final URI |
|
Copy or move a file between URIs |
|
Remove a file or a directory tree |
|
The local path of a URI; returns |
|
Locate the temporary directory and create a unique temporary file |
|
Moves a PHP upload into place |
|
Makes a name that does not collide with an existing file |
The third argument of copy(), move() and saveData() decides what happens when the destination exists. Since Drupal
10.3 it is the Drupal\Core\File\FileExists enum with the cases Rename, Replace and Error. The constants
FileSystemInterface::EXISTS_RENAME, EXISTS_REPLACE and EXISTS_ERROR are deprecated since 10.3.0 and are listed
for removal in Drupal 12.0.0, so new code uses the enum.
Creating a managed file from code
The following service belongs to the meridian_collection module. It stores a scanned loan agreement in the private
scheme and registers its usage, so the file survives the temporary-file cleanup.
# web/modules/custom/meridian_collection/meridian_collection.services.yml
services:
meridian_collection.loan_scans:
class: Drupal\meridian_collection\LoanScanStorage
arguments:
- '@file_system'
- '@file.repository'
- '@file.usage'
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
use Drupal\Core\File\FileExists;
use Drupal\Core\File\FileSystemInterface;
use Drupal\file\FileInterface;
use Drupal\file\FileRepositoryInterface;
use Drupal\file\FileUsage\FileUsageInterface;
/**
* Stores scanned loan agreements in the private file system.
*/
final class LoanScanStorage {
public function __construct(
private readonly FileSystemInterface $fileSystem,
private readonly FileRepositoryInterface $fileRepository,
private readonly FileUsageInterface $fileUsage,
) {}
/**
* Saves PDF bytes for a collection object and records which entity uses them.
*/
public function save(string $pdfBytes, string $accessionNumber, int $entityId): FileInterface {
$directory = 'private://loans';
// Create the directory (and fix its permissions) if it is missing.
$this->fileSystem->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
// writeData() saves the bytes, creates the file entity and avoids name collisions.
$file = $this->fileRepository->writeData($pdfBytes, "$directory/loan-$accessionNumber.pdf", FileExists::Rename);
// Without usage the file stays temporary and cron deletes it.
$file->setPermanent();
$file->save();
$this->fileUsage->add($file, 'meridian_collection', 'collection_object', $entityId);
return $file;
}
}
The file.repository service (FileRepositoryInterface) is the entity-aware counterpart of file_system: writeData(),
copy(), move() and loadByUri() create or update the file entity together with the bytes. Calling
file_system->saveData() alone creates an unmanaged file, which is a frequent source of orphans.
File usage and the file status
A managed file has a status: temporary (0) or permanent (1). A new upload is temporary until something claims it. The
file.usage service (Drupal\file\FileUsage\FileUsageInterface) keeps the claims: add($file, $module, $type, $id, $count),
delete() and listUsage($file). The columns are the file ID, the module that registered the usage, the entity type
and ID that uses it, and a count. Field widgets add and remove usage automatically when an entity is saved, deleted or
loses a revision; custom code that stores a file ID by hand must call add() itself.
Two settings control the cleanup, both editable at /admin/config/media/file-system or with Drush:
-
system.file:temporary_maximum_ageis the age in seconds after which cron deletes temporary files. The default is 21600 (six hours), and0disables the deletion. -
file.settings:make_unused_managed_files_temporaryis off by default. When enabled, a file whose last usage is removed becomes temporary and is deleted by cron once it passes the maximum age. When disabled, orphans accumulate on disk.
ddev drush config:set system.file temporary_maximum_age 21600 -y
ddev drush config:set file.settings make_unused_managed_files_temporary true -y
ddev drush cron
Source: Uploaded file management
Deleting a file entity is blocked in the UI while it has usage, and a file with usage from several revisions stays until all of them are deleted. Wide-ranging cleanup of old revisions therefore also frees disk space; see Content moderation, workflows and revisions.
Private files in detail
Setting file_private_path makes the scheme available; it does not decide who may download. Marking a field’s Upload
destination as Private files (field storage settings) places new files below private://, and Drupal serves them
through the route system.files (/system/files/{path}), handled by FileDownloadController. The controller:
-
resolves the requested path to a
private://URI and refuses anything that escapes the directory; -
calls
hook_file_download($uri)in every module; -
if any module returns
-1the answer is 403 Forbidden; if none returns an array of headers, the answer is 403 too; if at least one returns headers and none denies, the file is streamed with those headers.
The file module implements the hook with the default policy: a permanent file is downloadable by a user who may view an
entity that references it. This is the key consequence for editors and administrators: a private file attached to a
published node is available to every visitor who may see the node. The private scheme protects against guessing the URL,
not against seeing the content. Files referenced only by unpublished content are denied to users who cannot view that
content, and a temporary file is visible only to its owner.
Because every download starts PHP, private files cost far more than public ones; they are also not cacheable by the web server. Do not put the site’s images in the private scheme unless the images themselves are confidential.
Writing your own access rule
The Meridian curators want loan agreements visible only to users with a view loan agreements permission, even when the
collection object is public. The hook_file_download implementation below grants the headers only to them and denies
everyone else, which overrides the default policy because one -1 wins.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Hook;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Session\AccountInterface;
/**
* Access control for loan agreements stored in the private file system.
*/
final class LoanFileDownloadHooks {
public function __construct(private readonly AccountInterface $currentUser) {}
#[Hook('file_download')]
public function fileDownload(string $uri): array|int|null {
// Not one of our files: stay neutral so other modules can decide.
if (!str_starts_with($uri, 'private://loans/')) {
return NULL;
}
if (!$this->currentUser->hasPermission('view loan agreements')) {
return -1;
}
return [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="' . basename($uri) . '"',
'Cache-Control' => 'private',
];
}
}
The class goes in src/Hook/ and is autowired, which needs Drupal 11.1 or later; on 10.x the same logic lives in a
procedural meridian_collection_file_download() function in the .module file. See
Hooks and OOP hooks. The return contract is: NULL to abstain, -1 to deny, an
array of headers to allow. The permission itself is declared in a meridian_collection.permissions.yml file, as shown in
Users, roles and permissions.
Security notes for private files
-
Direct access. The private directory must not be reachable over HTTP. Test it: request a known file at its would-be public path and expect a 403 or 404.
-
Sensitive data in filenames. The filename travels in the URL and in logs. Do not put names, ID numbers or emails in it.
-
Headers. Return
Content-Disposition: attachmentfor types that a browser would otherwise render (HTML, SVG), so an uploaded file cannot run script in your origin. -
Image derivatives. Image styles of a private image are generated through Drupal on request, with a token (
itok) derived from the private key; see Display modes and image styles. -
Backups. The private directory holds data the database dump does not. Back it up with the database; see Backups and disaster recovery.
-
Reverse proxies. A CDN or Varnish in front of the site must not cache
/system/files/responses for anonymous users unless the file is truly public; see Performance and caching.
Upload validation
An upload passes through several checks before it becomes a file.
Validators as constraints
Until Drupal 10.1 a form element declared validators as function names (file_validate_extensions,
file_validate_size, file_validate_is_image). Since Drupal 10.2 validation uses Symfony validator constraint plugins
in the Drupal\file\Plugin\Validation\Constraint namespace and the file.validator service
(Drupal\file\Validation\FileValidatorInterface). The procedural file_validate() family was deprecated in 10.2.0 and is
gone in Drupal 11, and passing arbitrary data in #upload_validators is no longer accepted. Code written for Drupal 10.0,
including the examples of older books such as Drupal 10 Development Cookbook (2023), still uses the function names and
fails on Drupal 11 with a plugin-not-found error.
| Old function | Constraint ID | Option |
|---|---|---|
|
|
|
|
|
|
|
|
none |
|
|
none |
|
|
|
The FileExtensionSecure constraint always runs inside FileValidator::validate(). It rejects the insecure extensions
listed in FileSystemInterface::INSECURE_EXTENSIONS (phar, php, pl, py, cgi, asp, js, htaccess and
phtml) unless the Allow insecure uploads option is enabled, which production sites should never do. Uploaded files
with these extensions are otherwise renamed by a security subscriber so the web server does not execute them.
hook_file_validate is deprecated as well. A module that needs to veto a file, such as a virus scanner, subscribes to
Drupal\file\Validation\FileValidationEvent and adds violations to the list.
A managed upload element
The settings form of meridian_collection lets curators upload a catalogue logo with strict validation:
<?php
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Utility\Bytes;
public function buildForm(array $form, FormStateInterface $form_state): array {
$form['logo'] = [
'#type' => 'managed_file',
'#title' => $this->t('Catalogue logo'),
'#upload_location' => 'public://meridian/branding',
'#upload_validators' => [
'FileExtension' => ['extensions' => 'png svg'],
'FileSizeLimit' => ['fileLimit' => Bytes::toNumber('2 MB')],
'FileImageDimensions' => ['maxDimensions' => '1200x1200'],
],
];
return $form;
}
The #upload_location is a URI, never an absolute path. On submit the form receives the file ID, loads the entity, marks it
permanent and records usage; if the form forgets to do so the file is deleted by cron after six hours. See
Form API.
For file and image fields the same constraints are configured in the field settings (Allowed file extensions, Maximum
upload size, Maximum image resolution). The limits you can set in Drupal never exceed what PHP allows:
upload_max_filesize and post_max_size in php.ini, and client_max_body_size in nginx. A 413 error or an upload that
silently drops usually comes from one of those.
Validating in code
<?php
/** @var \Drupal\file\Validation\FileValidatorInterface $validator */
$validator = \Drupal::service('file.validator');
$violations = $validator->validate($file, [
'FileExtension' => ['extensions' => 'pdf'],
'FileSizeLimit' => ['fileLimit' => 10 * 1024 * 1024],
]);
$messages = [];
foreach ($violations as $violation) {
$messages[] = (string) $violation->getMessage();
}
validate() returns a ConstraintViolationListInterface, not an array of strings as the old file_validate() did, so
collect the messages as above.
Filename sanitisation
Names supplied by visitors can contain characters that break scripts, shells or file systems. Drupal handles this in layers.
-
Security renames. The core subscriber
SecurityFileUploadEventSubscriberneutralises dangerous extensions (the behaviour of the oldfile_munge_filename()function). -
Optional sanitisation, since Drupal 10.2. Core dispatches
Drupal\file\Event\FileUploadSanitizeNameEventon every upload through the UI or REST. Subscribers may change the name but not the extension. The default subscriber reads thefilename_sanitizationkey offile.settings, edited at/admin/config/media/file-system, with options to transliterate the name, replace whitespace with a dash or an underscore, replace characters outside0-9,A-Z,a-z,-,_and., collapse duplicate separators, and convert to lower case. Transliteration is off by default because it is not useful for every language. -
Drupal 12. The change record "Dangerous characters in filenames are replaced" (introduced in 12.0.0, published 2026-09-19) makes core replace dangerous characters such as shell metacharacters with underscores for everyone. For example
a;b|c&d.txtbecomesa_b_c_d.txt, and a name of only symbols such as---.txtbecomesunnamed.txt. Accents, spaces and most safe Unicode characters (emoji included) are kept unless transliteration is configured, and existing files are not renamed. The change record does not list configuration keys, so check the Transliteration settings of your release.
The configuration keys have been written in two spellings in published material: the 10.2 change record’s example uses
dedupe_separators, while the default subscriber’s code reads deduplicate_separators. Export the configuration of your
own site (drush config:get file.settings filename_sanitization) and use the key it contains.
An additional subscriber for Meridian prefixes uploads to the collection with the current year:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\EventSubscriber;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\file\Event\FileUploadSanitizeNameEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class UploadNameSubscriber implements EventSubscriberInterface {
public function __construct(private readonly TimeInterface $time) {}
public static function getSubscribedEvents(): array {
return [FileUploadSanitizeNameEvent::class => 'onSanitize'];
}
public function onSanitize(FileUploadSanitizeNameEvent $event): void {
$name = $event->getFilename();
$year = date('Y', $this->time->getRequestTime());
if (!str_starts_with($name, $year . '-')) {
// The extension cannot be changed here, only the base name.
$event->setFilename($year . '-' . $name);
}
}
}
Register it in meridian_collection.services.yml with the event_subscriber tag; see
Events and subscribers. The event covers uploads through the UI and the REST
file upload resource, not files that your own code writes with file_system. Filename changes also change URLs, so
redirect or keep the originals where external links point at files.
Serving files
Use the URL generator, never string concatenation:
<?php
/** @var \Drupal\Core\File\FileUrlGeneratorInterface $generator */
$generator = \Drupal::service('file_url_generator');
$absolute = $generator->generateAbsoluteString('public://exhibitions/hero.jpg');
$relative = $generator->generateString('public://exhibitions/hero.jpg');
$private = $generator->generateAbsoluteString('private://loans/loan-1949.pdf');
Source: FileUrlGeneratorInterface
For the private scheme the generator returns a /system/files/... URL that goes through the controller above; for
public it returns a direct path below file_public_path. A page that embeds a private URL is cacheable only per user
or per permission, so render caching needs the correct cache contexts (see
Render API and cache metadata).
Public files can be served from another host with $settings['file_public_base_url'] = 'https://files.example.org/files';,
which suits a CDN or an object-store bucket; the URL must answer the same relative paths.
Remote storage with the S3 File System module
A local disk is the wrong place for files when several web servers share a site or the web tier is disposable. The
contributed S3 File System module (drupal/s3fs, project page https://www.drupal.org/project/s3fs) registers a stream
wrapper for Amazon S3 and S3-compatible services. It can take over the public:// and private:// schemes or serve only
chosen fields, and it keeps a metadata cache in the database to avoid listing the bucket on every request. It requires the
AWS SDK for PHP v3, installed by Composer, and PHP with allow_url_fopen enabled. Its project page lists a development
release for Drupal 11.4 on the 8.x-3.x branch; check the releases page for a stable tag that supports your core version.
The module has had security advisories, including an access bypass between schemes sharing one bucket
(SA-CONTRIB-2022-057), so install a patched release and subscribe to the advisories (see
Updates, Composer and security advisories).
ddev composer require 'drupal/s3fs:^3.0'
ddev drush en s3fs -y
ddev drush s3fs:refresh-cache # reads the bucket into the metadata cache
Source: S3 File System project page
Credentials never go into configuration that is exported or committed. Keep them in the environment or in
settings.php read from the environment:
<?php
// settings.php
$settings['s3fs.access_key'] = getenv('MERIDIAN_S3_ACCESS_KEY');
$settings['s3fs.secret_key'] = getenv('MERIDIAN_S3_SECRET_KEY');
$config['s3fs.settings']['bucket'] = getenv('MERIDIAN_S3_BUCKET') ?: 'CHANGE_ME';
$config['s3fs.settings']['region'] = 'eu-west-1';
// Serve public files from the bucket and keep the private ones behind Drupal.
$settings['s3fs.use_s3_for_public'] = TRUE;
$settings['s3fs.use_s3_for_private'] = TRUE;
Source: S3 File System project page
The setting names above follow the module’s documentation; confirm them in the README of the release you install, because the contrib module versions its own options. Whatever the storage, private files in a bucket must stay private: do not make the bucket public and let Drupal stream the objects.
Scaling out
When a site runs on more than one machine, a plain local public:// and private:// directory breaks in typical ways: a
file uploaded to server A returns 404 from server B, image derivatives are generated on one machine only, and a deployment
that replaces the container deletes uploads. Choose one of these designs:
| Approach | How it works | Trade-offs |
|---|---|---|
Shared network file system |
NFS or a managed file service mounted at the same path on every web server and referenced by |
Simple, no code change; latency and locking on busy sites, and the file service is a single point of failure |
Object storage through a stream wrapper |
S3 File System (or a vendor module) owns the schemes; the bucket is the shared store |
Scales and survives disposable servers; needs credentials, a metadata cache and care with permissions |
Shared volume in Kubernetes |
A persistent volume claim with a read-write-many access mode mounted by every pod |
Works with standard Drupal; the storage class must support concurrent writers |
Sticky sessions with local disk |
The load balancer sends a user to one server |
Not a solution: files and derivatives still diverge, so avoid it |
Three rules apply whatever you pick: keep the database and the files in step (a restored database needs the matching
files), keep the private directory out of the web root and out of the code repository, and do not store cache or
configuration in the shared tree. Temporary files (file_temp_path) can and should stay local to each machine, because
they are scratch space; make sure the volume is large enough for large uploads. See
Docker and containers and
Deployment and CI/CD for the surrounding setup, and
Performance and caching for caching in front of the file tier.
Troubleshooting
| Symptom | Likely cause and fix |
|---|---|
"The specified file … could not be uploaded" |
The target directory is missing or not writable; run |
Private option missing in the field settings |
|
403 for a private file the user should see |
A |
Private file available to anonymous visitors |
The file is attached to published content; add a download rule or an access-control module |
Files disappear after six hours |
The file was never made permanent or has no usage; call |
Orphaned files fill the disk |
Enable |
Plugin-not-found for |
Code uses the pre-10.2 validator names; use the constraint IDs |
Uploaded image works on one server only |
Files are on local disks; use shared or object storage |
Changed private path ignored |
Rebuild caches |
For log messages, the status report and debugging tools, see Logging, status report and troubleshooting.
What older books and tutorials get wrong
Material written for Drupal 10.0 and earlier, such as Drupal 10 Development Cookbook (2023), predates several changes
that this page describes. Since 10.2 uploads are validated by constraint plugins and the file.validator service, and the
file_validate_* functions no longer exist in Drupal 11. Since 10.3 the FileExists enum replaces the
FileSystemInterface::EXISTS_* constants, which are scheduled for removal in 12.0.0. Since 10.2 the filename
sanitisation event exists, and Drupal 12 changes the default behaviour for dangerous characters. See
Where older books and tutorials are outdated for the
site-wide list.