DevOps и инфраструктура

Где хранить пользовательские файлы: PostgreSQL, диск VPS или S3 — практический выбор для веб-продукта

Пока веб-продукт существует только в виде прототипа, вопрос хранения файлов кажется почти элементарным.

Пользователь загружает PDF.

Backend получает его.

Файл сохраняется:

/uploads/document.pdf

Готово.

Потом появляется второй пользователь.

Потом сотый.

В систему начинают загружать фотографии, договоры, архивы, чеки, аудиозаписи и документы.

Через некоторое время возникают уже совсем другие вопросы:

Что произойдёт с файлами при переносе приложения на другой сервер?
Попадут ли они в резервную копию?
Как работать с файлами, если backend запущен сразу на двух серверах?
Можно ли открыть чужой документ, если узнать его URL?
Что делать, если файл успешно загрузился в хранилище, а запись в базе создать не удалось?
Где хранить десятки или сотни гигабайт пользовательских данных?
И нужно ли вообще держать файлы на том же VPS, где работает приложение?

В этот момент становится понятно, что задача:

«куда сохранить файл?»

на самом деле является частью архитектуры продукта.

Обычно рассматривают три основных варианта:

1. База данных

2. Локальный диск VPS

3. Object Storage
   с S3-совместимым API

Каждый из них может быть правильным.

И каждый способен стать серьёзной проблемой, если использовать его не в том продукте.

Разберём, чем эти подходы отличаются и какую архитектуру мы бы выбирали для CRM, SaaS, клиентского кабинета или другого сложного веб-сервиса.


Файл — это не только набор байтов

Представим, клиент загрузил:

contract.pdf

У файла есть содержимое.

Но приложению обычно нужно знать намного больше:

Кто загрузил?

К какому проекту относится?

Как называется?

Какой размер?

Какой MIME-type?

Кто может скачать?

Когда создан?

Удалён ли?

Есть ли новая версия?

Где физически хранится?

Поэтому практически в любой зрелой системе полезно разделять:

Файл как бизнес-сущность

и:

Физическое содержимое файла

Например, PostgreSQL может хранить:

files

id
owner_id
project_id
original_name
storage_key
content_type
size
status
created_at

А непосредственно PDF находится уже в выбранном файловом хранилище.

Это разделение станет особенно важным, когда мы доберёмся до S3.


Вариант №1. Хранить файл прямо в базе данных

PostgreSQL позволяет хранить бинарные данные, например через bytea.

Условно:

files

id
name
content

где content содержит сам PDF, JPG или архив.

На первый взгляд у такого подхода есть очень привлекательное свойство:

данные и файл находятся внутри одной базы.


Чем база данных удобна

Представим операцию:

создать документ
+
сохранить его содержимое

Если всё находится в PostgreSQL, можно использовать одну транзакцию.

BEGIN

INSERT metadata
INSERT binary data

COMMIT

Либо произошло всё.

Либо ничего.

Не возникает ситуации:

файл записан

но:

metadata отсутствует

или наоборот.

Это действительно сильное преимущество.


Backup тоже кажется проще

Если все данные находятся внутри PostgreSQL, резервная копия базы содержит:

пользователей;
проекты;
сообщения;
документы;
сами файлы.

Не требуется отдельно согласовывать восстановление:

database backup
+
filesystem backup

С точки зрения целостности это довольно удобно.


Когда хранение файлов в БД вполне нормально

Не каждый бинарный файл требует отдельного object storage.

Допустим, приложение хранит:

небольшие аватары;
маленькие подписи;
миниатюрные документы;
редкие бинарные объекты.

И общий объём составляет:

100–500 МБ

Для некоторых проектов помещать такие данные в PostgreSQL может быть вполне практично.

Особенно если простота и транзакционность важнее стоимости хранения.


Проблемы начинаются с ростом объёма

Представим SaaS.

Есть:

10 000 пользователей

Каждый загрузил в среднем:

50 МБ

Получается:

≈ 500 ГБ файлов

Теперь основной PostgreSQL содержит не только бизнес-данные, но и сотни гигабайт бинарных объектов.

Это меняет практически всё.


Резервные копии становятся тяжелее

Раньше backup содержал:

2 ГБ relational data

Теперь:

500+ ГБ

Даже если большая часть файлов вообще никогда не меняется.

Каждый механизм:

backup;
restore;
replication;
migration;
disk management

становится существенно тяжелее.


Восстановление базы начинает зависеть от файлов

Представим аварийную ситуацию.

Нужно восстановить:

пользователей;
проекты;
платежи;
настройки.

Без файлов это могла бы быть база размером несколько гигабайт.

Но если внутри лежит архив пользовательских загрузок на сотни гигабайт, время восстановления резко увеличивается.

Получается странная связь:

Чтобы восстановить таблицу пользователей, нужно восстановить ещё и весь архив фотографий.

Репликация тоже начинает переносить файлы

Если PostgreSQL реплицируется, бинарные данные становятся частью той же системы.

Добавили большой файл:

500 МБ

эта операция влияет на storage и репликацию БД.

То есть база превращается одновременно в:

transactional database

и:

file storage

Хотя требования к этим двум типам данных обычно разные.


Стоимость быстрого диска базы выше

PostgreSQL полезен быстрый storage с хорошими характеристиками I/O.

Но архив пользовательских PDF может месяцами не читаться.

Хранить такие холодные данные на том же дорогом диске, что и активно работающие таблицы, часто экономически невыгодно.


База данных лучше хранит структуру, чем огромный архив файлов

Для многих веб-продуктов мы бы рассматривали PostgreSQL как источник истины для:

кто владеет файлом;
где он находится;
какие у него права;
какой статус;
какой размер;
к какой сущности относится.

Но не обязательно для:

самих гигабайтов бинарных данных.

Вариант №2. Локальный диск VPS

Самый понятный подход выглядит так:

/opt/app/uploads/

или:

/var/lib/app/uploads/

Backend получает файл и выполняет:

write file

После этого PostgreSQL сохраняет:

storage_path

Например:

uploads/
2026/
09/
f8a7...

У локального диска есть серьёзные преимущества

Во-первых, простота.

Не нужен отдельный storage-сервис.

Не нужен S3 API.

Не нужны дополнительные credentials.

Архитектура:

Browser
   ↓
Backend
   ↓
VPS disk

очень понятна.


Локальный диск быстрый

Если backend и файл находятся на одном сервере, чтение происходит локально.

Нет дополнительного сетевого запроса к object storage.

Для небольшого продукта это может работать очень хорошо.


И зачастую это самый дешёвый вариант на старте

VPS уже имеет:

40–100 ГБ SSD/NVMe

Если приложение занимает несколько гигабайт, а пользователи загружают:

200 МБ файлов в месяц

покупать отдельную storage-инфраструктуру только ради архитектурной чистоты необязательно.

Простое решение может быть правильным решением.


Проблема №1: приложение начинает зависеть от конкретного сервера

Пока существует:

VPS #1

всё прекрасно.

Но затем нужен новый сервер.

Разработчик переносит:

код;
PostgreSQL;
Redis;
Nginx.

И внезапно выясняется:

А ещё есть 37 ГБ пользовательских файлов в /uploads.

Теперь их тоже нужно переносить.


Deploy становится опаснее

Представим структуру:

/app
  /src
  /public
  /uploads

Если deployment устроен неправильно и новый release заменяет весь каталог приложения, можно случайно удалить пользовательские данные.

Поэтому mutable user data вообще лучше не смешивать с immutable application release.

Например:

/opt/app/releases/...

и отдельно:

/var/lib/app/uploads

Это простая, но очень важная граница.


Docker добавляет ещё одну ловушку

Если приложение работает в контейнере и файл сохраняется просто в:

/app/uploads

внутри writable layer контейнера, после пересоздания контейнера данные могут исчезнуть.

Пользовательские файлы должны находиться:

в volume;
bind mount;
или внешнем storage.

Контейнер нельзя считать постоянным хранилищем пользовательских данных.


Проблема №2: backup становится вашей ответственностью

Если PostgreSQL резервируется каждый день, это ещё не означает, что пользовательские документы тоже защищены.

Можно иметь:

database backup: ✓
uploads backup: ✗

После аварии база восстановится.

В ней будет:

file #1842
storage_path = ...

Но самого файла уже нет.

С точки зрения БД всё выглядит корректно.

Для пользователя документ потерян.


Backup локальных файлов должен проектироваться отдельно

Нужно решить:

куда копировать;
как часто;
сколько версий хранить;
как проверить restore.

Если backup лежит:

на том же VPS

он защищает от некоторых ошибок.

Но не от потери всего сервера.

Для настоящего disaster recovery нужна независимая копия.


Проблема №3: локальный диск плохо сочетается с несколькими backend

Представим масштабирование.

Было:

Nginx
  ↓
API #1

Стало:

        ┌→ API #1
Nginx ──┤
        └→ API #2

Пользователь загрузил файл через:

API #1

Файл появился только:

на диске API #1.

Следующий запрос попал на:

API #2.

Там файла нет.

Можно использовать shared filesystem.

Но в этот момент архитектура уже становится сложнее.

Именно здесь object storage начинает выглядеть намного естественнее.


Проблема №4: диск конечен

Представим VPS:

60 ГБ

На нём уже находятся:

операционная система;
Docker;
PostgreSQL;
Redis;
логи;
backup;
releases;
пользовательские файлы.

Теперь пользователь загружает:

10 ГБ видео.

Если приложение никак не контролирует storage, один upload способен повлиять на всю систему.

Свободное место может понадобиться PostgreSQL.

Backup.

Deployment.

Логам.

Поэтому при локальном хранении обязательно нужны:

quota;
file size limits;
disk monitoring;
safety reserve.

Иначе пользовательские файлы конкурируют с базой за один диск

Это принципиально важная проблема.

Представим:

free disk:
3 ГБ

Пользователь начинает upload:

2,8 ГБ

Формально файл помещается.

Но после него PostgreSQL почти не остаётся пространства для нормальной работы.

Поэтому хорошая система проверяет не:

Файл меньше свободного места?

А:

После сохранения файла останется достаточный operational reserve?

Когда локальный диск VPS остаётся хорошим выбором

Например:

внутренняя CRM;
100 пользователей;
несколько тысяч документов;
общий объём 5 ГБ;
один сервер;
простая инфраструктура.

При нормальном backup и monitoring выделенный каталог на VPS может быть совершенно практичным решением.

Не каждый продукт обязан иметь S3.


Вариант №3. S3-совместимое Object Storage

Третий подход — вынести бинарные данные из application server.

Архитектура становится такой:

                PostgreSQL
              ↗
Backend
              ↘
            Object Storage

PostgreSQL хранит metadata.

Object Storage — содержимое.

Например:

File metadata:

id:
f_1842

ownerId:
u_482

projectId:
p_91

originalName:
contract.pdf

storageKey:
projects/91/files/a8f7...

size:
418293

contentType:
application/pdf

А сами:

418 293 bytes

находятся отдельно.


Почему S3 удобно масштабируется

Backend больше не зависит от локального диска.

Можно иметь:

API #1
API #2
API #3

Все они работают с одним storage.

      API #1
         \
      API #2 → Object Storage
         /
      API #3

Файл не принадлежит конкретному экземпляру приложения.

Это значительно упрощает горизонтальное масштабирование.


Сервер приложения можно заменить без переноса файлов

Появился новый VPS.

Развернули код.

Подключили:

PostgreSQL
Redis
S3

И пользовательские файлы уже доступны.

Не нужно переносить каталог:

/uploads

между application servers.

Application instances становятся ближе к stateless-модели.


Большие файлы перестают занимать production-диск приложения

На VPS остаются:

приложение;
база;
логи;
служебные данные.

А пользовательские:

PDF;
JPEG;
ZIP;
audio;
video

живут отдельно.

Это уменьшает вероятность, что массовая загрузка файлов внезапно заполнит диск PostgreSQL-сервера.


Но S3 не делает архитектуру автоматически правильной

Появляется новый класс проблем.

Главная из них:

PostgreSQL и Object Storage не находятся в одной ACID-транзакции.


Классическая проблема: файл записался, база — нет

Процесс:

1. Upload object
2. INSERT metadata

Первый шаг прошёл.

Второй:

database error

Теперь в bucket существует:

object a8f7...

но приложение ничего о нём не знает.

Получился:

orphan object.


Обратный вариант тоже возможен

Сначала:

INSERT metadata

а затем:

upload object

не удался.

Теперь база говорит:

файл существует

а storage:

файла нет

Нельзя решить проблему простым изменением порядка операций.

Нужна стратегия согласования.


Один из практичных подходов — статус загрузки

Создаём metadata:

File

status:
PENDING

Далее загружаем object.

После успешной проверки:

PENDING
   ↓
ACTIVE

Пользователь видит только:

ACTIVE

файлы.

Если upload не завершён:

PENDING

запись можно очистить позднее.


Другой вариант — upload сначала, metadata потом

Тогда при ошибке базы приложение сразу пытается выполнить:

DELETE object

Если storage временно недоступен и удалить не получилось, создаётся durable cleanup job:

DELETE_OBJECT

Worker повторяет очистку позднее.

Это пример compensating action.

Когда две системы нельзя объединить одной транзакцией, мы компенсируем незавершённую операцию отдельным действием.


Такой cleanup нельзя просто забыть

Плохая реализация:

try {
    await deleteObject();
} catch {
    // ничего
}

Через несколько месяцев storage может содержать тысячи orphan objects.

Пользователь их не видит.

База их не знает.

Но провайдер продолжает хранить данные и выставлять счёт.

Поэтому failure cleanup тоже должен быть durable.


Удаление файла имеет ту же проблему

Пользователь нажимает:

[Удалить]

Можно удалить metadata.

А затем не суметь удалить object.

Или наоборот.

Практичный lifecycle:

ACTIVE
   ↓
DELETED
   ↓
physical cleanup

Для пользователя файл исчезает сразу.

Physical delete может быть выполнен worker.

Если временно не получилось:

retry.

Почему soft delete особенно полезен

Кроме надёжности он даёт время исправить ошибку.

Пользователь случайно удалил важный документ.

Если система мгновенно уничтожила object, восстановление возможно только из backup.

Если существует:

deleted_at

и физическое удаление отложено:

24 часа

можно реализовать:

Восстановить

Но срок должен соответствовать продукту и требованиям к удалению данных.


Object Storage особенно хорошо работает с immutable-файлами

PDF загрузили.

Он не меняется.

Если пользователь загрузил новую версию договора, практичнее создать новый object:

file-v2

вместо перезаписи байтов старого файла.

Так проще:

версионирование;
audit;
cache;
backup.

Оригинальное имя и storage key — не одно и то же

Пользователь загрузил:

Договор Иванов финал (2).pdf

Не обязательно использовать это имя как physical key.

Гораздо безопаснее:

projects/
91/
files/
1f8e71de-....pdf

А в БД хранить:

original_name =
"Договор Иванов финал (2).pdf"

Пользователь продолжает видеть знакомое название.

Storage получает стабильный технический идентификатор.


Почему нельзя строить безопасность на «секретном URL»

Например:

https://storage.example.com/
client-91/
contract.pdf

Если bucket публичный, человек, получивший URL, может скачать документ в обход приложения.

Backend уже не участвует в проверке:

кто пользователь;
к какому проекту относится;
есть ли право FILE_READ.

Для приватных пользовательских файлов обычно нужен private storage.


Доступ к файлу должен начинаться с авторизации

Например:

GET /api/files/f_1842/download

Backend проверяет:

Пользователь авторизован?

Файл существует?

Файл принадлежит доступному проекту?

Есть FILE_READ?

Только после этого даётся возможность скачать object.


Есть два основных способа отдачи файла

Первый:

Browser
  ↓
Backend
  ↓
Object Storage
  ↓
Backend
  ↓
Browser

Backend проксирует содержимое.

Плюсы:

полный контроль;
простая модель доступа.

Минусы:

весь файловый трафик
проходит через приложение.

Если пользователи скачивают гигабайты, backend становится ненужным посредником.


Второй способ — временная подписанная ссылка

Схема:

Browser
   ↓
Backend
   ↓
Authorization
   ↓
Temporary signed URL
   ↓
Browser → Object Storage

Backend разрешает доступ, но сами байты передаются напрямую storage.

Это уменьшает нагрузку на API.


Подписанная ссылка должна быть временной

Если пользователь получил:

signed URL

действующий год, он почти превратился в постоянную публичную ссылку.

Для приватных данных срок лучше выбирать исходя из реального сценария.

Например:

5 минут

или:

15 минут

а не:

навсегда.

При следующем скачивании backend снова проверит permissions и создаст новую ссылку.


Но signed URL не отменяет права доступа

Плохой endpoint:

GET /api/files/:id/url

находит object и сразу подписывает его.

Правильная цепочка:

User
 ↓
Membership
 ↓
Project access
 ↓
File access
 ↓
Signed URL

Иначе S3 станет обходным путём вокруг всей permission architecture.


Можно сделать прямой upload в Object Storage

Обычная загрузка:

Browser
   ↓
Backend
   ↓
Object Storage

означает, что большой файл дважды проходит по сети приложения.

Например:

Browser → API: 500 МБ

API → S3: 500 МБ

Backend становится транспортным узлом.


Для больших файлов можно использовать direct upload

Например:

1. Browser → Backend:
   хочу загрузить файл

2. Backend:
   проверяет пользователя,
   quota и параметры

3. Backend:
   создаёт временное разрешение

4. Browser → Object Storage:
   загружает файл напрямую

5. Backend:
   подтверждает завершение

Схема:

             Backend
            ↗       ↘
Authorization       Upload permission
          ↖           ↙
             Browser
                │
                │ bytes
                ▼
          Object Storage

Backend контролирует операцию.

Но гигабайты данных через Node.js не проходят.


Direct upload требует отдельного lifecycle

Нельзя просто выдать пользователю URL:

Загружай что хочешь.

Перед выдачей разрешения backend проверяет:

можно ли загружать;
размер;
план пользователя;
quota;
тип объекта;
целевой project.

Создаётся:

UploadSession

например:

PENDING

После завершения файл проверяется и только затем становится:

ACTIVE

Что проверять после прямой загрузки

Нельзя полностью доверять данным, которые frontend сообщил до upload.

Клиент мог сказать:

size = 2 MB

а загрузить:

200 MB.

Поэтому backend полезно сверить фактический объект:

существует?
какой размер?
ожидаемый key?
допустимый тип?

И только потом активировать metadata.


MIME-type от браузера тоже нельзя считать абсолютной истиной

Пользователь может прислать:

Content-Type:
image/jpeg

для файла, который изображением не является.

В зависимости от риска продукта могут потребоваться:

проверка сигнатуры;
антивирус;
парсинг;
sandbox;
перекодирование.

Не каждому сервису нужен сложный malware pipeline.

Но доверять только расширению .jpg тоже не стоит.


Имя файла — пользовательский ввод

Например:

../../something

или необычные управляющие символы.

Если используется локальный filesystem и приложение просто соединяет:

UPLOAD_DIR + filename

можно получить path traversal или другие проблемы.

Physical filename лучше генерировать самостоятельно.

Оригинальное пользовательское имя хранить как metadata и безопасно использовать только для отображения или Content-Disposition.


Ограничение размера должно работать на нескольких уровнях

Например:

Frontend:
показывает лимит 50 МБ

Это UX.

Но пользователь может обойти frontend.

Поэтому должен быть:

Backend:
max 50 MB

Если upload идёт напрямую в storage, лимит должен учитываться и при выдаче upload permission.

А reverse proxy тоже может иметь:

client_max_body_size

при proxy upload.

Несколько согласованных уровней полезнее одного.


Нужна и пользовательская quota

Представим SaaS:

Free:
1 ГБ

Pro:
50 ГБ

При каждом новом upload система должна учитывать:

current usage
+
projected file size

а не ждать, пока bucket или VPS физически закончится.


Quota и физический лимит storage — разные ограничения

Пользователь может иметь:

ещё 10 ГБ quota

Но инфраструктура при этом почти заполнена.

Поэтому нужны два независимых ограничения:

User quota

и:

Infrastructure capacity

Первое обеспечивает fair use.

Второе защищает всю систему.


Когда файлы находятся в S3, основной VPS не знает реальный объём автоматически

Поэтому админ-панель и мониторинг должны получать storage metrics отдельно.

Например:

Objects:
128 420

Storage:
742 GB

Uploads today:
18.4 GB

Это помогает заранее видеть рост.


Lifecycle — одно из важных преимуществ Object Storage

Не все объекты должны храниться вечно.

Например:

временные upload;
незавершённые multipart upload;
промежуточные exports;
старые версии.

Для них можно определить автоматический lifecycle.

Например:

temporary export
→ удалить через 24 часа

Но нельзя применять одну универсальную политику ко всем пользовательским данным.

Договор и временный ZIP-экспорт имеют совершенно разную ценность.


Версионирование тоже требует внимания

Object Storage может поддерживать versioning.

Это полезно:

защита от случайной перезаписи;
восстановление версии.

Но есть обратная сторона.

Пользователь видит:

1 файл

а storage физически хранит:

версия 1
версия 2
версия 3
версия 4

Если lifecycle старых versions не настроен, объём может незаметно расти.


Delete marker не всегда означает физическое освобождение места

В versioned storage логическое удаление текущего объекта может оставлять старые версии.

То есть интерфейс говорит:

файл удалён

а storage bill почти не изменился.

При проектировании lifecycle важно понимать особенности конкретного S3-совместимого провайдера.


Multipart upload тоже способен оставлять мусор

Большие файлы часто загружаются частями.

Например:

part 1
part 2
part 3
...

Если загрузка оборвалась и multipart session никогда не завершилась, части могут остаться в storage.

Поэтому полезно иметь автоматическую очистку abandoned multipart uploads.


CDN — ещё одно преимущество отдельного storage

Если приложение отдаёт:

фотографии;
видео;
публичные изображения;
статические документы,

перед object storage можно использовать CDN.

Тогда пользователь получает файл с ближайшей edge-точки.

Основной backend не участвует.

Но для приватных данных CDN тоже должен уважать access model.


Не все пользовательские файлы нужно отдавать через CDN

Например:

аватар

и:

подписанный договор

имеют разные требования.

Аватар может быть публичным и активно кешироваться.

Договор:

private;
authorization required;
short-lived access.

Одно и то же storage может обслуживать разные классы объектов, но policy должны различаться.


Полезно классифицировать файлы

Например:

PUBLIC_ASSET
PRIVATE_ATTACHMENT
TEMP_EXPORT
SYSTEM_BACKUP
USER_MEDIA

Для каждого класса определить:

доступ;
retention;
backup;
encryption;
cache;
максимальный размер.

Это значительно лучше подхода:

все файлы лежат в одной папке uploads.

Backup Object Storage устроен иначе, чем backup PostgreSQL

В PostgreSQL snapshot содержит консистентное состояние relational data.

Object storage — отдельная система.

Если мы восстанавливаем:

DB вчера 03:00

а storage:

сегодня 12:00

могут появиться расхождения.

Например, база не знает о новом файле, который storage уже содержит.


Нужно понимать модель disaster recovery

Для каждого файла важны два вопроса:

Где metadata?
Где bytes?

Если:

PostgreSQL
→ backup A

Object Storage
→ backup/lifecycle B

нужно понимать, как эти два слоя восстанавливаются вместе.

В небольших системах допустима eventual reconciliation после restore.

В критичных — потребуется более строгая процедура.


Полезен storage reconciliation

Периодически можно искать:

Metadata без object

База:
file A exists

Storage:
object A missing

и:

Object без metadata

Storage:
object B exists

База:
file B unknown

Второй случай особенно важен для борьбы с orphan data.

Не обязательно сканировать миллионы объектов каждый час.

Но механизм проверки полезно иметь.


Что считать источником истины

В типичной архитектуре мы бы считали PostgreSQL источником истины для бизнес-смысла файла:

существует ли он для пользователя;
кому принадлежит;
можно ли читать;
удалён ли;
какой storage key.

Object Storage отвечает за:

физические bytes.

То есть наличие случайного объекта в bucket ещё не означает:

Это действующий пользовательский файл.

Без активной metadata приложение его не выдаёт.


И наоборот: metadata не гарантирует существование bytes

Поэтому операция скачивания должна корректно обрабатывать:

object missing.

Не стоит возвращать пользователю generic:

500

если можно зарегистрировать storage inconsistency и показать контролируемую ошибку.


Шифрование — отдельный вопрос

Есть несколько уровней.

HTTPS

защищает файл при передаче.

Storage provider может поддерживать encryption at rest.

А в приложениях с более строгими требованиями можно использовать дополнительное application-level encryption.

Тогда object storage хранит:

ciphertext

а ключи управляются отдельно.


Но дополнительное шифрование имеет цену

Нужно решить:

как создавать ключи;
как хранить;
как ротировать;
как восстанавливать;
как стримить большие файлы;
как сделать backup ключей.

Если потерять master key:

storage может быть цел,
но данные фактически потеряны.

Поэтому шифрование нужно проектировать как систему управления ключами, а не просто как вызов:

encrypt(file)

На локальном VPS тоже нельзя забывать права файловой системы

Если backend работает от:

appuser

не нужно делать:

chmod 777 uploads

только потому, что:

Иначе не записывает.

У каталога должен быть понятный владелец и минимальные права.


Публичный web-root — плохое место для приватных файлов

Например:

/public/uploads/contracts/...

Если Nginx напрямую отдаёт каталог, backend authorization может вообще не выполняться.

Для приватных документов полезнее хранить их вне публичного web-root и выдавать только через контролируемый механизм.


Пример №1. Маленькая внутренняя CRM

Условия:

30 сотрудников

2 000 документов

общий объём:
3 ГБ

один VPS

В такой системе локальный диск может быть наиболее практичным решением.

Например:

/var/lib/crm/uploads

при условии:

отдельного backup;
monitoring;
quota;
правильных permissions.

Вводить отдельный S3 только потому, что он архитектурно красивее, необязательно.


Пример №2. B2B-клиентский кабинет

Есть:

несколько тысяч клиентов;
договоры;
акты;
архивы;
изображения;
постоянный рост.

Backend планируется запускать в нескольких экземплярах.

Здесь мы бы уже предпочли:

PostgreSQL
→ metadata

S3-compatible storage
→ bytes

Это значительно уменьшает связанность приложения с конкретным VPS.


Пример №3. Сервис обработки аудио и видео

Пользователь загружает:

500 МБ
2 ГБ
5 ГБ

Проксировать всё через основной backend может быть дорого и неудобно.

Практичная схема:

Browser
↓
получить upload permission
↓
Object Storage
↓
background processing
↓
result storage

Backend управляет lifecycle, но не является трубой для каждого гигабайта.


Пример №4. Приложение с десятком небольших файлов

Если весь продукт хранит:

20 PDF
по 300 КБ

строить полноценную S3-архитектуру, multipart upload, reconciliation jobs и lifecycle, возможно, просто избыточно.

Здесь даже PostgreSQL может быть приемлем.


Поэтому нет ответа «всегда используйте S3»

Выбор зависит от:

КритерийБаза данныхДиск VPSS3/Object Storage
Простота стартаВысокаяОчень высокаяСредняя
Транзакционность с metadataВысокаяНизкаяНизкая
Большие объёмыНеудобноОграниченноХорошо
Несколько backendНе проблемаПроблема без shared FSХорошо
Backup DBСтановится тяжёлымОтдельный backupРазделён
CDNНеестественноВозможно, но сложнееЕстественно
Direct uploadПрактически нетОбычно через backendУдобно
МасштабированиеОграничивает БДОграничивает серверЛучше подходит
Operational complexityНизкаяНизкая/средняяСредняя
Orphan objectsНетВозможныВозможны

Эта таблица не выбирает технологию автоматически.

Она показывает компромиссы.


Что мы чаще всего выбирали бы для современного SaaS

Базовая модель:

PostgreSQL
      ↓
File metadata

S3-compatible storage
      ↓
File bytes

Backend связывает два слоя.

Например:

files.id
        ↓
files.storage_key
        ↓
Object Storage

В результате relational database остаётся относительно компактной.

Storage масштабируется независимо.


Полезно спрятать конкретное хранилище за abstraction layer

В коде домена не обязательно писать везде:

s3.putObject()

Можно иметь:

StorageService

с интерфейсом:

put()
get()
delete()
exists()
createDownloadUrl()

А реализации:

LocalStorageAdapter
S3StorageAdapter

Это особенно удобно на локальной разработке.


Локально не всегда нужен настоящий S3

Development может использовать:

filesystem adapter

а production:

S3 adapter

Или локальный S3-совместимый сервис.

Главное, чтобы domain layer не был намертво привязан к конкретному способу хранения.


Такая абстракция помогает и при миграции

Сегодня файлы лежат:

на VPS.

Продукт вырос.

Нужно перейти в S3.

Если вся система работает через:

StorageService

можно:

1. скопировать objects;
2. обновить storage keys/backend adapter;
3. постепенно переключить чтение.

Если каждый endpoint самостоятельно делает:

fs.readFile(...)

миграция будет значительно сложнее.


Но abstraction не должна скрывать реальные различия

Filesystem и S3 — не абсолютно одинаковые системы.

Например, у них отличаются:

способ выдачи URL;
multipart upload;
consistency guarantees конкретного провайдера;
metadata;
lifecycle;
стоимость запросов.

Поэтому не нужно создавать чрезмерно абстрактный интерфейс:

doAnythingWithFile()

Достаточно изолировать то, что действительно общее.


Какие ошибки встречаются чаще всего

Первая:

хранить пользовательские файлы внутри release-каталога приложения.

Новый deployment способен их удалить.

Вторая:

иметь backup PostgreSQL и считать, что backup продукта готов.

Файлы могли остаться без копии.

Третья:

хранить приватные документы в публичном bucket.

Ссылка начинает обходить authorization.

Четвёртая:

доверять исходному имени файла как physical path.

Это создаёт проблемы с безопасностью и коллизиями.

Пятая:

не ограничивать размер и quota.

Один пользователь способен занять всё storage.

Шестая:

сохранять файл в S3 и забывать о rollback/cleanup при ошибке базы.

Появляются orphan objects.

Седьмая:

считать удаление metadata физическим удалением файла.

Storage продолжает расти.

Восьмая:

включить versioning и никогда не настроить lifecycle.

Старые versions накапливаются незаметно.

Девятая:

смешать временные файлы и постоянные пользовательские данные.

К ним начинают применять одинаковый retention.

И десятая:

не проверять восстановление.

Backup существует, но никто не знает, удастся ли из него вернуть документы.


Самый важный вопрос — не «где дешевле сохранить гигабайт»

Стоимость storage важна.

Но архитектурная стоимость обычно складывается из большего числа факторов:

сколько стоит backup;
сколько стоит восстановление;
как выполняется миграция;
можно ли добавить второй backend;
как работает безопасность;
сколько занимает deployment;
насколько легко расследовать потерю файла.

Самый дешёвый гигабайт не обязательно даёт самую дешёвую систему.


Когда хранить в базе

Мы бы рассматривали этот вариант, когда:

файлы маленькие;
их немного;
они тесно связаны с транзакцией;
простота важнее масштабирования.

Например, очень небольшие бинарные артефакты или специализированные данные.


Когда хранить на диске VPS

Это практичный выбор, когда:

продукт небольшой;
backend один;
объём контролируемый;
простая инфраструктура важна;
backup файлов уже продуман.

Для небольших корпоративных систем это может быть оптимальный баланс.


Когда выбирать S3

Object Storage становится особенно интересным, когда:

файлов много;
они большие;
объём быстро растёт;
backend может масштабироваться;
нужен direct upload;
нужен CDN;
нужно отделить данные от application VPS.

Для зрелого SaaS, B2B-портала или сервиса с пользовательскими медиа это часто наиболее практичная архитектура.


Но S3 — это не просто «куда залить файл»

Production-модель требует подумать ещё о:

metadata;
permissions;
upload sessions;
signed URLs;
quota;
lifecycle;
orphan cleanup;
backup;
monitoring;
reconciliation.

То есть object storage снимает одни проблемы и создаёт другие.

Это нормально.

Любое разделение системы имеет цену.


Как выглядит зрелый lifecycle пользовательского файла

Например:

Пользователь выбирает файл
        ↓
Backend проверяет права
        ↓
Проверяет quota
        ↓
Создаёт upload session
        ↓
Файл загружается
        ↓
Проверяется размер/тип
        ↓
Metadata становится ACTIVE
        ↓
Файл доступен по permissions
        ↓
Пользователь удаляет
        ↓
Metadata → DELETED
        ↓
Cleanup job
        ↓
Object удалён физически

Здесь файл рассматривается как полноценная сущность с жизненным циклом.

Не просто как:

что-то появилось в папке.

Важный архитектурный принцип: bytes и business meaning — разные уровни

Object Storage может знать:

key = a8f72...
size = 418293

Но он не знает:

кто владелец;
можно ли скачать;
является ли проект архивным;
есть ли у сотрудника FILE_READ.

Это знает приложение.

Поэтому:

Object Storage

не заменяет:

PostgreSQL + authorization.

Они решают разные задачи.


Не нужно давать пользователю прямую структуру storage

Например, URL:

/client-1842/
passport.pdf

выдаёт внутреннюю структуру и иногда бизнес-информацию.

Лучше использовать технические keys:

objects/
4e/
f2/
4ef2c8...

или другой непрозрачный формат.

Пользовательское название остаётся в metadata.


Наблюдаемость тоже нужна

Полезно понимать:

сколько объектов;
общий объём;
скорость роста;
ошибки загрузки;
ошибки удаления;
число failed cleanup jobs;
старые PENDING uploads.

Если:

orphan cleanup failed

месяцами остаётся незаметным, storage будет медленно расти.


Особенно полезно следить за PENDING

Например:

UploadSession
PENDING
старше 24 часов

скорее всего является незавершённой загрузкой.

Такие записи можно анализировать и очищать по отдельной retention policy.


Файл должен иметь историю там, где это важно

Для договора иногда нельзя просто:

заменить старый PDF новым.

Правильнее:

Document #91

Version 1
Version 2
Version 3

где каждая версия имеет собственный storage key.

Теперь можно доказать:

Какая версия была доступна 15 сентября?

Это уже не вопрос файлового хранилища.

Это domain model.


И это показывает главный принцип всей темы

Выбор:

PostgreSQL
VPS
S3

не должен определять бизнес-модель продукта.

Сначала мы понимаем:

что такое файл для бизнеса;
кому он принадлежит;
сколько живёт;
кто читает;
есть ли версии;
что означает удаление.

И только потом выбираем физическое хранилище.


Практичная схема для сложного веб-продукта

Для многих современных CRM, SaaS и клиентских кабинетов мы бы начинали примерно с такой архитектуры:

                         ┌──────────────┐
                         │ PostgreSQL   │
                         │              │
                         │ metadata     │
                         │ permissions  │
                         │ ownership    │
                         │ versions     │
                         └──────┬───────┘
                                │
                                │ storage_key
                                │
Browser                         │
   │                            │
   ▼                            ▼
Backend ─────────────────→ Object Storage
   │                            │
   │ auth                       │ bytes
   │ quota                      │
   │ policy                     │
   │                            │
   └──── temporary signed URL ──┘

При больших upload поток можно изменить:

Browser
   ↓
Backend
   ↓
authorization + temporary upload permission
   ↓
Browser
   ↓
Object Storage

А после завершения:

Backend
↓
verify
↓
ACTIVE

Но начинать можно проще

Очень важно не превратить хорошую архитектуру в культ сложности.

Если сегодня продукт имеет:

20 клиентов;
300 PDF;
1 VPS;

можно начать с локального filesystem.

Но сделать это так:

отдельный data directory;
backup;
quota;
storage abstraction;
monitoring.

Тогда будущий переход в S3 не потребует переписывать половину системы.


Хорошая архитектура оставляет путь роста

Сегодня:

LocalStorageAdapter

Завтра:

S3StorageAdapter

При этом:

File

как бизнес-сущность остаётся прежним.

Это намного важнее, чем выбрать «самую масштабируемую» технологию в первый день проекта.


Вместо вывода

На вопрос:

Где лучше хранить пользовательские файлы?

нет универсального ответа:

Только PostgreSQL.

или:

Всегда S3.

Правильнее сначала ответить на другие вопросы:

Сколько файлов будет?
Какого они размера?
Должно ли приложение работать на нескольких серверах?
Кто имеет к ним доступ?
Как выполняется backup?
Как быстро нужно восстановиться после аварии?
Нужны ли версии?
Есть ли большие upload?
Как будет расти продукт?

После этого выбор становится намного понятнее.

Для небольшого закрытого проекта локальный диск VPS может оказаться самым практичным решением.

Для небольших бинарных данных база данных тоже не является ошибкой сама по себе.

Но по мере роста SaaS, CRM или B2B-платформы всё чаще появляется естественное разделение:

PostgreSQL
→ смысл файла

Object Storage
→ байты файла

База знает, кому принадлежит документ и кто может его получить.

Хранилище отвечает за надёжное размещение самого объекта.

А backend соединяет эти уровни через authorization, quota, lifecycle и контроль целостности.

Именно поэтому хороший выбор хранилища — это не вопрос:

«Куда проще записать Buffer?»

Гораздо полезнее другой:

«Как сделать так, чтобы через три года мы могли восстановить, перенести, удалить, защитить и масштабировать пользовательские файлы, не ломая остальной продукт?»

Если архитектура отвечает на этот вопрос, место хранения выбрано гораздо осознаннее.

Есть похожая задача?

Опишите продукт, интеграции и ограничения. До разработки зафиксируем объём, риски и критерии приёмки.

Обсудить проект →