Simple and modern universal file upload/download server.
(For Japanese language/ζ₯ζ¬θͺγ―γγ‘γ)
Please note that this English version of the document was machine-translated and then partially edited, so it may contain inaccuracies. We welcome pull requests to correct any errors in the text.
There are many situations, especially in private environments, where you want to host simple file exchange on your own server. Sharing files with friends, small-office coworkers, or even customers often falls into this category.
Today, cloud storage is the common answer. At the same time, placing confidential files in cloud storage, even temporarily, may feel uncomfortable, or may be prohibited by strict organizational policy.
So how do you handle simple file upload and download without turning it into a large infrastructure task? Do you prepare Apache or Nginx, manually tune it, enable WebDAV, then decide what clients should use and how users will browse the stored files?
This "uplodah" may be what you are looking for. It is a simple server implementation built on Node.js, focused specifically on uploading and downloading files.
Setup is very easy, and zero-config operation is possible in many cases. There is no database to manage. If you need backups, just copy the storage subdirectory as files. Restoring it is equally straightforward and does not require any special tooling.
It also provides a modern browser-based UI:
- Browse uploaded files.
- Search and organize them by file name or virtual directory.
- Check download URLs for the latest version or a specific version.
- Upload multiple files with drag and drop.
- Copy ready-to-use
curlAPI examples from the UI.
- Quick setup, start an upload server in seconds
- No database required: uploaded files and metadata are managed directly on the filesystem
- Simple upload API: just send
application/octet-streamwithPOSTorPUT - Versioned storage: re-uploading the same file name keeps history
- Flexible downloads: retrieve either the latest version or a specific upload ID directly
- Modern Web UI:
- File list, search, and expandable version view
- Sectioned display by virtual directory
- Multiple file upload
- Copyable API command examples
- Download selected files in bulk
- Virtual storage rules:
- Per-directory store/delete control
- Per-directory expiration rules
- Authentication: protect uploads only or the whole server with UI login, user roles, and API passwords
- Supports reverse proxies and subpath hosting
- Docker image available
- Health check endpoint at
/health
Node.js 20.19.0 or later
Used stack: Node.js, TypeScript, Vite, Vitest, prettier-max, screw-up, Fastify, React, React MUI, dayjs, JSON5, async-primitives
If Node.js is not installed on your system yet, install it first.
$ node --version
v24.11.1Once Node.js is available, install uplodah with npm:
$ npm install -g uplodah
added 157 packages in 8s
42 packages are looking for funding
run `npm fund` for detailsYou can also run it directly via npx:
$ npx uplodah
Need to install the following packages:
uplodah@0.1.0
Ok to proceed? (y)
[uplodah]: [2026/04/07 14:25:56.966]: [info]: uplodah [0.1.0] Starting...
[uplodah]: [2026/04/07 14:25:56.967]: [info]: Config file: ./config.json
[uplodah]: [2026/04/07 14:25:56.967]: [info]: Port: 5968
[uplodah]: [2026/04/07 14:25:56.967]: [info]: Base URL: http://localhost:5968 (auto-detected)
[uplodah]: [2026/04/07 14:25:56.967]: [info]: Storage directory: ./storage
:
:Here are a few examples:
# Start the server on the default port 5968
uplodah
# Custom port and storage directory
uplodah --port 3000 --storage-dir ./storage
# Fix the public base URL behind a reverse proxy
uplodah --base-url https://files.example.com/uplodah
# Combine multiple options
uplodah --port 3000 \
--storage-dir ./storage \
--config-file ./config.json \
--max-upload-size-mb 500 \
--max-download-size-mb 500By default, the following URLs are available:
- Web UI:
http://localhost:5968/ - File listing API:
http://localhost:5968/api/files - Upload API:
http://localhost:5968/api/upload/<file-name> - Download API:
http://localhost:5968/api/files/<file-name> - Health check:
http://localhost:5968/health
When --base-url is specified, the UI-generated download URLs and API command examples use that URL as their base.
--config-file points to the configuration file and is useful when you want more detailed customization.
The file is optional. If the default behavior works for you, you do not need it.
The Web UI is user-friendly and includes file browsing per directory, file filtering, upload support, and ready-to-copy curl usage examples:
The UI also shows upload and download examples using curl, which makes CLI integration straightforward:
You can upload files from the UI. It supports drag and drop and makes uploading multiple files at the same time very easy:
You can also upload files through the API.
The following example uploads report.txt into the root directory with curl:
curl -X POST http://localhost:5968/api/upload/report.txt \
-H "Content-Type: application/octet-stream" \
--data-binary @./report.txtYou can do the same with PUT:
curl -X PUT http://localhost:5968/api/upload/report.txt \
-H "Content-Type: application/octet-stream" \
--data-binary @./report.txtWhen the upload succeeds, the server returns 201 Created.
The response body includes the stored uploadId and generated download URLs.
The Location header is also set to the download target.
TODO: Add a JSON response example.
If storage rules are configured, you can upload files into virtual subdirectories.
In the UI, the target directory is selected from the dropdown in the upload panel.
When using the API, include the subdirectory path in the request URL.
The following example stores report.txt under /foobar:
curl -X POST http://localhost:5968/api/upload/foobar/report.txt \
-H "Content-Type: application/octet-stream" \
--data-binary @./report.txtThis API path is treated as the public file name /foobar/report.txt.
Notes:
- If
storageis not configured, only plain file names such asreport.txtare allowed. - If
storageis configured, uploadable directories are limited to paths defined there. - Paths containing special characters should be encoded per URL segment.
To download a file from the UI, open the entry from the file list and click the "Download" button for the desired version.
As described earlier, uplodah can store multiple versions of the same file.
Those versions are distinguished by upload timestamp, and the list is shown from newest to oldest:
When using curl, you also need to specify which version to download.
The precise value is returned in the JSON response from the upload API, but if you know the timestamp-based ID, you can construct the URL directly.
Download the latest version by file name only:
curl -L "http://localhost:5968/api/files/report.txt" -o ./report.txtDownload a specific version in YYYYMMDD_HHmmss_fff format:
curl -L "http://localhost:5968/api/files/report.txt/20260406_203040_123" -o ./report.txtNote that if multiple uploads happen at exactly the same timestamp, the version identifier may gain a suffix such as _1, _2, and so on.
When selecting multiple versions in the Web UI and downloading them as a batch, the server creates the ZIP archive as a background job and stores it in a temporary file until it is downloaded or expires. The ZIP file name uses the {realm}_YYYYMMDD_HHmmss.zip form, where the timestamp is generated by the browser in its local timezone. The temporary server-side file name does not reuse the browser-provided file name.
To fetch the file list with curl:
curl "http://localhost:5968/api/files?skip=0&take=20"The listing API returns groups sorted by most recent upload first. Each group contains all versions for that file name.
By default, uploaded files are stored under ./storage.
You can change this with the --storage-dir option or the storageDir setting:
# Use the default ./storage directory
uplodah
# Use a custom directory
uplodah --storage-dir /srv/uplodah/storageRelative paths provided via CLI options or environment variables are resolved from the current working directory.
storageDir inside config.json is resolved relative to the directory containing that config.json.
As described above, uplodah does not use any special database.
It only places directories and files under the storage directory.
Each stored upload is recognized only when all of the following are true:
- The version directory name can be parsed as an
uploadId metadata.jsonexists and contains valid JSON- A payload file exists in the same directory and its file name matches the parent file-group directory name
The storage tree mirrors the public path directly under the storage root:
storage/
βββ report.txt/
β βββ 20260406_203040_123/
β βββ metadata.json
β βββ report.txt
βββ bropdox/
βββ report.txt/
βββ 20260406_204512_918/
βββ metadata.json
βββ report.txt
When storage rules are enabled, the directory segments from the public path are simply inserted before the file-group directory.
No separate internal tree is used.
uploadId values are generated from timestamps in YYYYMMDD_HHmmss_fff format.
If multiple uploads collide within the same millisecond, a sequence suffix is appended.
You can define rules for virtual directories in config.json.
The storage section is optional.
If it is not defined, uploads are accepted only at the root with plain file names.
Once storage: { ... } is defined, uploads must target a path under a configured virtual directory.
When uploading into deeper subdirectories, the most specific matching virtual directory rule is applied.
You can also configure behavior per virtual directory.
Here is an example storage section in config.json:
{
"port": 5968,
"storage": {
// Enabled virtual directories
"/": {}, // (Root directory)
"/bropdox": {
// "/bropdox"
"description": "Temporary sharing area",
"accept": ["store", "delete"],
"expireSeconds": 86400 // Expire after 24 hours
},
"/archive": {
// "/archive"
"description": "Long-term archive",
"accept": [] // Read only
},
"/archive/incoming": {
"accept": ["store", "delete"]
} // "/archive/incoming"
}
}In this example:
/accepts normal uploads- Uploads anywhere under
/bropdoxexpire automatically after 24 hours /archiveaccepts deletion but not uploads/archive/incomingis more specific than/archive, so uploads are allowed there again under that subtree
Rule behavior:
- Keys must always start with
/ - Backslashes and relative path segments such as
.and..are not allowed descriptionis shown in the UI directory list and upload-directory selectoracceptmay containstoreand/ordelete; when omitted, both remain allowed for backward compatibility- The most specific matching directory rule is applied
- Once
storageis defined, uploads outside configured virtual directory subtrees are rejected To allow uploads at the root directory and its descendants as well, include/explicitly as shown above
Because there is no database, backing up the storage directory is sufficient:
cd /your/server/base/dir
tar -cf - ./storage | bzip2 -9 > backup-storage.tar.bz2To restore, extract the archive and start uplodah again with the same storageDir setting.
If the directory structure is damaged, you can rebuild it manually as long as you preserve the required layout:
- Create a directory in the form
<public-path>/<YYYYMMDD_HHmmss_fff[_num]>/. - Place a valid JSON
metadata.jsoninto that directory. - Place the payload file into the same directory, and name it exactly the same as the parent file-group directory.
If you modify the storage directory directly while uplodah is running, those changes are not reflected immediately.
Restart uplodah afterward.
uplodah supports configuration via command-line options, environment variables, and config.json.
Settings are applied in the following order, from highest priority to lowest:
- Command-line options
- Environment variables
config.json- Default values
You can specify a custom configuration file:
# Using a command-line option
uplodah --config-file /path/to/config.json
# Using an environment variable
export UPLODAH_CONFIG_FILE=/path/to/config.json
uplodahIf not specified, uplodah looks for ./config.json in the current directory.
config.json is parsed as JSON5, so comments and trailing commas are allowed.
{
"port": 5968,
"baseUrl": "https://files.example.com/uplodah",
"storageDir": "./storage",
"usersFile": "./users.json",
"realm": "Awesome uplodah",
"logLevel": "info",
"trustedProxies": ["127.0.0.1", "::1"],
"authMode": "none",
"sessionSecret": "<your-secret-here>",
"passwordMinScore": 2,
"passwordStrengthCheck": true,
"maxUploadSizeMb": 500,
"maxDownloadSizeMb": 500,
"storage": {
"/": {
"accept": ["store"]
},
"/bropdox": {
"accept": ["store", "delete"],
"expireSeconds": 86400
},
"/archive": {
"accept": ["delete"]
}
}
}All fields are optional. Only specify the ones you want to override.
Relative storageDir and usersFile paths are resolved from the directory containing config.json.
uplodah also supports authentication.
| Authentication Mode | Details | Auth Initialization |
|---|---|---|
none |
Default. No authentication required | Not required |
publish |
Authentication required for uploads and admin UI. Listing and downloads remain public | Required |
full |
Authentication required for all operations (must login first) | Required |
To enable authentication on uplodah, first register an initial user using the --auth-init option.
Create an initial admin user interactively:
uplodah --auth-initThis command will:
- Prompt for admin username
- Prompt for password (with strength checking, masked input)
- Create
users.json - Exit after initialization (server does not start)
When enabling authentication with the Docker image, run this option against the same mounted config/data directory so that users.json is created in persistent storage.
Initializing authentication...
Enter admin username: admin
Enter password: ********
Password strength: Good
Confirm password: ********
Creating admin user...
============================================================
Admin user created successfully!
============================================================
Username: admin
Role: admin
============================================================
Note: You need to generate an API password for API access.
You can do this through the web UI after logging in with your username and password.
============================================================
Users added with --auth-init automatically become administrator users.
Administrator users can add or remove other users through the UI, and can reset user passwords.
Available roles are:
read: browse, list, and download filespublish: same asread, plus upload filesadmin: same aspublish, plus user management
Administrator users can also generate API passwords, but it is usually better to separate day-to-day upload accounts from the admin account.
uplodah distinguishes between the password used to log in to the UI and the password used by API clients.
API clients use an "API password" with HTTP Basic authentication.
Log in through the browser UI first, then open the API password screen from the user menu and create one or more labeled API passwords. The plaintext API password is shown only once, so store it securely.
Examples:
# Upload with API password
curl -X POST http://localhost:5968/api/upload/report.txt \
-u publisher:xxxxxxxxxxxxxxxxxxxxxx \
-H "Content-Type: application/octet-stream" \
--data-binary @./report.txt# List files with API password (required in authMode=full)
curl "http://localhost:5968/api/files?skip=0&take=20" \
-u reader:xxxxxxxxxxxxxxxxxxxxxx# Download the latest file version with API password (required in authMode=full)
curl -L "http://localhost:5968/api/files/report.txt" \
-u reader:xxxxxxxxxxxxxxxxxxxxxx \
-o ./report.txtIn publish mode, HTTP Basic authentication is required only for upload APIs.
In full mode, provide Basic authentication for all API routes, while the browser UI uses the session created after login.
uplodah uses the zxcvbn library to enforce strong password requirements:
- Evaluates password strength on a scale of 0-4 (Weak to Very Strong)
- Default minimum score: 2 (Good)
- Checks against common passwords, dictionary words, and patterns
- Provides feedback during password creation
Configure password requirements in config.json:
{
"passwordMinScore": 2, // 0-4, default: 2 (Good)
"passwordStrengthCheck": true // default: true
}uplodah stores both login passwords and API passwords as salted hashes, so plaintext passwords are not saved on disk.
However, if you do not use HTTPS (TLS), the Authorization header contains the plaintext API password, which makes it vulnerable to sniffing.
If the server is exposed beyond a trusted local network, protect communications with HTTPS.
The server is designed to run behind a reverse proxy.
For example, you may want to expose it at https://files.example.com/uplodah while the Node.js server itself runs on another host or port internally.
The server resolves its public URL with the following priority:
- Fixed base URL:
--base-urlorbaseUrl ForwardedheaderX-Forwarded-Proto,X-Forwarded-Host, andX-Forwarded-Port- Normal
Hostheader
For subpath hosting, the path prefix can be resolved in one of these ways:
- Include the path in
baseUrl - Send the
X-Forwarded-Pathheader
The most reliable option is to fix baseUrl explicitly:
uplodah --base-url https://files.example.com/uplodahIn that case, the public URLs look like this:
- Web UI:
https://files.example.com/uplodah/ - File listing API:
https://files.example.com/uplodah/api/files - Download API:
https://files.example.com/uplodah/api/files/report.txt
If you want to explicitly define trusted proxies, configure trustedProxies:
uplodah --trusted-proxies "10.0.0.10,10.0.0.11"You can provide the same values via environment variables:
export UPLODAH_BASE_URL=https://files.example.com/uplodah
export UPLODAH_TRUSTED_PROXIES=10.0.0.10,10.0.0.11
export UPLODAH_CONFIG_FILE=/srv/uplodah/config.json
export UPLODAH_STORAGE_DIR=/srv/uplodah/storage
export UPLODAH_MAX_UPLOAD_SIZE_MB=500Docker images are available for multiple architectures:
linux/amd64(x86_64)linux/arm64(aarch64)
When pulling the image, Docker automatically selects the appropriate architecture for your platform.
Suppose you have configured the following directory structure for persistence (recommended):
docker-instance/
βββ data/
β βββ config.json
βββ storage/
βββ (uploaded files)
Execute as follows:
# Pull and run the latest version
docker run -d -p 5968:5968 \
-v $(pwd)/data:/data \
-v $(pwd)/storage:/storage \
kekyo/uplodah:latest
# Or with Docker Compose
cat > docker-compose.yml << EOF
version: '3'
services:
uplodah:
image: kekyo/uplodah:latest
ports:
- "5968:5968"
volumes:
- ./data:/data
- ./storage:/storage
environment:
- UPLODAH_BASE_URL=http://localhost:5968
EOF
docker-compose up -duplodah is now available at:
- Web UI:
http://localhost:5968/ - File listing API:
http://localhost:5968/api/files - Upload API:
http://localhost:5968/api/upload/<file-name> - Download API:
http://localhost:5968/api/files/<file-name> - Health check:
http://localhost:5968/health
The Docker container runs as the uplodah user (UID 1001) for security reasons.
You need to ensure that the mounted directories have the appropriate permissions for this user to read and write files.
Set proper permissions for mounted directories:
# Create directories if they don't exist
mkdir -p ./data ./storage
# Set ownership to UID 1001 (matches the container's uplodah user)
sudo chown -R 1001:1001 ./data ./storageImportant: Without proper permissions, you may encounter Permission denied errors when:
- Creating directories for uploads
- Writing uploaded files into
/storage - Deleting expired files from
/storage - Reading
/data/config.json
# Run with default settings (port 5968, storage and config taken from mounted volumes)
docker run -p 5968:5968 \
-v $(pwd)/data:/data \
-v $(pwd)/storage:/storage \
kekyo/uplodah:latest
# With a fixed public base URL
docker run -p 5968:5968 \
-v $(pwd)/data:/data \
-v $(pwd)/storage:/storage \
-e UPLODAH_BASE_URL=https://files.example.com/uplodah \
kekyo/uplodah:latestYou can also change settings using environment variables or command-line options, but the easiest way to configure settings is to use config.json.
Since the Docker image has mount points configured, you can mount /data and /storage as shown in the example above and place /data/config.json there to flexibly configure settings.
Below is an example of config.json:
{
"port": 5968,
"baseUrl": "http://localhost:5968",
"realm": "Awesome uplodah",
"logLevel": "info",
"maxUploadSizeMb": 500,
"maxDownloadSizeMb": 500,
"storage": {
"/": {
"accept": ["store"]
},
"/bropdox": {
"accept": ["store", "delete"],
"expireSeconds": 86400
},
"/archive": {
"accept": ["delete"]
}
}
}Note: The default container command already specifies --config-file /data/config.json --storage-dir /storage.
If you need a different storage directory or config file path, override the container command explicitly.
/data: Default location forconfig.jsonand other runtime files you want to place beside it/storage: Default upload storage directory
Default behavior: The Docker image runs with --config-file /data/config.json --storage-dir /storage by default.
Configuration priority (highest to lowest):
- Custom command line arguments (when overriding CMD)
- Environment variables for settings not already fixed by the command line, such as
UPLODAH_BASE_URL config.jsonvalues loaded from/data/config.json- Built-in default values in
uplodah
Various methods exist for automatically starting containers with systemd.
Below is a simple example of configuring a systemd service using Podman.
This is a simple service unit file used before quadlets were introduced to Podman.
By placing this file and having systemd recognize it, you can automatically start uplodah:
/etc/systemd/system/container-uplodah.service:
# container-uplodah.service
[Unit]
Description=Podman container-uplodah.service
Documentation=man:podman-generate-systemd(1)
Wants=network-online.target
After=network-online.target
RequiresMountsFor=%t/containers
[Service]
Environment=PODMAN_SYSTEMD_UNIT=%n
Restart=always
RestartSec=30
TimeoutStopSec=70
ExecStart=/usr/bin/podman run \
--cidfile=%t/%n.ctr-id \
--cgroups=no-conmon \
--rm \
--sdnotify=conmon \
--replace \
-d \
-p 5968:5968 \
--name uplodah \
-v /export/data:/data -v /export/storage:/storage docker.io/kekyo/uplodah:latest
ExecStop=/usr/bin/podman stop \
--ignore -t 10 \
--cidfile=%t/%n.ctr-id
ExecStopPost=/usr/bin/podman rm \
-f \
--ignore -t 10 \
--cidfile=%t/%n.ctr-id
Type=notify
NotifyAccess=all
[Install]
WantedBy=default.targetThe build of the uplodah Docker image uses Podman.
Use the provided multi-platform build script that uses Podman to build for all supported architectures:
# Build for all platforms (local only, no push)
./build-docker-multiplatform.sh
# Build and push to Docker Hub
./build-docker-multiplatform.sh --push
# Build for specific platforms only
./build-docker-multiplatform.sh --platforms linux/amd64,linux/arm64
# Push with custom Docker Hub username
OCI_SERVER_USER=yourusername ./build-docker-multiplatform.sh --push
# Inspect existing manifest
./build-docker-multiplatform.sh --inspectImportant: For cross-platform builds, QEMU emulation must be configured first:
# Option 1: Use QEMU container (recommended)
sudo podman run --rm --privileged docker.io/multiarch/qemu-user-static --reset -p yes
# Option 2: Install system packages
# Ubuntu/Debian:
sudo apt-get update && sudo apt-get install -y qemu-user-static
# Fedora/RHEL:
sudo dnf install -y qemu-user-static
# Verify QEMU is working:
podman run --rm --platform linux/arm64 alpine:latest uname -m
# Should output: aarch64Without QEMU, you can only build for your native architecture.
/health returns a response like this:
{
"status": "ok",
"version": "0.1.0"
}All settings are resolved with the priority CLI > environment variable > config.json > default.
| CLI option | Environment variable | config.json key |
Description | Valid values | Default |
|---|---|---|---|---|---|
-p, --port <port> |
UPLODAH_PORT |
port |
HTTP server listening port | 1-65535 | 5968 |
-b, --base-url <url> |
UPLODAH_BASE_URL |
baseUrl |
Fixed external base URL | valid URL | auto-detected |
-d, --storage-dir <dir> |
UPLODAH_STORAGE_DIR |
storageDir |
Storage root directory | valid path | ./storage |
-c, --config-file <path> |
UPLODAH_CONFIG_FILE |
N/A | Path to the configuration file | valid path | ./config.json |
-u, --users-file <path> |
UPLODAH_USERS_FILE |
usersFile |
Path to the users database file | valid path | ./users.json |
-r, --realm <realm> |
UPLODAH_REALM |
realm |
UI title and server label | string | uplodah [version] |
-l, --log-level <level> |
UPLODAH_LOG_LEVEL |
logLevel |
Log verbosity | debug, info, warn, error, ignore |
info |
--trusted-proxies <ips> |
UPLODAH_TRUSTED_PROXIES |
trustedProxies |
Comma-separated trusted proxy IP list | list of IP addresses | none |
--auth-mode <mode> |
UPLODAH_AUTH_MODE |
authMode |
Authentication mode | none, publish, full |
none |
| N/A | UPLODAH_SESSION_SECRET |
sessionSecret |
Secret used for session cookies | string | auto-generated |
| N/A | UPLODAH_PASSWORD_MIN_SCORE |
passwordMinScore |
Minimum password strength score | 0-4 | 2 |
| N/A | UPLODAH_PASSWORD_STRENGTH_CHECK |
passwordStrengthCheck |
Enable password strength checking | true, false |
true |
--max-upload-size-mb <size> |
UPLODAH_MAX_UPLOAD_SIZE_MB |
maxUploadSizeMb |
Maximum upload size in MB | 1-10000 | 100 |
--max-download-size-mb <size> |
UPLODAH_MAX_DOWNLOAD_SIZE_MB |
maxDownloadSizeMb |
Maximum selected batch download size in MB | 1-10000 | 100 |
| N/A | N/A | storage |
Per-virtual-directory storage policy | object | unset |
| N/A | UPLODAH_AUTH_FAILURE_DELAY_ENABLED |
N/A | Enable progressive delays for failed auth attempts | true, false |
true |
| N/A | UPLODAH_AUTH_FAILURE_MAX_DELAY |
N/A | Maximum delay for failed auth attempts (ms) | number | 10000 |
--auth-init |
N/A | N/A | Initialize authentication with an interactive admin user | flag | N/A |
This server project is a sister project of nuget-server.
Under MIT.