CLOUD
development

Where Should User Uploads Live? A Storage Strategy for Web Apps

CLOUD|October 19, 2026|7 min read|117 views
Where Should User Uploads Live? A Storage Strategy for Web Apps

Keep uploads outside your code and web root, start on local SSD up to about 200 GB, then move to S3 compatible storage on a Saudi server. Serve private files through signed URLs that expire in minutes, and back them up separately.

For most business applications the right answer is this: keep user uploads outside your code directory and outside the web root, start on the local SSD while total storage is under about 200 GB and you run a single app server, and move to an S3 compatible object store such as MinIO on a Saudi server once you add a second app server, need lifecycle rules, or want files and compute to scale separately. Private files are served through short lived signed URLs that expire in 5 to 15 minutes, never through a guessable public path. Every upload is checked by real content type rather than extension, renamed to a random ID, and limited consistently in PHP and Nginx so users do not hit confusing 413 errors. Finally, files are backed up on their own schedule, separate from the code (which already lives in Git) and the database (which needs point in time dumps). If your app receives 800 files a day at an average of 400 KB, that is roughly 117 GB a year, which on a CLOUD server costs about 1,000 SAR a year in extra SSD. The sections below explain each decision with configuration you can copy, and the numbers to plan storage before it fills up at 2 a.m.

Local disk or object storage

Local disk is the simplest thing that works. Laravel's local disk or a plain directory like /srv/storage/uploads gives you microsecond reads, zero network hops and no extra service to monitor. It breaks down in three situations: when a second app server needs the same files, when you want rules such as "delete temporary exports after 7 days" handled by the storage layer, and when uploads grow much faster than the rest of the app.

Object storage solves those problems by putting files behind an HTTP API. Running it yourself on a server inside the Kingdom keeps the data under Saudi jurisdiction, which matters for entities that integrate with government platforms or hold personal data under the PDPL.

CriterionLocal SSDSelf hosted S3 (MinIO or similar)
Setup timeMinutesAbout 1 hour including TLS
Multiple app serversNeeds NFS or syncNative
Signed URLsThrough the app or X-Accel-RedirectBuilt in presigned URLs
Lifecycle and versioningCron scriptsBucket policies
Read latencyUnder 1 ms2 to 5 ms on the same network

A practical path is to write your code against a storage abstraction from day one. In Laravel that means always using Storage::disk('uploads') and never file_put_contents with a hard coded path. Switching later becomes a config change plus a one time copy with rclone sync.

What the Laravel side looks like

Pointing Laravel at a self hosted S3 endpoint takes four environment variables and one flag that people often miss:

AWS_ACCESS_KEY_ID=app-uploads
AWS_SECRET_ACCESS_KEY=change-me-40-chars
AWS_BUCKET=uploads
AWS_ENDPOINT=https://s3.internal.example.sa
AWS_USE_PATH_STYLE_ENDPOINT=true

Without AWS_USE_PATH_STYLE_ENDPOINT=true the SDK tries bucket subdomains such as uploads.s3.internal.example.sa, which a single node server does not answer. Give the app its own access key limited to one bucket, never the root credentials of the storage server.

Upload limits that agree with each other

The most common upload bug is three layers with three different limits. Nginx rejects the request with 413, PHP silently drops the file, or the framework validator complains last. Set them together, with Nginx slightly higher than PHP so PHP gets to return a readable error:

# /etc/nginx/conf.d/uploads.conf
client_max_body_size 25m;
client_body_timeout 120s;

; php.ini or the pool config
upload_max_filesize = 20M
post_max_size = 22M
max_execution_time = 120
max_file_uploads = 10

Then mirror the same 20 MB in your validation rule, for example 'file' => 'required|file|max:20480|mimes:pdf,jpg,png'. Document the limit in the UI too, since telling users "up to 20 MB, PDF or image" before they pick a file cuts support tickets noticeably.

For files above 100 MB, skip the app server entirely. Generate a presigned PUT URL and let the browser upload straight to the object store, which keeps PHP workers free and avoids tying up a request for 3 minutes on a slow connection.

Private files and signed URLs

Contracts, invoices, ID scans and medical documents should never sit in public/. Store them in a private location and hand out links that expire. With S3 compatible storage, Laravel does it in one line:

$url = Storage::disk('s3')->temporaryUrl(
    $document->path, now()->addMinutes(10)
);

On local disk, authorize the request in your controller and let Nginx stream the file using an internal location, so PHP does not hold the file in memory:

location /protected/ {
    internal;
    alias /srv/storage/private/;
}

The controller checks permissions and responds with the header X-Accel-Redirect: /protected/2026/10/a8f3c1.pdf. Nginx serves the bytes at full disk speed while your authorization logic stays in the app. Keep expiry short: 10 minutes is enough to open a document, and a link forwarded on WhatsApp stops working before it spreads.

Checking what users actually upload

Treat every upload as hostile until proven otherwise. A file named invoice.pdf can be a PHP script, and an SVG can carry JavaScript that runs in your domain. A sound checklist:

  • Detect type from content with finfo or the framework's mimetypes rule, not from the extension the browser sent.
  • Rename every file to a random ID such as a UUID and store the original name in the database only.
  • Never store uploads inside a directory where PHP can execute. If you must, add location ~ ^/uploads/.*\.php$ { deny all; }.
  • Block SVG and HTML for user content unless you sanitize them, and serve downloads with Content-Disposition: attachment.
  • Scan with ClamAV for document heavy apps. clamdscan checks a 5 MB PDF in about 50 to 150 ms when the daemon is running, and needs roughly 1 to 1.5 GB of RAM for its signatures.
  • Strip EXIF data from photos, since it can include the GPS location where the picture was taken.

Planning storage growth by the year

Storage problems are rarely sudden, they are a straight line nobody drew. Take a week of real numbers: files per day and average size. Multiply and project over 12 months, then add 30% for thumbnails, versions and temporary files.

Files per dayAverage sizeGrowth per yearWith 30% overheadExtra SSD cost per year
200250 KB18 GB24 GBFits in base disk
800400 KB117 GB152 GB2 x 500 = 1,000 SAR
3,0001.2 MB1.3 TB1.7 TB17 x 500 = 8,500 SAR

On a CLOUD server, each +100 GB SSD block costs 500 SAR a year including VAT, so you buy capacity in the size you actually need. Set an alert at 75% disk usage and you will always have weeks of runway, not hours. If downloads are heavy, also watch bandwidth: 50,000 downloads a year of a 2 MB file is only 100 GB, but a popular 200 MB installer can eat a terabyte quickly, and an extra 1 TB of transfer costs 1,150 SAR a year.

Back up files separately from code

Code, database and files have different shapes, so each needs its own backup strategy:

  • Code already lives in Git. Backing up vendor/ or node_modules/ wastes space and slows restores.
  • Database needs consistent dumps, for example mysqldump --single-transaction every night plus binary logs if you need recovery to the minute.
  • Files grow append only, which makes them perfect for incremental tools such as restic. After the first full run, a nightly backup of 150 GB that changed by 400 MB finishes in a few minutes.

Keep the backup on a different machine than the one being protected, test a restore every quarter, and record how long it took. The daily backup add-on with 7 day retention costs 800 SAR a year, and the 30 day version costs 2,000 SAR, while our managed cloud hosting plans include daily and weekly backups plus longer retention at no extra charge.

A decision you can make this week

  1. Move uploads out of the web root and behind a named storage disk.
  2. Align Nginx, PHP and validation limits, then show the limit in the upload form.
  3. Serve private files only through signed or internally redirected URLs that expire within 15 minutes.
  4. Add content based type checks and random file names, plus ClamAV if users upload documents.
  5. Measure a week of uploads, project 12 months, and size your SSD with the table above.
  6. Back up files with an incremental tool on their own schedule, and run a test restore.

When the numbers say you have outgrown a single disk, check the plans and add-ons and add a dedicated storage server next to your app, in the same Riyadh data center, about 8 ms from your users.

Share