devsnack
Documentation

Driply documentation

Run it, point it at your own accounts, ship it under your own brand. Written against version 1.0.0 of the download you get on CodeCanyon.

$19 on CodeCanyon
On this page
01

Interface tour

Screens from a running install. Nothing here is a mockup — the web shots are a production build served from a real deployment, and the mobile shots are rendered from the app’s own widgets against the real theme.

Web

Driply landing page
Landing pageThe marketing front page your customers arrive at. Headline, feature copy and calls to action come from src/config/brand.ts and the settings table, so the admin panel can change them without a redeploy.
Driply pricing page
PricingPlan cards are read from the plans table, not hardcoded. Adding a plan in the admin panel adds a card here. The three shown are what pnpm db:seed creates.
Driply sign-in page
Sign inEmail and password by default. Google and GitHub buttons render only when you have configured that provider — a disabled provider has no button and no callback URL at all.
Driply registration page
RegistrationNew accounts land on whichever plan is marked default. Public sign-up can be switched off entirely for a closed install where you create every account.

Admin panel

Driply admin dashboard
DashboardAccounts, storage consumed, recurring revenue and trash, over the real tables rather than sample figures. Install health is the panel worth knowing about: it names what a half-configured install fails at quietly — no SMTP means nobody can reset a password, no Stripe keys means paid plans cannot be bought — and links straight to the setting that fixes each one. The job counters below it read the same jobs table the worker polls, so a stalled worker is visible here.
Driply admin user management
User managementSearch, filter and sort every account, move somebody onto a different plan, promote an administrator, or suspend an account with a recorded reason. Suspending signs the account out everywhere and blocks sign-in — it never deletes anything, so the files stay put and restoring the account gives them back.
Driply admin plan editor
Plan editorPrice, currency, billing period, storage allowance, per-plan maximum file size and the feature list printed on the pricing card — all editable without a deploy, because the pricing page renders from these rows. Saving changes what new customers are offered and never re-prices existing subscribers.

Mobile

Mobile file browser
DriveThe same files as the web browser, typed icons, starred state, and a sort the app remembers.
Mobile upload progress
Resumable uploadFiles upload in chunks straight to your bucket, three parts at a time, and resume from the last delivered part.
Mobile share link options
Share linksPassword, expiry and a download cap, created from the phone and revocable from either surface.
Mobile file preview
PreviewImages, video, audio, PDF and text open in the app without downloading the whole file first.
Mobile search
SearchServer-side search across names and types, backed by a trigram index rather than a table scan.
Mobile offline files
OfflinePinned files are kept on the device and stay readable with no connection.
02

Overview

Driply is the source code for a file-hosting service you run yourself. You point it at a PostgreSQL database and an S3-compatible bucket, and you get a working product: customers sign up, upload files, organise them in folders, share them by link, and pay you for storage.

It ships as four surfaces built from one codebase:

  • Web platform — marketing pages, sign-up and sign-in, the file browser, the public share page, account and billing.
  • Admin panel — users, files, plans, subscriptions, settings, branding and an activity log, at /admin, gated on an account role.
  • REST API — a versioned /api/v1 surface. The mobile app is its first consumer; you can build against it too.
  • Flutter app — one codebase for Android and iOS, signing into the same accounts and showing the same files.

A fifth piece runs alongside them: a background worker that generates thumbnails and video posters, purges the trash, reconciles storage quotas and cleans up abandoned uploads.

Where the files actually go

Uploads never pass through your application server. The browser or the app asks Driply for a signed URL and sends the bytes straight to your bucket; downloads are a redirect to a signed URL pointing the same way. That is why a 2 GB upload does not need a 2 GB server, and why your bandwidth bill is your storage provider’s, not your host’s.

Note
Portability is the point. DATABASE_URL is one string — Neon, Supabase, Amazon RDS, or a PostgreSQL container you run yourself all work with no code change. Storage is one endpoint — Cloudflare R2, Amazon S3, Backblaze B2, Wasabi and DigitalOcean Spaces are all S3-compatible and all supported by the same driver. Nothing in Driply is tied to a vendor you cannot replace.
03

What’s included

  • Full commented source for the Next.js web application, the admin panel, the /api/v1 handlers and the background worker.
  • Full commented source for the Flutter application (Android and iOS).
  • The PostgreSQL schema as Drizzle definitions — 15 tables — with 4 SQL migrations that run clean against an empty database.
  • An idempotent seed script that creates the plan catalogue and an admin account.
  • The design system as tokens in exactly two files, one per platform.
  • Both typefaces self-hosted, with their SIL Open Font License files, cleared for you to redistribute.
  • Brand mark as SVG plus generated app icons and favicons.
  • The Flutter test suite — 416 passing tests.
  • A docker-compose.yml, a multi-stage driply.Dockerfile and a Caddyfile — app, worker and a reverse proxy with automatic HTTPS, plus an optional PostgreSQL service for a fully self-contained install.
  • This documentation, a quick-start guide, and a deployment guide in the package.
  • Six months of item support, per the CodeCanyon standard.

What is not included

Warning
No hosting. No domain. No database. No object storage. No Stripe, SMTP or OAuth account, and no credit with any of them. This is source code — you supply the infrastructure it runs on and you pay those providers directly. See Third-party costs, which puts numbers on it.

Also not included, and deliberately so: end-to-end client-side encryption, a desktop sync client, and in-app purchases on mobile. Known limitations is the full list.

04

Features

Accounts and authentication

  • Email and password sign-up with verification, password reset, and sessions stored in your database — not a signed token you cannot revoke.
  • Optional Sign in with Google and Sign in with GitHub. Each is off by default; while off, its button does not render and its callback URL does not exist.
  • An active-sessions list on the account page. Every session — web and mobile — is listed and can be revoked individually.
  • Roles, with /admin gated on them.

Drive

  • Folders nested arbitrarily deep, with move, copy, rename and multi-select.
  • Chunked resumable upload. Lose the connection halfway through a large file and it continues from the last chunk that arrived rather than starting again.
  • Trash with a retention window (30 days by default) and restore. Deleting a folder soft-deletes everything under it in one pass, and restoring puts it all back.
  • Starred files, a recent view, list and grid layouts, and a sort the app remembers per view.
  • Server-side search across names and file types, backed by a trigram index.
  • An empty state on every view — an empty file browser never looks broken.

Preview

  • Images, video with range-request seeking, audio, PDF, plain text and syntax-highlighted code open in the browser and in the app.
  • Image thumbnails generated by sharp; video poster frames by ffmpeg; PDF first pages by pdftoppm. The last two are optional system binaries — see Integrations matrix.

Sharing

  • Share any file or folder by link.
  • Per-link password, expiry date, and a maximum download count.
  • Drop-box mode — a link other people upload into, without an account.
  • Direct shares to other accounts on your install, with view or edit permission.
  • A public share page with no login wall, no app-install interstitial and no cookie banner. It is the only page a non-customer sees, so it is built to convert.

Plans, quota and billing

  • Plans defined in the database — name, storage allowance, price, billing interval, maximum file size and feature flags — and editable in the admin panel.
  • Stripe Checkout and a signature-verified webhook endpoint. You do not have to create Products or Prices in the Stripe dashboard; checkout prices each plan from its database row.
  • Invoice history with PDF download.
  • Quota tracked as a running total per account, adjusted inside the same transaction as the upload or delete, and reconciled nightly by the worker.
  • Over quota and lapsed accounts go read-only. Downloads and deletes keep working; uploads are refused. A customer whose card fails never loses their files.

Admin panel

  • Dashboard with storage, user and revenue analytics over time.
  • Users — search, inspect, change plan, change role, ban and unban.
  • Files — browse everything on the install, with owner and size.
  • Plans — create and edit the plan catalogue, including which plan new sign-ups land on.
  • Subscriptions — the current state of every paying account.
  • Settings — site name, branding, registration on/off and upload limits, written to the database and applied without a redeploy.
  • Activity log — an append-only audit trail written from the first day the install runs.
  • A read-only mode for public demos, set by environment variable rather than by a setting the panel itself could switch off.

Mobile app

  • Sign in, browse, search, upload, download, preview and share — the same account as the web.
  • Uploads from the file picker, the photo library or the camera, with a progress notification while the app is in the background.
  • Offline pinning — keep chosen files on the device and read them with no connection.
  • Native share sheet integration.
  • The session token is held in the Android Keystore and the iOS Keychain, never in shared preferences.
05

Architecture

driply/
driply/
├── docker-compose.yml          app + worker + reverse proxy, optional local database
├── Caddyfile                   reverse proxy, automatic HTTPS
├── web/                        Next.js application — owns its own package.json
│   ├── driply.Dockerfile       multi-stage; ffmpeg and pdftoppm included
│   ├── .dockerignore           keeps .env out of the image layers
│   ├── src/app/                App Router
│   │   ├── (marketing)/        Landing, pricing, terms, privacy
│   │   ├── (auth)/             Sign in, register, verify, reset
│   │   ├── drive/              File browser, starred, recent, trash, search
│   │   ├── s/[token]/          Public share page
│   │   ├── account/            Profile, sessions, billing, invoices
│   │   ├── admin/              Admin panel (role-gated)
│   │   └── api/
│   │       ├── v1/             REST surface consumed by the app and by you
│   │       ├── auth/           Authentication endpoints
│   │       └── webhooks/       Stripe
│   ├── src/lib/                Domain logic: files, shares, billing, admin, auth
│   ├── src/storage/            S3-compatible driver
│   ├── src/worker/             Background job loop and handlers
│   ├── src/db/                 Drizzle schema, migrations runner, seed
│   ├── src/config/             Typed, validated configuration
│   ├── src/app/theme.css       ← design tokens (web)
│   ├── drizzle/                Generated SQL migrations
│   └── .env.example
└── mobile/                     Flutter application
    ├── lib/src/features/       auth, browse, drive, upload, download,
    │                           offline, preview, search, share, settings
    ├── lib/src/theme/app_theme.dart   ← design tokens (mobile)
    ├── lib/src/config/         API origin and app-level constants
    ├── test/                   416 tests
    └── preview/                Store-ready screenshots

Stack

LayerChoiceWhy it is this
Web framework
Next.js 16 (App Router)
Server components read the database in-process; mutations are server actions.
Language
TypeScript 5.9, strict
Build and lint both run at zero warnings.
Styling
Tailwind CSS v4
CSS-first. theme.css is the config — there is no tailwind.config.ts.
Database access
Drizzle ORM 0.45
Typed queries. Raw SQL only for the recursive folder CTEs and the search index.
Postgres driver
postgres.js 3.4
A TCP driver, so transactions hold across statements — the worker’s job claim depends on it.
Authentication
Better Auth 1.7
Database sessions with credentials login, which is what a revocable session list requires.
Object storage
AWS SDK v3 + presigner
One driver, five providers. Bytes never touch the app server.
Payments
Stripe 22
Behind a narrow provider interface — see Payments.
Email
nodemailer 8 over SMTP
Any SMTP host. No proprietary email API to sign up for.
Background jobs
A jobs table in PostgreSQL
Polled with FOR UPDATE SKIP LOCKED. No Redis, no queue service, nothing extra to install.
Mobile
Flutter 3, Riverpod 2, GoRouter, dio
One codebase, both platforms, no platform-specific business logic.

Design invariants

These hold throughout the codebase. If you extend Driply, keeping them will save you pain:

  • Design tokens live in exactly two files — web/src/app/theme.css and mobile/lib/src/theme/app_theme.dart. There are no hardcoded colours anywhere else.
  • Nothing reads process.env at a call site. All configuration goes through the typed config module, which validates at boot and falls back to the settings table. ESLint enforces this.
  • The web app never calls its own REST API over HTTP. Pages read the domain layer directly. The two exceptions are the upload engine and the download link, both of which need to reach object storage rather than the server.
  • Folders are rows in the files table, not a separate table, which is what makes move, copy and recursive trash simple.
  • Deleting is always deleted_at plus a worker purge. Nothing is hard-deleted at the moment a user clicks.
  • /admin answers 404 to non-admins, not 403 — a 403 would confirm the route exists.
06

Installation

Requirements

RequirementVersionNeeded for
PostgreSQL
14 or newer
All dataRequired
S3-compatible bucket
—
All filesRequired
Docker + Compose
any recent
The quickest install. Everything below except the bucket and Flutter comes with the image.
Node.js
20.11 or newer
Running it without Docker
pnpm
10
Running it without Docker
Flutter
3.13 or newer
Building the mobile appOptional
ffmpeg
any recent
Video poster framesOptionalalready in the Docker image
pdftoppm
poppler-utils
PDF thumbnailsOptionalalready in the Docker image

Install and run

Note
Using Docker? Skip this section — Deployment gets you running with three commands, and ffmpeg and pdftoppm are already in the image. What follows is the manual path.
terminal
cd web
pnpm install
cp .env.example .env        # then fill it in — see below
pnpm db:migrate             # create the schema
pnpm db:seed                # create plans and an admin account
pnpm dev                    # http://localhost:3000

In a second terminal, start the worker. Thumbnails, trash purging and quota reconciliation are its job, so an install without it works but never finishes anything in the background:

terminal
cd web
pnpm worker

The minimum you must configure

Everything is documented inline in .env.example. These six have no working default:

APP_URL
Public origin of this install, no trailing slash. Used for share links, emails and OAuth callbacks.
AUTH_SECRET
Session signing key, 32+ characters. Generate with openssl rand -base64 32. Rotating it signs everyone out.
DATABASE_URL
PostgreSQL, pooled connection string. Everything except migrations uses this.
DATABASE_URL_UNPOOLED
PostgreSQL, direct connection string. Migrations only. On a plain PostgreSQL server both are the same string.
S3_ENDPOINT, S3_REGION, S3_BUCKET
Where files go. See Object storage.
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY
Credentials for that bucket.
Note
Configuration is validated when the app boots. A missing or malformed value fails immediately and lists every problem at once, rather than throwing on somebody’s first upload.

Building for production

terminal
cd web
pnpm build      # next build, then copies static assets into the standalone bundle
pnpm start      # runs the built server
Warning
pnpm start is not next start. The build emits a standalone server and a post-build step copies the static assets and public/ into it. Run the package scripts as given — starting the server another way produces a site with no CSS that still passes a health check.
07

Database

Driply needs one PostgreSQL database. Any PostgreSQL 14+ works: a managed service, or one you run yourself.

  1. 01

    Create the database and set both URLs

    Two connection strings, because migrations and the running app have different needs. Migrations issue DDL and take a session-scoped advisory lock, which does not survive a transaction-mode connection pooler. On a managed provider that offers pooling, put the pooled string in DATABASE_URL and the direct one in DATABASE_URL_UNPOOLED. On a plain PostgreSQL server there is no pooler, so use the same string for both.

  2. 02

    Run the migrations

    terminal
    pnpm db:migrate

    Four migrations, applied in order, creating fifteen tables: plans, users, accounts, sessions, verifications, files, file_shares, share_links, upload_sessions, subscriptions, invoices, settings, jobs, activity_log and api_tokens.

  3. 03

    Seed the plan catalogue and your admin account

    terminal
    pnpm db:seed

    This creates three plans and two accounts, then prints the generated passwords once:

    PlanStoragePriceMax file
    Free (default for new sign-ups)
    5 GB
    Free
    1 GB
    Personal
    100 GB
    $5 / month
    2 GB
    Professional
    1 TB
    $15 / month
    5 GB

    The accounts are admin@driply.local (admin) and demo@driply.local (ordinary user). Change the addresses and prices in the admin panel afterwards — they are starting points, not fixtures.

Warning

There is no default password in this package, and you should not add one. The seed invents a strong random password for each account and prints it to your terminal once. Copy it then. A shipped default credential is the most common way a self-hosted install gets compromised.

If you need fixed credentials — for a public demo where you publish them on purpose — set SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD (and the SEED_DEMO_* pair) in .env before seeding.

Note
The seed is safe to re-run. Every write is an upsert keyed on a stable column, so it never duplicates a plan and never touches an existing account’s files. Re-running it without the SEED_* variables leaves existing passwords alone.
08

Object storage

Required. Every file lives in S3-compatible object storage. There is no local-disk mode — the app will not start without a bucket. That is a deliberate design choice: uploads and downloads go directly between the browser or app and the bucket using signed URLs, so file bytes never pass through your server and a large upload never occupies it.

Choosing a provider

Any S3-compatible provider works. Only the endpoint changes:

ProviderS3_ENDPOINTS3_REGION
Cloudflare R2
https://<account-id>.r2.cloudflarestorage.com
auto
Amazon S3
https://s3.<region>.amazonaws.com
e.g. eu-west-1
Backblaze B2
https://s3.<region>.backblazeb2.com
as issued
Wasabi
https://s3.<region>.wasabisys.com
as issued
DigitalOcean Spaces
https://<region>.digitaloceanspaces.com
as issued
Note
Cloudflare R2 is the recommended default because it charges nothing for egress, and egress is the single largest running cost of a file host. On S3 you pay for every gigabyte your customers download.

CORS — do not skip this

Because browsers upload straight to the bucket, the bucket must accept requests from your site. This is the single most common setup failure: everything looks configured, the app starts, and then every upload from a browser fails. Paste this into your bucket’s CORS settings, changing only the origins.

cors.json
[
  {
    "AllowedOrigins": [
      "https://drive.yourdomain.com"
    ],
    "AllowedMethods": ["PUT", "GET", "HEAD"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

Where each line matters:

AllowedOrigins
Exactly your APP_URL, scheme included and no trailing slash. Add a second entry such as http://localhost:3000 while you develop. Avoid "*" in production — it lets any site drive uploads with a URL it has managed to obtain.
AllowedMethods
PUT uploads each part. GET and HEAD cover in-browser preview and range requests for video playback.
AllowedHeaders
The browser sends a Content-Type derived from the file and the signed request carries its own headers. "*" is the reliable setting; if your policy forbids wildcards, list content-type at minimum.
ExposeHeaders
The one people miss. Resumable upload reads back the ETag of every part and sends it to the server to assemble the finished object. Without this the PUT succeeds but script cannot see the header, so every multi-part upload fails at the last step. Driply detects this exact case and reports it by name rather than failing silently.
Note
Where to paste it. On Cloudflare R2: your bucket → Settings → CORS policy → Edit, which takes this JSON as-is. On Amazon S3: bucket → Permissions → Cross-origin resource sharing, also this JSON. DigitalOcean Spaces and Backblaze B2 offer the same fields as a form — fill them with the values above. Wasabi expects the older XML form, in which case the equivalent is:
cors.xml
<CORSConfiguration>
  <CORSRule>
    <AllowedOrigin>https://drive.yourdomain.com</AllowedOrigin>
    <AllowedMethod>PUT</AllowedMethod>
    <AllowedMethod>GET</AllowedMethod>
    <AllowedMethod>HEAD</AllowedMethod>
    <AllowedHeader>*</AllowedHeader>
    <ExposeHeader>ETag</ExposeHeader>
    <MaxAgeSeconds>3600</MaxAgeSeconds>
  </CORSRule>
</CORSConfiguration>

A CORS change can take a minute or two to apply, and browsers cache the preflight for MaxAgeSeconds. After editing, hard-reload before deciding it did not work.

09

Third-party services and costs

Warning

This item is source code, not a hosted service. Your purchase includes no hosting, no database, no object storage, no third-party account and no credit with any provider. Every service below is operated by a third party, billed to you directly by that third party, under pricing that we neither set nor control and that can change at any time.

Check each provider’s current pricing yourself before you commit. The figures below are indicative only and were correct at the time of writing.

Required — Driply does not run without these

WhatWho bills youIndicative cost
Somewhere to run the app and worker
Your VPS or platform host
A 2 vCPU / 4 GB VPS is roughly $12–24 / month. A managed platform is typically more.
PostgreSQL database
Your database host
Free tiers exist at launch scale; expect roughly $19–25 / month once you outgrow one. Self-hosting it on the same VPS costs nothing extra in money and something in your time.
S3-compatible object storage
Your storage provider
Charged per GB stored per month, plus — on most providers — per GB downloaded. Cloudflare R2 is around $0.015 per GB-month with no egress charge; Amazon S3 storage is cheaper per GB but egress is billed and usually dominates the bill for a file host.
Domain name
Your registrar
Typically $10–15 / year.

Optional — off until you configure them

WhatWho bills youIndicative cost
SMTP email
Your email provider
Free tiers cover a few hundred messages a day; paid plans commonly start around $15–25 / month.
Stripe
Stripe
No monthly fee, but a percentage of every payment plus a fixed fee per transaction, varying by country. This is the cost buyers most often forget: it comes out of your revenue on every subscription you sell.
Google / GitHub sign-in
—
No charge at ordinary volumes.
ffmpeg, pdftoppm
—
Free open-source software you install on your own server.

Two costs scale with your success rather than sitting flat: storage, which grows with every file your customers keep, and payment processing, which grows with revenue. Price your plans with both in mind.

10

Integrations matrix

Everything optional degrades quietly. Nothing crashes, nothing retries forever, and no feature half-appears in the interface when its dependency is missing.

IntegrationUnlocksConfigurationBehaviour when unset
PostgreSQLRequired
Everything
DATABASE_URL, DATABASE_URL_UNPOOLED
The app refuses to start.
Object storageRequired
All uploads, downloads and previews
S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY
The app refuses to start. There is no local-disk fallback by design.
SMTPOptional
Email verification, password reset, notifications
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_SECURE, MAIL_FROM
Transactional email is disabled. See the warning in Email — this one has a consequence.
StripeOptional
Paid plans and checkout
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET
Plans still exist and quotas still apply; nothing can be purchased. Useful for a free or invite-only install.
Google sign-inOptional
OAuth login
AUTH_GOOGLE_ENABLED, AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET
The button does not render and the callback route does not exist. Enabling it without credentials is refused at boot, not at the moment a visitor clicks.
GitHub sign-inOptional
OAuth login
AUTH_GITHUB_ENABLED, AUTH_GITHUB_ID, AUTH_GITHUB_SECRET
Same as Google.
ffmpegOptional
Video poster frames
System binary on PATH
Videos are marked unsupported for preview and show a typed icon. The worker logs which package to install. No job fails or retries.
pdftoppmOptional
PDF first-page thumbnails
System binary on PATH (poppler-utils)
Same as ffmpeg. PDFs still open in the browser’s viewer.
sharp
Image thumbnails
Nothing to do
Not applicable — it is a normal dependency and ships a prebuilt binary with pnpm install.
Warning
Binary detection is cached for the life of the worker process. If you install ffmpeg or pdftoppm after starting the worker, restart the worker or it will keep believing they are absent.
11

Email

Driply sends transactional email over plain SMTP through nodemailer. Any provider works — a transactional email service, your own mail server, or a mailbox with SMTP enabled. There is no proprietary email API to sign up for.

If you do not already have one, Brevo, Mailgun, Postmark, SendGrid and Amazon SES all expose standard SMTP credentials and several offer a free or low-cost starting tier. They are independent companies that bill you directly, and their pricing and free allowances are theirs to change — check the current terms yourself before relying on one.

.env
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=your-username
SMTP_PASSWORD=your-password
SMTP_SECURE=false          # true for implicit TLS on 465, false for STARTTLS on 587
MAIL_FROM=Driply <no-reply@yourdomain.com>
Warning
Configure SMTP before you open registration to the public. New accounts must verify their email address before they can upload. With SMTP unset the verification message is never sent, so a visitor can sign up and browse but will never be able to upload anything — and the refusal will look to them like a bug. Either configure SMTP, or turn off public registration with REGISTRATION_ENABLED=false and create accounts yourself in the admin panel.
12

Payments

Billing runs on Stripe Checkout. Leave the keys empty and Driply runs perfectly well without billing — plans and quotas still apply, there is simply nothing to buy.

  1. 01

    Set the keys

    .env
    STRIPE_SECRET_KEY=sk_live_...
    STRIPE_WEBHOOK_SECRET=whsec_...
  2. 02

    Add one webhook endpoint

    In the Stripe dashboard, point a webhook at <APP_URL>/api/webhooks/stripe and subscribe it to:

    • checkout.session.completed
    • invoice.paid
    • customer.subscription.deleted
    • customer.subscription.updated (optional, but keeps cancellations current)

    Then paste that endpoint’s signing secret into STRIPE_WEBHOOK_SECRET. Every incoming webhook has its signature verified before anything is read from it; a request that cannot be verified is rejected with a 400 and changes nothing.

Note
You do not need to create Products or Prices in Stripe. Checkout prices each plan from its row in the plans table, so editing a price in the admin panel is enough. If you already have a Stripe Price you would rather use, put its id in that plan’s features JSON as stripePriceId and it will be used instead.

Other payment gateways

Billing sits behind a deliberately narrow PaymentProvider interface — start a checkout, verify a webhook signature, read the event. Stripe is the only implementation included. The interface is the extension point for adding a regional gateway; implementing one is development work, not configuration.

13

Background worker

The worker is a plain Node process. It polls a jobs table every five seconds and claims work with SELECT … FOR UPDATE SKIP LOCKED, so you can run more than one copy and they will not collide.

terminal
cd web
pnpm worker
Note
There is no Redis, no message broker and no queue service to install. The job queue is a table in the database you already have. That is one fewer service in your install, one fewer thing to monitor, and one fewer bill.
Thumbnails and posters
Generates image thumbnails, video poster frames and PDF first pages, and writes them back to your bucket.
Trash purge
Permanently removes files whose retention window has passed — 30 days by default, set with TRASH_RETENTION_DAYS.
Quota reconciliation
Recomputes each account’s storage total nightly, correcting any drift.
Abandoned uploads
Aborts multipart uploads that were never completed. This one costs real money if it does not run: incomplete parts are billed by your storage provider and are invisible in a normal bucket listing.
Lapsed plans
Moves accounts whose subscription ended to the read-only state.
Invoice PDFs
Renders invoice documents for the billing page.
Warning
Run the worker in production. It is a separate long-running process from the web server. Without it, trash is never emptied, quotas drift, thumbnails never appear, and abandoned upload parts accumulate on your storage bill.
VariableDefaultMeaning
WORKER_POLL_INTERVAL_MS
5000
How often to poll for work.
WORKER_BATCH_SIZE
5
Jobs claimed per poll.
TRASH_RETENTION_DAYS
30
How long trashed files are kept.
UPLOAD_SESSION_TTL_HOURS
24
How long an unfinished upload stays resumable.
14

Mobile app

terminal
cd mobile
flutter pub get
flutter run

Point it at your server — do this first

The app ships pointing at our public demo so it runs the moment you open it. It will not show your data until you change this. Edit the API origin in mobile/lib/src/config/app_config.dart:

app_config.dart
static const String _apiOrigin = 'https://drive.yourdomain.com';

Or override it at build time without editing the file, which is the convenient path for CI:

terminal
flutter build apk --dart-define=DRIPLY_API_ORIGIN=https://drive.yourdomain.com
Warning
Use an HTTPS origin. Both platforms block plain HTTP by default, and a self-signed certificate will be rejected. For local development against a machine on your network, point the Android emulator at http://10.0.2.2:3000 and the iOS simulator at http://localhost:3000 — and note that plain HTTP needs a platform exception you should not ship to production.

Renaming the app

WhatWhereShips as
Android application ID
mobile/android/app/build.gradle.kts
dev.devsnack.driply
Android display name
mobile/android/app/src/main/AndroidManifest.xml
Driply
iOS bundle identifier
Xcode → Runner → Signing & Capabilities
dev.devsnack.driply
iOS display name
mobile/ios/Runner/Info.plist
Driply

Platform targets

PlatformSettingValue
Android
minSdk
24 — Android 7.0 and newer
Android
targetSdk / compileSdk
37, pinned to explicit integers
iOS
Deployment target
17.0

Shipping the app to the stores

Warning
There is no pre-built binary that talks to your server, and there cannot be one. The API origin is compiled into the app, and every store requires the binary to be signed by your developer account — not ours. An app we built and signed could never be published under your name. So the supported path is the one below: you change one line, build, and sign with your own key. The APK on the item page is a demo build pointed at our demo server, for evaluating the app before buying. It is not a starting point for your install.

You need a Mac only for the iOS half. Android can be built and published from Windows, macOS or Linux.

Android — one-time setup

  1. 01

    Create an upload keystore

    This is your signing identity. Keep it safe: lose it and you cannot ship an update to an app already on Play under the same listing.

    terminal
    keytool -genkey -v -keystore ~/driply-upload.jks \
      -keyalg RSA -keysize 2048 -validity 10000 -alias upload
  2. 02

    Point the build at it

    Create mobile/android/key.properties:

    key.properties
    storePassword=<the store password you just chose>
    keyPassword=<the key password you just chose>
    keyAlias=upload
    storeFile=/Users/you/driply-upload.jks

    That is the whole configuration — no Gradle editing. android/app/build.gradle.kts already looks for key.properties and signs the release build with it when the file is there. When it is absent the release build falls back to debug keys so flutter run --release still works on a fresh clone; a debug-signed build is rejected by both stores, so it cannot be published by mistake. key.properties, *.jks and *.keystore are all gitignored — never commit a signing key.

  3. 03

    Build the bundle Google Play requires

    Play takes an .aab, not an APK:

    terminal
    cd mobile
    flutter build appbundle --release

    The result is at build/app/outputs/bundle/release/app-release.aab. A standalone APK for direct distribution — your own website, or sideloading for an internal team — is flutter build apk --release instead.

Android — publishing

  1. 01
    Register at the Google Play Console. It is a one-off fee, currently US$25, paid to Google.
  2. 02
    Create an app, and set the package name to whatever you changed the application ID to above. It is permanent — it cannot be changed after the first upload.
  3. 03
    Upload the .aab to a testing track first, then production.
  4. 04
    Fill in the store listing, a privacy policy URL, and the Data safety form. Driply uploads files to your storage, so the answers describe your service, not ours.
  5. 05
    Bump version: in mobile/pubspec.yaml for each subsequent upload — it supplies both versionCode and versionName, and Play refuses a version code it has already seen.

iOS — one-time setup

Requires a Mac with Xcode and an Apple Developer Program membership, currently US$99 a year, paid to Apple.

  1. 01
    Register your bundle identifier in the Apple Developer portal — the same value you set in Xcode, and permanent once used.
  2. 02
    Open mobile/ios/Runner.xcworkspace in Xcode, select Runner → Signing & Capabilities, tick Automatically manage signing and choose your team. Xcode creates the certificate and provisioning profile for you; this is far less painful than managing them by hand.
  3. 03
    Create the app record in App Store Connect with that bundle identifier.
terminal
cd mobile
flutter build ipa --release

Upload build/ios/ipa/*.ipa with Apple’s Transporter app, or open the generated Xcode archive and use Distribute App. Then submit for review from App Store Connect. Expect to answer the export-compliance question — the app uses only standard HTTPS — and to supply a privacy policy and App Privacy answers describing your service.

Warning
Give the reviewer a working account. Both stores reject apps whose reviewer cannot get past the sign-in screen. Put a real, working email and password for your server in the review notes, on an account that has a few files in it. This is the single most common cause of a first submission being rejected.

Before you build either one

  • Set the API origin to your server, as described above. An app pointing at our demo is the single easiest thing to ship by accident — check it first.
  • Change the application ID and bundle identifier off dev.devsnack.driply. Both are permanent once published, and dev.devsnack is our namespace, not yours.
  • Replace the app name and icon so the listing is yours.
  • Your server must be on HTTPS with a certificate from a real authority. Both platforms block plain HTTP, and a self-signed certificate fails on a real device even though it may work in a simulator.

If you would rather not deal with the stores at all, you do not have to. The web app is a full-featured responsive site that works in a mobile browser, and a signed APK can be distributed straight from your own website. The stores are a distribution choice, not a requirement for running Driply.

Note
There is no purchase screen in the app, and that is intentional. Apple and Google both require their own in-app purchase systems for digital goods, and taking payment for storage anywhere else in a mobile app risks rejection. The app shows the current plan and usage; upgrades happen on the web.
15

Deployment

Driply is built for a long-running Node process — a VPS, a container, or a platform that runs containers. The build emits a self-contained standalone server.

Warning
Do not deploy Driply to a serverless platform. The background worker needs a long-lived process to hold its job-claim transactions open, and serverless functions cannot do that. Without the worker, trash is never purged, quotas drift, and abandoned upload parts accumulate on your storage bill.

Two processes

Whatever you deploy on, you are running two things against the same database and bucket:

ProcessCommandNotes
Web application
pnpm build then pnpm start
Put a reverse proxy with TLS in front of it.
Background worker
pnpm worker
No inbound port. Needs ffmpeg and pdftoppm installed if you want video and PDF thumbnails.

With Docker — the shortest path

The package ships a docker-compose.yml at its root that runs both processes plus a reverse proxy with automatic HTTPS. ffmpeg and poppler-utils are already inside the image, so video posters and PDF thumbnails work with nothing further to install.

terminal
cp web/.env.example web/.env            # then fill it in
docker compose run --rm app pnpm db:migrate
docker compose run --rm app pnpm db:seed     # save the password it prints
docker compose up -d

For a real domain, set DRIPLY_SITE_ADDRESS and Caddy obtains and renews the certificate itself:

terminal
DRIPLY_SITE_ADDRESS=drive.example.com docker compose up -d

Point the domain’s DNS at the server first — certificate issuance requires the name to resolve there. Set APP_URL in web/.env to the same HTTPS origin.

Bringing your own database, or not

By default the compose file contains no database: DATABASE_URL points at whatever PostgreSQL you already have. If you would rather have everything on one machine, an optional profile runs PostgreSQL alongside it:

terminal
docker compose --profile local-db up -d

Then set both database URLs in web/.env to postgresql://driply:<POSTGRES_PASSWORD>@db:5432/driply, and override POSTGRES_PASSWORD — the default in the file exists so a first trial runs, and is not a production password. Data lives in a named volume, so it survives docker compose down; docker compose down -v deletes it.

Note
There is no storage container and there will not be one. Driply needs S3-compatible object storage it can issue signed URLs for, so that browsers upload straight to it. That is the one piece you always bring yourself — see Object storage.
Note

The image file is web/driply.Dockerfile, not Dockerfile. docker-compose.yml points at it by name, so the commands above work as written and you can ignore this. It only matters if you build the image by hand, which needs docker build -f driply.Dockerfile . from inside web/.

The name is deliberate: some hosting platforms auto-detect a file called Dockerfile and build from it whether or not you asked them to, overriding the build settings you configured. Naming it something else keeps that choice yours. Rename it if you prefer — just update build.dockerfile in docker-compose.yml to match.

Warning
Secrets reach the containers at runtime through web/.env, never through the build. The .dockerignore excludes .env for a specific reason: a COPY . . that picks it up bakes your credentials into an image layer, where they survive being deleted in a later layer and can be read by anyone who pulls the image.

On a VPS, without Docker

  1. 01
    Install Node.js 20.11+ and pnpm. Install ffmpeg and poppler-utils if you want video posters and PDF thumbnails.
  2. 02
    Copy the web/ directory to the server, create .env, and run pnpm install.
  3. 03
    pnpm db:migrate, then pnpm db:seed.
  4. 04
    pnpm build
  5. 05
    Run pnpm start and pnpm worker under a process supervisor — systemd, pm2 or your platform’s equivalent — so both restart on boot and on failure.
  6. 06
    Put Nginx, Caddy or your platform’s router in front with a TLS certificate, and set APP_URL to the public HTTPS origin.

On a container platform

Deploy the repository twice from the same source: once with the start command pnpm start, once with pnpm worker. Give both the same environment variables. Run pnpm db:migrate once as a release step, not on every boot of every instance.

Note
A deployment guide with worked configuration lives at web/DEPLOYMENT.md in the package, including the environment split between the two processes and a first-deploy checklist.

Upgrading

Take a database backup first — the same advice applies to any software you run. Migrations are additive and are applied in order; your data stays put.

terminal
# Docker
docker compose build
docker compose run --rm app pnpm db:migrate
docker compose up -d

# Without Docker
cd web && pnpm install && pnpm db:migrate && pnpm build
# then restart both processes
16

Branding and customization

Colours and type

The whole design system is tokens in two files. Change them and both surfaces follow — this is the fastest, highest-leverage customization in the package.

Web
web/src/app/theme.css
Mobile
mobile/lib/src/theme/app_theme.dart

There is no tailwind.config.ts and you should not add one — Tailwind v4 is CSS-first, so theme.css is the configuration.

Worked example: change the accent colour

Driply’s accent is violet. Suppose your brand is teal. This is the entire change on the web — one line, in web/src/app/theme.css:

Before

theme.css
@theme {
  --color-accent: #7b5cff; /* primary action, links, active nav */
}

After

theme.css
@theme {
  --color-accent: #0d9488; /* primary action, links, active nav */
}

Save, and every primary button, link, active navigation item, focus ring and progress bar in the web app and the admin panel is teal. You do not hunt for the other places — there are none. Row selection in the drive follows too, because it is derived from the same token rather than written out a second time:

theme.css
--color-accent-selected: color-mix(in srgb, var(--color-accent) 12%, transparent);

The mobile app is the matching one-line change in mobile/lib/src/theme/app_theme.dart, in Flutter’s 0xAARRGGBB notation — the same hex with 0xFF in front of it for full opacity:

Before

app_theme.dart
static const Color accent = Color(0xFF7B5CFF);

After

app_theme.dart
static const Color accent = Color(0xFF0D9488);
Note
One extra line on mobile, and it is worth doing. Just below it sits accentDark — the accent used on dark backgrounds, lightened so it keeps a readable contrast ratio against the dark page. Set it to a lighter tint of your new colour (for teal, 0xFF2DD4BF) rather than leaving the violet behind. If you skip this, light mode is correct and dark mode still shows the old accent.

Every other colour in the palette works exactly the same way — change the value, save, done. Two rules keep the result looking deliberate rather than broken: keep --color-ink true black, because the design’s borders and hard shadows depend on it, and check contrast if you pick a pale accent, since white button text needs a dark enough background to stay legible.

TokenValueUsed for
--color-paper
#f4f4f0
Page background
--color-ink
#000000
Borders, text, shadows
--color-surface
#ffffff
Cards, panels, tables
--color-accent
#7b5cff
Primary action, links, active navigation
--color-signal
#00c48c
Success, storage available
--color-caution
#ffd84d
Quota warning, expiring links
--color-alert
#ff4d4d
Destructive, over quota, expired
--color-muted
#6b6b66
Secondary text only

Typography is Bricolage Grotesque for headings and Inter for interface text, both self-hosted in web/src/assets/fonts/ with their SIL Open Font License files. They are licensed for you to redistribute, and nothing is fetched from a font CDN — so your site works for visitors in regions where those CDNs are blocked, and loads without a third-party request.

Name, logo and copy

  • Site name and marketing copy — the admin settings page, applied without a redeploy. Defaults live in web/src/config/brand.ts.
  • Logo — upload in admin, or replace web/public/brand/driply-mark.svg.
  • Terms and privacy — templates in web/src/config/legal.ts, editable from admin. They are starting points; have a lawyer look at yours.
  • Plans and prices — the admin plans page, or web/src/db/seed/plans.ts before first seed.
  • Upload limits — UPLOAD_MAX_FILE_SIZE_BYTES (2 GB default) and UPLOAD_CHUNK_SIZE_BYTES (8 MB default). The maximum file size is capped at 10 GB in the configuration module.
17

REST API

Everything under /api/v1 is a stable, versioned contract. The Flutter app is its first consumer, and you can build your own clients against it.

Authenticating

Driply uses session bearer tokens. A client signs in once with an account’s credentials, receives a token, and sends that token on every subsequent request. This is the same database-backed session the web app uses — which is the point: a token issued to a script or a phone shows up in that account’s active-sessions list at /account and can be revoked from there. A token that could not be revoked would be a worse deal for your customers.

Step 1 — obtain a token. POST the credentials to the sign-in endpoint:

terminal
curl -i -X POST https://drive.yourdomain.com/api/auth/sign-in/email \
  -H 'Content-Type: application/json' \
  -d '{"email":"you@example.com","password":"your-password"}'

The token comes back as a response header, not in the body:

response
HTTP/1.1 200 OK
set-auth-token: 8f2c1d...e91a
Warning
Read the set-auth-token header, not the token field in the JSON body. The body’s copy is unsigned and will be rejected. This trips people up exactly once, and the symptom is a 401 on every call that looks correct.

Step 2 — send it on every request to /api/v1:

terminal
curl https://drive.yourdomain.com/api/v1/files \
  -H 'Authorization: Bearer 8f2c1d...e91a'

Store the token the way a password is stored. It is as good as the account until it expires or is revoked; sessions last 30 days by default. Signing out, or revoking the session from /account, invalidates it immediately. A request without a valid token gets 401; a valid token acting on someone else’s file gets 404, not 403, so the API never confirms that a file it will not show you exists.

The api_tokens table

If you read the schema you will find an api_tokens table, and it is fair to ask what it does, because nothing in this release reads or writes it. It is a reserved structure, not a feature — we are calling that out rather than letting you discover it.

It is there for named, long-lived integration tokens: the case where you want a backup script, a CI job or a desktop sync client to hold a credential that is not tied to a human’s 30-day login session — mintable per integration, individually revocable, and independently expiring. The columns show the intended shape: a name so you can tell one integration from another, a token_hash (only a SHA-256 digest is ever stored, so the token itself is shown once at creation and is unrecoverable afterwards), last_used_at, expires_at and revoked_at.

Session bearer token · available nowapi_tokens · reserved
How you get one
Sign in at /api/auth/sign-in/email
Not issued in this release — no endpoint or admin screen creates one
How it is sent
Authorization: Bearer <token>
Intended to be the same header
Lifetime
30 days, renewed by use
Intended to be long-lived with an optional expiry
Revoking
/account → active sessions
Intended per-token
Built for
The Flutter app, your own clients, scripts
Desktop sync and unattended integrations

So: use the session bearer token above for everything today. It authenticates the whole of /api/v1 and has no functional gap for a third-party client — the Flutter app in this package is built on it and uses nothing else. The table is left in the schema deliberately so that adding named tokens later is a migration you do not have to write, and so that a half-built feature is not shipped pretending to be finished.

Endpoints

Health
GET /health
Account
GET /account, GET /storage
Files and folders
GET /files, GET|PATCH|DELETE /files/:id, POST /files/batch, POST /folders, GET /breadcrumbs
Content
GET /files/:id/content (302 to a signed URL), GET /files/:id/thumbnail
Search
GET /search
Upload
POST /uploads, GET|DELETE /uploads/:id, POST /uploads/:id/part-urls, PUT /uploads/:id/parts/:n, POST /uploads/:id/complete
Sharing
GET|POST /share-links, GET /files/:id/shares, POST /files/:id/share-link
Public share access
GET /share-links/:token, GET /share-links/:token/content, and the drop-box upload endpoints beneath it
Note
Uploads are a protocol, not a single call. Create a session, ask for signed part URLs, PUT the parts straight to your bucket, report each one, then complete. That shape is what makes an upload resumable and lets a client send several parts at once — and it means file bytes never travel through your server.
18

Security

  • No credential ships in this package. Every value in .env.example is a placeholder. There are no keys in the web bundle, none in the app binary, and no default password anywhere.
  • The mobile app holds no third-party credentials at all. It talks only to your /api/v1 surface; every Stripe, SMTP and storage secret stays on your server. An APK can be unpacked in minutes, which is why nothing sensitive is in it.
  • Sessions live in your database and can be revoked individually from the account page — web and mobile alike.
  • Signed URLs are short-lived and scoped to a single object.
  • Stripe webhooks are signature-verified before anything in the payload is read.
  • /admin returns 404 to non-admins, so the route’s existence is not disclosed.
  • Passwords are hashed, never stored or logged in the clear.
  • The activity log is written from day one, because an audit trail cannot be backfilled after you need it.

Set a strong unique AUTH_SECRET, keep .env out of version control, run behind HTTPS, and use storage credentials scoped to the one bucket Driply uses.

19

Production checklist

  1. 01
    APP_URL is your public HTTPS origin, with no trailing slash.
  2. 02
    AUTH_SECRET is freshly generated and at least 32 characters — not the value from .env.example.
  3. 03
    Both database URLs are set, and pnpm db:migrate has been run.
  4. 04
    pnpm db:seed has been run and you have saved the printed admin password.
  5. 05
    You have signed in to /admin and changed the seeded admin email and password.
  6. 06
    Bucket CORS allows your origin for PUT and GET, with ETag exposed.
  7. 07
    SMTP is configured — or public registration is off.
  8. 08
    The worker is running under a supervisor, alongside the web process.
  9. 09
    ffmpeg and pdftoppm are installed if you want video and PDF thumbnails, and the worker was restarted afterwards.
  10. 10
    Stripe keys and the webhook endpoint are set, if you are charging.
  11. 11
    Plans, prices and storage limits reflect what you actually intend to sell.
  12. 12
    Site name, logo, colours, terms and privacy have been reviewed.
  13. 13
    The mobile app’s API origin points at your server, not the demo.
  14. 14
    ADMIN_READ_ONLY is false — it is a demo switch, not a production one.
  15. 15
    If you are on Docker: DRIPLY_SITE_ADDRESS is your real domain so HTTPS is issued, and — if you run the local-db profile — POSTGRES_PASSWORD is not the default from the compose file.
  16. 16
    Database backups are scheduled. A named Docker volume is not a backup.
20

Troubleshooting and FAQ

Nearly every first-install problem is one of the five below, and the first two account for most of them. Each one starts with what you actually see, because a symptom is what you have when something goes wrong.

Start here: sign in as an administrator and open Admin → Overview. The Install health panel names what is unconfigured — storage, email, payments, registration, logo — and links to the setting that fixes it. It is faster than reading logs, and it is there precisely because a half-configured install fails quietly rather than loudly.

Uploads fail in the browser, but everything else works

Symptom. Sign-in works, the file browser loads, but dropping a file shows an upload error. The browser console has a message mentioning CORS, Access-Control-Allow-Origin, or a failed PUT to your storage endpoint. Alternatively the parts upload to 100% and then the upload fails right at the end with a message about a missing ETag.

Cause. The browser sends file bytes straight to your bucket, so the bucket — not Driply — has to allow requests from your site. A bucket with no CORS policy rejects them all.

Fix. Apply the CORS policy in Object storage. Two details are where people get caught:

  • ExposeHeaders must include ETag. This is the cause of the “uploads reach 100% then fail” version. The upload succeeds, but the browser is not permitted to read back the part’s identifier, so the file can never be assembled. Driply detects this exact case and says so by name rather than failing silently.
  • The origin must match exactly — scheme included, no trailing slash. https://drive.example.com and https://drive.example.com/ are not the same value, and http:// does not match https://.

After changing a CORS policy, give it a minute and hard-reload. Browsers cache the preflight result, so the old answer can outlive the fix and make a correct policy look broken.

Verification emails never arrive — and new accounts cannot upload

Symptom. You register an account, no email arrives, and the upload area says “Confirm your email address first.” Password resets do not arrive either.

Cause. SMTP is not configured. Driply never fails an install over unset mail settings, so the app starts and runs normally — but nothing can be delivered, and uploading requires a confirmed address.

Warning
Configure SMTP before you take real signups. Without it, every account that registers can browse and download but can never upload, and cannot reset a password. This is the one optional integration that is not really optional in production.

Fix. Set SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD and the from-address, then restart. Email covers the settings and names providers with a free tier. Then check, in order:

  • Port and encryption agree. Port 465 needs SMTP_SECURE=true; port 587 needs SMTP_SECURE=false, because it upgrades the connection after connecting. Mismatching these is the usual cause of a connection that hangs and then times out.
  • The from-address is one the provider has verified. Most providers silently drop mail from an unverified domain or sender.
  • Many VPS hosts block outbound port 25, and some block 587. Use your provider’s submission port, or an API-based relay.
  • Look in spam before concluding nothing was sent. A new sending domain with no SPF or DKIM records lands there routinely — set those up at your DNS host.

If you are locked out right now and need an account working before SMTP is sorted, mark it confirmed directly in the database:

sql
UPDATE users SET email_verified = true WHERE email = 'you@example.com';

That is a deliberate stopgap for your own administrator account, not a substitute for working email — your customers cannot reset their passwords without it.

Thumbnails never appear, and the trash never empties

Symptom. Files upload and download correctly, but previews stay generic, video posters never generate, deleted files sit in the trash forever, and storage figures drift away from reality. Nothing errors.

Cause. The background worker is not running. Driply is two processes: the web app and a worker. The web app is perfectly usable without the worker, which is exactly why this is missed — nothing breaks loudly, it just quietly never happens.

Fix. Start the worker alongside the app:

terminal
cd web
pnpm worker

It polls for work every five seconds. Confirm it in Admin → Overview: the Background jobs panel shows queued, running and given-up counts. A queue that keeps growing while nothing runs means the worker is not up.

It exits immediately
It needs the same DATABASE_URL and storage settings as the web app. Run it from web/ so it reads the same .env.
tsx: not found
Install with production dependencies included. tsx is a runtime dependency here, not a development one — the worker runs TypeScript directly. An install that skipped it will fail here and nowhere else.
It runs, but thumbnails stay generic
Image thumbnails need nothing extra. Video posters need ffmpeg and PDF first pages need poppler-utils. When a binary is missing the file is marked unsupported and the log names the package to install — it does not retry or fail the job. Both are already in the Docker image.
You installed ffmpeg and nothing changed
Restart the worker. It probes for those binaries once at startup and caches the answer for the life of the process.

Under Docker both processes start together and this does not come up. On a bare VPS, run the worker under the same process manager as the app so it restarts with the machine — Deployment covers that.

The mobile app will not connect to my server

Symptom. The app opens but sign-in fails, hangs, or reports a network error, while the same credentials work in a browser.

Cause and fix, in the order worth checking:

It is still pointed at our demo
The app ships pointing at our demo server so it runs out of the box. Until you change the API origin it will sign in against our demo, not your install — so your accounts do not exist and your files never appear. See Mobile app.
You changed the origin but did not rebuild
The origin is compiled in. Stop the app and run it again — a hot reload does not pick up a new compile-time constant.
HTTPS with a real certificate
Android and iOS both block plain HTTP by default, and a self-signed certificate is rejected on a real device even when it works in a simulator. A certificate from Let’s Encrypt is free, and the supplied Caddy configuration obtains one automatically.
Trailing slash and path
The setting is an origin — https://drive.example.com. Not a trailing slash, and not .../api/v1; the app appends the path itself.
Emulator addresses differ
An Android emulator cannot reach your machine at localhost — that is the emulator. Use http://10.0.2.2:3000. The iOS simulator does use http://localhost:3000. Both need a platform exception for plain HTTP, which you should not ship to production.
The server is reachable at all
From the device’s network, open https://your-domain/api/v1/health in a browser. If that does not answer, the problem is DNS, the firewall or the proxy — not the app.

The site loads but has no styling

Symptom. Pages render as unstyled text. The app is running and its health check passes.

Cause. The production build is a standalone bundle, and the static assets live beside it. Starting the emitted server directly — or copying only part of the build output — serves the HTML without the CSS.

Fix. Build and start with the provided scripts rather than calling Next.js directly:

terminal
cd web
pnpm build
pnpm start

pnpm build copies the static assets into the bundle and pnpm start launches it with the correct network binding. Substituting next start for pnpm start produces exactly this symptom — and it still passes its health check, which is what makes it confusing.

Frequently asked

/admin returns 404 and I am signed in
Working as intended. The admin area returns 404 — not 403 — to anyone who is not an administrator, because a 403 confirms the route exists. Your account needs role = 'admin'; the seed script creates one, and an existing administrator can promote another from Admin → Users.
An administrator cannot save anything
ADMIN_READ_ONLY is switched on. It makes every page browsable and refuses every write — it is for public demos. Unset it and restart.
The app will not start and names a setting
Configuration is validated at startup, so a missing or malformed value stops the app immediately with the name of the offending setting rather than failing later in a request. Fix the named value; the message is the whole diagnosis.
Database connection errors under load
Use your provider’s pooled connection string for the app, and the direct one for migrations. Running migrations through a transaction-mode pooler is the usual cause of migrations that hang or half-apply.
Stripe webhooks are not arriving
The endpoint is /api/webhooks/stripe and it verifies signatures, so the signing secret must be the one for that endpoint — each endpoint has its own, and test and live mode differ. A 400 here is a signature mismatch. Stripe’s dashboard shows every attempt and its response.
A customer is over quota — are their files deleted?
Never. Over-quota and lapsed accounts become read-only: uploads are blocked, downloads and deletes keep working, and nothing is removed. Deleting a paying customer’s files on a lapsed payment would be indefensible, so the code does not do it.
Storage figures look wrong
Usage is a running total kept per account rather than a scan of every file, which is what makes uploads fast. A nightly worker job reconciles it against reality — so a figure that drifted is usually a worker that is not running.
Can I use S3 instead of R2?
Yes — any S3-compatible provider. Only the endpoint, region and credentials change. Object storage lists the endpoint format for five providers.
Can I change the colours without knowing CSS?
Yes. It is one line per colour in one file, with a worked before-and-after example in Branding and customization.
Why is there no upgrade button in the mobile app?
Apple and Google both require their own in-app purchase systems for digital goods. The app shows plan and usage; upgrades happen on the web. This is store policy, not an omission.

If none of this matches what you are seeing, write in — Support and licensing. Telling us your Node version, database and storage providers, and the exact error text gets you a useful answer in one round rather than three.

21

Known limitations

Stated plainly so you can decide before you buy rather than discover afterwards.

English only
There is no translation layer on either surface. Interface strings are in the components; translating means editing them.
Stripe only
A payment provider interface exists, but Stripe is the one implementation. Other gateways are development work.
No push notifications
Deliberately excluded: push would require every buyer to create a Firebase project and rebuild the app before it worked at all. The app refreshes when it comes to the foreground instead.
No in-app purchases
The app shows plan status; upgrades happen on the web. This is Apple and Google policy for digital goods, not an omission.
Uploads pause when the app is backgrounded
An interrupted upload resumes when the app comes back to the foreground. It does not continue while suspended by the operating system.
No end-to-end encryption
Files are encrypted at rest by your storage provider. Client-side encryption would break thumbnails, previews and search.
No desktop sync client
Web and mobile only.
Automated tests cover the mobile app
416 Flutter tests ship. The web application has no automated test suite in this release.
CDN variable is reserved
S3_PUBLIC_URL is present but not yet used — every URL is signed against S3_ENDPOINT.
22

Changelog

Every version published so far. Updates are free for the life of the item.

v1.0.0 · Sep 2026
Initial release: web platform, admin panel, REST API, Flutter app for Android and iOS, and the background worker.
23

Support and licensing

Six months of item support are included with your purchase, per the CodeCanyon standard, and can be extended at any time from your Envato downloads page.

What support covers: answering questions about how Driply works, help with the setup in this guide, and fixing defects in the code you were sold. What it does not cover: hosting, server administration, third-party accounts, or customisation work — Envato’s item support policy excludes these. We are glad to quote for customisation separately.

When you write in, telling us your Node version, your database and storage providers, and the exact error text gets you a useful answer in one round rather than three.

Licensing

Driply is sold under Envato’s standard licences. A Regular License covers one end product that your end users are not charged for. If you are charging your users — which is the entire point of a storage service with paid plans — you need an Extended License. Both are on the item page; the licence you bought is recorded in your Envato account.

What support covers

  • Six months of support from purchase, extendable at checkout
  • Support covers bugs in the template and questions about how it is put together
  • It does not cover custom feature work, third-party API changes or store review outcomes
  • The Regular licence covers one free end product. Charging users for the app itself needs the Extended licence