# Introduction (/en/docs)
ImgYoung is a self-hosted image host with multi-channel failover. Every image is stored on several storage channels, so losing one does not break uploads or links.
## What ImgYoung is [#what-imgyoung-is]
ImgYoung is a self-hosted image host. It runs as a single Go binary (or a single Docker image), ships with a web admin console, and is MIT licensed.
Storage is built on [rclone](https://rclone.org/), so images can live on any backend rclone supports: S3 / R2 / MinIO and other object stores, WebDAV, consumer cloud drives, or the local disk.
Each such storage configuration is called a **channel** in ImgYoung.
## The problem it solves [#the-problem-it-solves]
The real risk of running your own image host is not missing features, it is losing the images: a free image host shuts down, an object storage account gets suspended, a cloud drive throttles you, or the server's disk dies, and every image link in your blog and notes breaks at once.
ImgYoung is designed to keep image links working for the long run:
- **Every image is stored more than once.** An upload returns as soon as the primary channel has it; the other channels receive replicas asynchronously. Pending replication survives restarts.
- **Reads fail over automatically.** By default ImgYoung proxies image responses itself; if one channel cannot serve the bytes, it reads from the next channel that has a replica, and the link stays the same.
- **The ledger is backed up too.** The metadata database that records which image lives on which channel is snapshotted on a schedule and written to every healthy channel, so losing the host does not take that mapping with it.
## Core capabilities [#core-capabilities]
| Capability | What it does |
|---|---|
| Multi-channel failover | The healthy channel with the highest priority is the primary channel. Periodic health checks switch away from a failing channel automatically; replicas are filled in asynchronously and replication progress is visible in the console. See [Storage channels](./channels/index.mdx) |
| Channel encryption | Built on rclone crypt: both content and file names are encrypted, so the storage provider only sees ciphertext. With the master key you can decrypt the data yourself using stock rclone. See [Channel encryption](./channels/encryption.mdx) |
| Automatic metadata backups | By default a snapshot is taken every 24 hours and written to every healthy channel, each keeping its own latest 7. The console can back up, download and restore on demand. See [Backup and restore](./operations/backup.mdx) |
| TTL lifecycle and cold-image review | Expiry is resolved as per-upload > per-user default > global default, and access-based renewal is on by default. Images with no public access for 90 days (default) land in the cleanup list, where an admin decides to delete or keep them; nothing is deleted automatically. See [Lifecycle](./guides/lifecycle.mdx) |
| Background deduplication | Byte-identical images are merged into one physical file automatically; near-duplicates (by perceptual hash) go to a review list. Deduplication runs in the background and never slows down uploads. See [Admin console](./guides/admin.mdx) |
| On-demand processing | Append `?w=400` to resize to a preset width; browsers that accept WebP get WebP automatically. Results are cached on local disk. See [Access and links](./guides/access.mdx) |
| Multiple users and quotas | Admins create accounts; each user has their own images, upload tokens and storage quota, and regular users cannot see each other's data. See [Admin console](./guides/admin.mdx) |
| Many ways to upload | Drag-and-drop or paste in the console, the HTTP upload API, fetch from URL, the S3 protocol (PicList's built-in AWS S3 uploader works as is; PicGo works once its S3 plugin is installed), and PicGo / PicList custom uploaders. See [Uploading](./guides/upload.mdx) and [Clients](./guides/clients.mdx) |
There is also hotlink protection (a Referer allowlist), proxy or 302 redirect access modes, Cloudflare CDN cache purging, an audit log, alert webhooks and Prometheus metrics.
## When it fits, and when it does not [#when-it-fits-and-when-it-does-not]
A good fit:
- Personal or small-team use: images for blogs, notes and documentation sites.
- You have several storage options (free object storage tiers, cloud drives, VPS disks) and want them to back each other up.
- You do not fully trust your storage providers and want them to hold only ciphertext.
Not a good fit:
- **Horizontal scaling or an HA cluster.** ImgYoung is a single-instance design (SQLite plus an in-process job queue); several processes cannot share one data set.
- **A public image hosting service.** There is no open sign-up, payment or billing; only admins can create accounts.
- **Private images.** Image links are public: anyone with the link can view the image. There are no signed URLs or per-image access control, and hotlink protection is only a light barrier. See [No private images](./guides/access.mdx#no-private-images).
- **Albums, tags and similar organization.** The console offers a list, search, and filters by user, token and time.
- **Formats beyond the common four.** Only JPEG, PNG, GIF and WebP are accepted (detected from file content, not the extension); SVG, HEIC, AVIF and others are rejected.
## How to read these docs [#how-to-read-these-docs]
If you are new, start with the quick start and get it running in Docker; for a long-running deployment, continue with the installation options and storage channels. Before exposing it to the internet, read [Reverse proxy](./operations/reverse-proxy.mdx) and [Backup and restore](./operations/backup.mdx).
From nothing to the Markdown link of your first image in ten minutes.
Install script, Docker, Docker Compose, release archives and building from source, compared.
Add object storage and cloud drive channels; set priority, encryption and direct links.
Reuse the multi-channel storage engine and failover primitives in your own Go project.
Every HTTP endpoint, generated from the OpenAPI document.
# Quick start (/en/docs/quick-start)
Run ImgYoung with Docker, sign in to the console, upload your first image and get its Markdown link.
You need a machine with Docker installed (Linux, macOS or Windows) whose port 8721 you can reach from a browser. For installs without Docker, see [Install](./install/index.mdx).
### Start the service [#start-the-service]
```bash title="Terminal"
docker run -d --name imgyoung --restart unless-stopped \
-p 8721:8721 \
-v imgyoung-data:/data \
ghcr.io/x-haose/imgyoung:latest
```
- `-v imgyoung-data:/data`: the database, the images in the built-in local channel and the master key `master.key` all live in this volume. Deleting the volume deletes everything.
- `--restart unless-stopped`: restoring the metadata database from a snapshot in the console makes the service exit on purpose; Docker starts it again and the snapshot is swapped in on startup. Without a restart policy the container stays stopped after a restore.
Check that it started:
```bash title="Terminal"
docker logs imgyoung
```
Look for a line with `msg="ImgYoung 已启动"` (log messages are in Chinese). The first start also prints two `level=WARN` lines, `已生成主密钥文件,请立即备份——丢失将导致渠道配置与加密渠道数据永久不可恢复` (a master key file was generated; back it up now) and `已初始化默认管理员密码,请登录后立即在后台修改` (the default admin password was initialized; change it after signing in). Both are expected.
### Sign in and change the password [#sign-in-and-change-the-password]
Open `http://:8721/` in a browser (`http://localhost:8721/` on the same machine) and sign in with the factory account:
| Username | Password |
|---|---|
| `admin` | `imgyoung123` |
The factory password is public. After the first sign-in you land on the "Set the admin password" page. Until the password is changed no other console page opens, and the server rejects every console API call other than signing out, reading your own account and changing the password (with 403). The new password needs at least 8 characters; click "Save and sign in again", then sign in with the new password.
### Upload your first image [#upload-your-first-image]
Pick one: upload in the console, or issue an upload token and upload with curl.
1. Open "Images" in the sidebar and click "Upload".
2. Drag images into the dialog, or press Ctrl / ⌘ + V to paste a screenshot from the clipboard. JPEG, PNG, GIF and WebP are accepted, up to 50 MB per image by default.
3. When the upload finishes, close the dialog, click the image card to open "Image details", and under "Share link" pick **Markdown** next to "Copy as".
1. Open "Upload tokens" in the sidebar, click "Issue token", enter `curl` as the purpose, leave the expiry at "Never", and click "Issue".
2. Copy the token shown in the dialog; it starts with `imgy_`. It is shown only this once, and the dialog only closes after you tick "I have stored this secret safely".
3. Upload:
```bash title="Terminal"
curl -H "Authorization: Bearer imgy_your_token" \
-F "file=@screenshot.png" \
http://localhost:8721/api/upload
```
```json title="Response"
{"id":"R0RjUyP1UW","url":"http://localhost:8721/i/R0RjUyP1UW"}
```
`url` is the image address. With jq installed you can get the Markdown directly:
```bash title="Terminal"
curl -s -H "Authorization: Bearer imgy_your_token" -F "file=@screenshot.png" \
http://localhost:8721/api/upload | jq -r '")"'
```
The Markdown looks like this:
```md

```
Paste it into your blog or notes and the image shows up.
Until the public site address (`base_url`) is configured, links are built from the address of the current request: if you opened the console via `localhost`, the copied link points to `localhost` and will not work on other machines.
To share links, open the console through its public domain or IP, or set `base_url` under "Settings → Runtime settings". See [Configuration](./configuration.mdx).
## Next steps [#next-steps]
A fresh instance has only the built-in `local` channel, which stores images under `blobs/` in the data volume. Metadata snapshots are written to every healthy channel, so for now this disk holds the only copy. If it fails, the images and the snapshots are lost together, and the "Dashboard" warns "Your data only lives on this host".
Open "Storage channels" in the sidebar soon and click "New channel" to add an off-site object storage or cloud drive channel. Existing images are replicated to it automatically, and a snapshot is written there on the next scheduled backup or the next start; for a copy right away, click "Back up now" under "Settings → Backups & export". Once the new channel is healthy, the dashboard warning goes away.
- [Storage channels](./channels/index.mdx): add an off-site channel, set priority and encryption.
- [Backup and restore](./operations/backup.mdx): back up the master key `master.key` separately. It is not part of the snapshots; without it the service refuses to start, and the channel credentials and encrypted data cannot be recovered.
- [Reverse proxy](./operations/reverse-proxy.mdx): serve it over HTTPS on your own domain.
- [Clients](./guides/clients.mdx): set up PicGo, PicList, Typora and other clients.
- [Docker image](./install/docker.mdx): image tags, the data volume and environment variables in full.
# Installation (/en/docs/install)
Five ways to install, compared - the install script, the Docker image, Docker Compose, release archives and building from source.
ImgYoung is a single pure-Go binary with the admin console's front end compiled in; it needs no database server, cache or other process at runtime. All five methods give you the same program. They differ only in how you get it and what supervises it.
## Comparison [#comparison]
| Method | Who it suits | Needs root | How to upgrade | Where data lives |
|---|---|---|---|---|
| [Install script](./script.mdx) | Running directly on a Linux / macOS host; on Linux, handing it to systemd | To install into `/usr/local/bin` or with `--service`; not for `~/.local/bin` | Run the same command again | With `--service`: `/var/lib/imgyoung`; otherwise `data/` under the directory it is started from |
| [Docker image](./docker.mdx) | Hosts that already run Docker | Depends on how Docker permissions are set up on the host | Pull the new image and recreate the container | The volume mounted at `/data` in the container |
| [Docker Compose](./compose.mdx) | Keeping the deployment in a file, or running it alongside a reverse proxy | Same as above | `docker compose pull`, then `docker compose up -d` | The `imgyoung-data` volume (Compose prefixes it with the project name) |
| [Release archive](./binary.mdx) | Windows; environments where running the script is inconvenient; full manual control | No | Download the new archive, replace the binary, restart | `data/` under the directory it is started from, or `IMGYOUNG_DATA_DIR` |
| [Build from source](./source.mdx) | Developers who change the code or build it themselves | No | Pull the new code and rebuild | Same as the release archive |
Not sure? If you have Docker, use [Docker Compose](./compose.mdx). On a Linux host without Docker, use the [install script](./script.mdx) with `--service`.
## What all methods share [#what-all-methods-share]
- **Listening address**: `:8721` by default (port 8721 on every interface); change it with the `IMGYOUNG_LISTEN` environment variable.
- **First sign-in**: open `http://:8721/` and sign in with `admin` / `imgyoung123`; the console asks you to change the password right away.
- **Data directory**: holds the metadata database `imgyoung.db`, the master key `master.key`, the built-in local channel's `blobs/` and the variant cache `variants/`. Back it up, and back up `master.key` separately in particular.
- **Configuration**: a handful of bootstrap settings come from `IMGYOUNG_*` environment variables; all runtime settings are edited in the console and apply on save. There is no config file. See [Configuration](../configuration.mdx).
- **Process supervisor**: "Restore" under "Settings → Backups & export" in the console makes the service exit cleanly so the snapshot can be swapped in when the supervisor starts it again. For long-running use, always run it under something that restarts the process after it exits, such as systemd or Docker (`--restart unless-stopped` for Docker, `Restart=always` for systemd). The samples under `deploy/` in the repository are already set up that way.
`go install` compiles straight from the repository source, but the admin console's front end is a build artifact that is not committed, so the resulting binary has the API but no console. To install on a machine, use the install script or a release archive; no Go toolchain is needed.
What Go developers usually want to reuse is the storage engine, which is a public package you can `go get`. See [Go library](../library/index.mdx).
# Install script (/en/docs/install/script)
Download a release on Linux or macOS, verify its sha256 and put it on the PATH; on Linux it can also register a systemd service. Running it again upgrades.
## Usage [#usage]
```bash title="Terminal"
curl -fsSL https://raw.githubusercontent.com/x-haose/imgyoung/main/install.sh | sh
```
Pass options to the script through `sh -s --`:
```bash title="Terminal"
# Install as a systemd service and start it (Linux only, needs root)
curl -fsSL https://raw.githubusercontent.com/x-haose/imgyoung/main/install.sh | sudo sh -s -- --service
# Install a specific version
curl -fsSL https://raw.githubusercontent.com/x-haose/imgyoung/main/install.sh | sh -s -- --version 1.0.0
```
To read the script before running it:
```bash title="Terminal"
curl -fsSLo install.sh https://raw.githubusercontent.com/x-haose/imgyoung/main/install.sh
less install.sh
sudo sh install.sh --service
```
The script needs `curl`, `tar`, `install`, and either `sha256sum` or `shasum`; `--service` also needs `systemctl`.
## Options and environment variables [#options-and-environment-variables]
| Option | Default | Description |
|---|---|---|
| `--version X.Y.Z` | Latest stable release | Install a specific version; the `v` prefix is optional |
| `--dir DIR` | `/usr/local/bin` for root, `~/.local/bin` for other users | Where to install the binary; created if missing, must be writable. With `--service` it must be an absolute path outside `/home`, `/root` and `/run/user` |
| `--service` | Not installed | Linux only, needs root: also install and start the systemd service `imgyoung`, with data in `/var/lib/imgyoung` |
| Environment variable | Default | Description |
|---|---|---|
| `IMGYOUNG_RELEASES` | `https://github.com/x-haose/imgyoung/releases` | The releases address. Set it to use a mirror or internal distribution; the layout must match GitHub Releases (see "Mirrors and internal distribution" below) |
An unknown option or a malformed version makes the script exit with an error right away, before it changes anything.
## What the script does [#what-the-script-does]
1. **Checks prerequisites.** Confirms `curl`, `tar` and `install` exist; with `--service` it also checks for Linux, root, `systemctl`, and that any `--dir` given is an absolute path outside the home directories. If anything is missing it exits before downloading and leaves the system untouched.
2. **Detects the platform.** `uname -s` must be Linux or Darwin, `uname -m` must be x86_64 / amd64 or aarch64 / arm64; anything else is an error (on Windows, use a [release archive](./binary.mdx)).
3. **Picks the version.** Without `--version` it requests `/latest`, follows the redirect and takes the last path segment of the final URL as the tag. GitHub's latest release never points at a pre-release.
4. **Downloads and verifies.** Downloads `imgyoung___.tar.gz` and that release's `checksums.txt` into a temporary directory and checks the sha256. On a mismatch it aborts without installing anything. The temporary directory is removed when the script exits.
5. **Installs the binary.** Installs `imgyoung` into the target directory with mode 0755 and prints its version.
6. **Installs the service** (`--service` only). Writes the archive's `deploy/imgyoung.service` to `/etc/systemd/system/imgyoung.service` with `ExecStart` pointing at the actual install path, saying so if the content differs from the existing file, then runs `systemctl daemon-reload` and enables it. If the service is already running it is restarted onto the new version; otherwise it is started.
7. **Prints next steps.** Warns if the target directory is not on `PATH`, and prints the sign-in address and factory account.
Without `--service` the script only installs the binary and does not start it. Run `imgyoung` yourself; data goes to `data/` under the current directory (or `IMGYOUNG_DATA_DIR`). For long-running use, put it under a process supervisor; see [Installation](./index.mdx) for why.
## The systemd service [#the-systemd-service]
`--service` installs this sample from the repository, changing only `ExecStart`:
```ini title="deploy/imgyoung.service"
# ImgYoung 的 systemd 服务示例;install.sh --service 安装的就是它。
# 手动安装:二进制放到 /usr/local/bin/imgyoung,本文件复制到 /etc/systemd/system/imgyoung.service,
# 然后 systemctl daemon-reload && systemctl enable --now imgyoung。
# 数据在 /var/lib/imgyoung(数据库、本地存储渠道、master.key),备份它。
[Unit]
Description=ImgYoung 自部署图床
Documentation=https://github.com/x-haose/imgyoung
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/imgyoung
Environment=IMGYOUNG_DATA_DIR=%S/imgyoung
# 渠道参数直接交给 rclone,不用 rclone 的配置文件;置空让 rclone 启动时不去寻找家目录下的配置
Environment=RCLONE_CONFIG=
# 只经本机反向代理对外时,改成只监听回环:
# Environment=IMGYOUNG_LISTEN=127.0.0.1:8721
# 以临时系统用户运行,数据目录由 systemd 创建并归属该用户
DynamicUser=yes
StateDirectory=imgyoung
# 后台「从快照恢复」会让服务优雅退出,靠这里重新拉起后换入快照;正常退出也要拉起
Restart=always
RestartSec=3s
# 优雅停机先停收新请求、等在途请求至多 6 秒,再停后台任务至多 2 秒;多出的是关库与退出的余量
TimeoutStopSec=15s
# 数据目录里有 master.key,新建文件只给运行用户自己读写
UMask=0077
# 进程只能写自己的数据目录;本地存储渠道指向别处时,用 ReadWritePaths= 放开那个目录
NoNewPrivileges=yes
CapabilityBoundingSet=
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectKernelLogs=yes
ProtectClock=yes
ProtectHostname=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.target
```
The settings that matter:
- **Data directory.** `IMGYOUNG_DATA_DIR=%S/imgyoung` resolves to `/var/lib/imgyoung`, which `StateDirectory=imgyoung` makes systemd create and hand to the service user. With `DynamicUser` the real directory is `/var/lib/private/imgyoung` and `/var/lib/imgyoung` is a symlink to it. It holds `imgyoung.db`, `master.key`, the local channel's `blobs/` and the variant cache `variants/`.
- **DynamicUser=yes.** systemd allocates a transient system user on every start, so no user has to be created in advance. Together with sandboxing such as `ProtectSystem=strict` and `ProtectHome=yes`, the process can write only its own data directory: if you point the local channel somewhere else, open that directory with `ReadWritePaths=`; and the binary must not live under `/home` or `/root`, or the service cannot start; the script rejects such a `--dir`.
- **Restart=always.** Restoring a snapshot in the console makes the service exit cleanly; systemd starts it again 3 seconds later and the snapshot is swapped in before the database is opened. `Restart=on-failure` does not restart a clean exit, so the restore would only complete once someone starts the service by hand.
- **TimeoutStopSec=15s.** On stop, the service first stops accepting new requests and waits up to 6 seconds for in-flight ones (forcibly closing what remains), then cancels background loops and in-flight replication jobs and waits up to 2 seconds, then closes the database and exits. That is about 8 seconds in total; 15 leaves headroom. Cancelled replication jobs are re-queued by reconciliation on the next start.
- **UMask=0077.** New files, `master.key` included, are readable and writable only by the service user.
- **RCLONE_CONFIG=** (empty). ImgYoung hands channel parameters to rclone directly and never reads an rclone config file.
Everyday commands:
```bash title="Terminal"
systemctl status imgyoung # service status
journalctl -u imgyoung -f # follow the logs
sudo systemctl restart imgyoung # apply changed environment variables
```
### Changing the service configuration [#changing-the-service-configuration]
Re-running the script with `--service` overwrites `/etc/systemd/system/imgyoung.service` with the version from the release archive, and says so when the content changes. Keep local changes out of that file and put them in a drop-in instead:
```bash title="Terminal"
sudo systemctl edit imgyoung
```
In the editor that opens, add the settings to override. For example, to listen on loopback only when a local reverse proxy is the sole entry point:
```ini title="/etc/systemd/system/imgyoung.service.d/override.conf"
[Service]
Environment=IMGYOUNG_LISTEN=127.0.0.1:8721
```
Save, then run `sudo systemctl restart imgyoung`. The available environment variables are listed in [Configuration](../configuration.mdx).
## Upgrading [#upgrading]
Run the same command you installed with:
- Installed with `--service`: the binary is replaced, the unit file is rewritten and the service is restarted onto the new version. You must pass `--service` again; otherwise only the file on disk changes and the running process stays on the old version.
- Installed without `--service`: only the binary is replaced; restart the process yourself.
Use `--version` to stay on a particular version. The database schema is migrated automatically when the new version starts.
`--version` can also install an older version, but that is not a downgrade: an older version refuses to start on a database migrated by a newer one, and the error names the latest migration in the database and in the binary. Rolling back takes the older version plus a snapshot taken before the upgrade; see [Upgrading](../operations/upgrade.mdx) for the steps and what to prepare beforehand.
## Uninstalling [#uninstalling]
With the service installed:
```bash title="Terminal"
sudo systemctl disable --now imgyoung
sudo rm /etc/systemd/system/imgyoung.service
sudo rm -rf /etc/systemd/system/imgyoung.service.d # only exists if you used systemctl edit
sudo systemctl daemon-reload
sudo rm /usr/local/bin/imgyoung
```
The data directory is left in place. Delete it only once you are sure you no longer need the images, the database and `master.key` in it:
```bash title="Terminal"
sudo rm -rf /var/lib/private/imgyoung /var/lib/imgyoung
```
If only the binary was installed, delete it (`~/.local/bin/imgyoung` or `/usr/local/bin/imgyoung` by default), then delete whichever data directory you ran it with, if you no longer need it.
## Mirrors and internal distribution [#mirrors-and-internal-distribution]
If GitHub is hard to reach, host the release files on a mirror or an internal server and point `IMGYOUNG_RELEASES` at it. The variable has to reach the `sh` that runs the script, not `curl`:
```bash title="Terminal"
curl -fsSL https://mirror.example.com/imgyoung/install.sh \
| sudo IMGYOUNG_RELEASES=https://mirror.example.com/imgyoung/releases sh -s -- --service
```
`install.sh` itself must be reachable there too. The mirror must use the same layout as GitHub Releases:
```text
/latest redirects to .../tag/vX.Y.Z
/download/vX.Y.Z/imgyoung_X.Y.Z_linux_amd64.tar.gz
/download/vX.Y.Z/checksums.txt
```
`/latest` is only used without `--version`: the script follows the redirect and treats the last segment of the final URL as the version, which must start with `v` followed by a digit. If the mirror cannot redirect like that, always pass `--version` when installing and upgrading.
# Docker image (/en/docs/install/docker)
Run ImgYoung from the multi-arch image on GHCR - the run command, image tags, the data volume, environment variables and settings behind a reverse proxy.
## Running it [#running-it]
```bash title="Terminal"
docker run -d --name imgyoung --restart unless-stopped \
-p 8721:8721 \
-v imgyoung-data:/data \
ghcr.io/x-haose/imgyoung:latest
```
Open `http://:8721/` and sign in with `admin` / `imgyoung123`; the first sign-in asks you to change the password right away.
| Flag | Purpose |
|---|---|
| `-d --name imgyoung` | Run in the background as a container named `imgyoung`, which later commands refer to |
| `--restart unless-stopped` | Start the process again after it exits; see "Restart policy" below |
| `-p 8721:8721` | Publish the service port. If a reverse proxy on the same host is the only entry point, use `-p 127.0.0.1:8721:8721` so 8721 is not exposed directly |
| `-v imgyoung-data:/data` | A named volume holding all state |
The image is published for `linux/amd64` and `linux/arm64`; Docker picks the right one for the host. To see the version inside an image:
```bash title="Terminal"
docker run --rm ghcr.io/x-haose/imgyoung:latest -version
```
## Image tags [#image-tags]
The image is `ghcr.io/x-haose/imgyoung`. Tags have no `v` prefix:
| Tag | Points to | Good for |
|---|---|---|
| `1.2.3` | One specific version; never changes after release | Production, where you decide exactly when to upgrade |
| `1.2` | The latest stable release in the 1.2 series; moves when a patch release ships | Getting fixes automatically without crossing a minor version |
| `latest` | The latest stable release | Trying it out and personal instances |
Pre-releases get only their exact version tag and never move the major.minor tags or `latest`, so instances pinned to those never pull a pre-release.
## The data volume [#the-data-volume]
The data directory inside the container is `/data` (the image sets `IMGYOUNG_DATA_DIR=/data`). It contains:
| Path | Contents |
|---|---|
| `imgyoung.db` | The metadata database (SQLite in WAL mode, so `imgyoung.db-wal` and `imgyoung.db-shm` sit next to it while running): users, image records, channel configuration (credentials encrypted with the master key), runtime settings and the job queue |
| `master.key` | The master key, generated with mode 0600 when a brand-new instance first starts without `IMGYOUNG_MASTER_KEY`. When `IMGYOUNG_MASTER_KEY` is set, this file is neither created nor read |
| `blobs/` | Objects of the built-in local channel. While the local channel is healthy, metadata snapshots are also written here, under `_imgyoung/backup/` |
| `variants/` | The cache of variants produced by on-demand processing, capped at 10240 MB by default and evicted least-recently-used first |
During a restore from snapshot you may also see the staged file `imgyoung.db.restore`, and afterwards the previous database kept as `imgyoung.db.pre-restore-`.
Channel credentials and the data on encrypted channels can only be decrypted with the master key. It is not included in metadata snapshots or data exports, and once it is lost that data cannot be recovered. The database records a fingerprint of the master key, so if the key is missing or wrong the service refuses to start and says why, instead of carrying on with a new key. Copy it somewhere safe right after the first start:
```bash
docker cp imgyoung:/data/master.key ./imgyoung-master.key
```
### Using a host directory instead [#using-a-host-directory-instead]
To keep the data in a directory on the host, first give it to the user the image runs as (uid 65532):
```bash title="Terminal"
sudo mkdir -p /srv/imgyoung
sudo chown 65532:65532 /srv/imgyoung
docker run -d --name imgyoung --restart unless-stopped \
-p 8721:8721 \
-v /srv/imgyoung:/data \
ghcr.io/x-haose/imgyoung:latest
```
A named volume inherits the ownership of `/data` from the image the first time it is mounted; a host directory does not. With the wrong owner the process cannot create the database and fails at startup.
## Environment variables [#environment-variables]
These are all bootstrap settings. Pass them with `-e`; changes take effect only after the container is recreated. See [Configuration](../configuration.mdx) for the full reference.
A variable that is not set takes its default. Setting it to an empty string (or only whitespace) explicitly empties it instead of falling back to the default; the "When empty" column says what that means:
| Variable | Default | When empty | Description |
|---|---|---|---|
| `IMGYOUNG_DATA_DIR` | `/data` (set by the image) | Startup fails, naming the variable | Leave it alone |
| `IMGYOUNG_LISTEN` | `:8721` | Startup fails, naming the variable | Listening address inside the container; rarely needs changing |
| `IMGYOUNG_MASTER_KEY` | Uses `/data/master.key` | Same as not set | A 64-character hex master key, e.g. from `openssl rand -hex 32`. When switching an existing instance to it, use the exact value from its current `master.key`; a wrong value makes startup fail |
| `IMGYOUNG_TRUSTED_PROXIES` | `127.0.0.0/8,::1/128` | Forwarding headers are never trusted | Reverse proxy networks whose forwarding headers are trusted; see below |
| `IMGYOUNG_METRICS_ALLOW` | Loopback and private ranges | `/metrics` is open to no one | Networks allowed to scrape `/metrics` |
With a reference such as `IMGYOUNG_TRUSTED_PROXIES: ${TRUSTED_PROXIES}` in `compose.yaml`, if the host does not set that variable, Compose only prints a warning and substitutes an empty string, which is the "When empty" case above: `IMGYOUNG_LISTEN` and `IMGYOUNG_DATA_DIR` make startup fail, and `IMGYOUNG_TRUSTED_PROXIES` ends up trusting no forwarding headers. An empty `IMGYOUNG_MASTER_KEY` falls back to reading `/data/master.key`; an instance whose master key comes only from that variable, with no `master.key` in the data directory, refuses to start and reports that the master key is missing, without generating a new one.
When the host must provide a variable, write `${TRUSTED_PROXIES:?TRUSTED_PROXIES must be set}`, which refuses to start when it is missing or empty; if you want the default, leave the entry out.
## Running as nonroot [#running-as-nonroot]
The image is based on `gcr.io/distroless/static-debian12:nonroot` and runs as the nonroot user (uid 65532). It contains no shell and no package manager, so `docker exec -it imgyoung sh` does not work. When you need to work on the data directly (for example editing the database to [reset a forgotten password](../operations/password-reset.mdx)), stop the container and mount the same volume into another container that has the tools.
## Behind a reverse proxy [#behind-a-reverse-proxy]
ImgYoung trusts `X-Forwarded-For`, `X-Forwarded-Proto` and `X-Forwarded-Host` only when the connecting peer is inside `IMGYOUNG_TRUSTED_PROXIES`. The default trusts loopback only, but inside a container the peer is almost never loopback:
- The proxy runs in another container on the same Docker network: the peer is the proxy container's address.
- The proxy runs on the host and reaches the container through a port published with `-p`: the peer is usually the Docker bridge gateway (such as `172.17.0.1`), not `127.0.0.1`.
In both cases, mark the proxy's network as trusted:
```bash title="Terminal"
docker run -d --name imgyoung --restart unless-stopped \
-p 127.0.0.1:8721:8721 \
-v imgyoung-data:/data \
-e IMGYOUNG_TRUSTED_PROXIES=172.16.0.0/12 \
ghcr.io/x-haose/imgyoung:latest
```
`172.16.0.0/12` covers most networks Docker allocates by default; check the actual subnet with `docker network inspect `. If you leave it unset:
- Login rate limiting is keyed on the proxy's address, so every visitor shares one allowance (a burst of 5, then 1 more every 12 seconds): when someone keeps mistyping a password, everyone else is temporarily locked out too.
- Without `base_url`, links returned by uploads use `http://` even though the site is served over HTTPS, and the session cookie is not marked `Secure`.
- The `/metrics` allowlist also sees the proxy's (private) address, so anyone going through the proxy can scrape metrics unless the proxy blocks `/metrics`. The Caddy and Nginx samples under `deploy/` in the repository already block it.
For the proxy itself, see [Reverse proxy](../operations/reverse-proxy.mdx); for running it together with Caddy, see [Docker Compose](./compose.mdx).
## Restart policy [#restart-policy]
"Restore" under "Settings → Backups & export" in the console first stages the snapshot in the data directory, then makes the service exit cleanly (exit code 0); the snapshot is swapped in before the database is opened on the next start. Therefore:
- Use `unless-stopped` or `always`. `on-failure` does not restart a container that exited cleanly, and the default `no` never restarts it.
- Without a suitable policy, the container stays exited after a restore, and the snapshot is swapped in only when you run `docker start imgyoung` yourself.
The restart policy also brings ImgYoung back after the host or the Docker daemon restarts.
## Upgrading [#upgrading]
Pull the new image and recreate the container with the same flags. All data lives in the volume and is untouched; the database schema is migrated automatically when the new version starts:
```bash title="Terminal"
docker pull ghcr.io/x-haose/imgyoung:latest
docker stop imgyoung && docker rm imgyoung
docker run -d --name imgyoung --restart unless-stopped \
-p 8721:8721 \
-v imgyoung-data:/data \
ghcr.io/x-haose/imgyoung:latest
```
`docker stop` waits 10 seconds by default before killing the container, which is enough for ImgYoung: on the stop signal it stops accepting new requests and waits up to 6 seconds for in-flight ones, stops background work within 2 more seconds, then closes the database and exits.
Downgrading is not supported: if you switch back to an older tag, the older version refuses to start on a database migrated by a newer one. Rolling back takes the older image plus a snapshot taken before the upgrade.
Before upgrading, consider clicking "Back up now" under "Settings → Backups & export". See [Upgrading](../operations/upgrade.mdx) for the rollback steps and more.
# Docker Compose (/en/docs/install/compose)
Run ImgYoung with the compose.yaml from the repository, and pair it with Caddy for automatic HTTPS.
## The sample file [#the-sample-file]
`deploy/compose.yaml` in the repository (release archives include a copy too):
```yaml title="deploy/compose.yaml"
# ImgYoung 的 Docker Compose 示例。
# 用法:把本文件放进一个空目录,执行 docker compose up -d,浏览器打开 http://<主机>:8721/,
# 用 admin / imgyoung123 登录(首次登录强制改密)。
services:
imgyoung:
image: ghcr.io/x-haose/imgyoung:latest # 生产环境建议钉大小版本(如 1.0)或具体版本
# 后台「从快照恢复」会让服务优雅退出,靠重启策略重新拉起后换入快照;正常退出也要拉起
restart: unless-stopped
# 优雅停机先停收新请求、等在途请求至多 6 秒,再停后台任务至多 2 秒;多出的是关库与退出的余量
stop_grace_period: 15s
ports:
- "8721:8721"
volumes:
# 数据库、本地存储渠道与自动生成的 master.key 都在这里;丢了 master.key,渠道凭证与加密数据不可恢复
- imgyoung-data:/data
# environment:
# # 前面有反向代理时,不论代理在宿主机还是另一个容器,ImgYoung 看到的对端都是 Docker 网络里的地址,
# # 填该网络的网段(docker network inspect 查看);否则登录限速与 /metrics 放行会把所有请求都当成来自代理本身
# IMGYOUNG_TRUSTED_PROXIES: 172.16.0.0/12
volumes:
imgyoung-data:
```
Put it in an empty directory and start it:
```bash title="Terminal"
mkdir imgyoung && cd imgyoung
curl -fsSLO https://raw.githubusercontent.com/x-haose/imgyoung/main/deploy/compose.yaml
docker compose up -d
```
Open `http://:8721/` and sign in with `admin` / `imgyoung123`; the first sign-in asks you to change the password right away.
Image tags, the contents of the data volume, environment variables and the restart policy are explained on [Docker image](./docker.mdx) and not repeated here. Before referencing host variables with `${VAR}` under `environment:`, read the warning in the "Environment variables" section there: a variable the host does not set is substituted as an empty string.
`stop_grace_period: 15s` in the sample caps how long Compose waits for the process to exit on stop. On the stop signal ImgYoung stops accepting new requests and waits up to 6 seconds for in-flight ones, stops background work within 2 more seconds, then closes the database and exits; 15 seconds leaves headroom over those roughly 8.
## Day-to-day operations [#day-to-day-operations]
Run these in the directory that holds `compose.yaml`:
| Task | Command |
|---|---|
| Start, or apply changes to `compose.yaml` | `docker compose up -d` |
| Show status | `docker compose ps` |
| Follow the logs | `docker compose logs -f imgyoung` |
| Stop and remove the container (the volume is kept) | `docker compose down` |
| Upgrade | `docker compose pull && docker compose up -d` |
`docker compose down -v` also deletes the data volume, and with it the database, the images and `master.key`.
The volume's real name carries the project name as a prefix, and the project name defaults to the directory name: inside an `imgyoung` directory it is `imgyoung_imgyoung-data`. Check with `docker volume ls`.
To pin a version, change `image:` to a major.minor tag such as `ghcr.io/x-haose/imgyoung:1.0`, or to an exact version, then run `docker compose up -d`. Switching back to an older tag is not a downgrade: the older version refuses to start on a database migrated by a newer one. Before upgrading, consider clicking "Back up now" under "Settings → Backups & export"; see [Upgrading](../operations/upgrade.mdx) for the rollback steps and more.
## Serving HTTPS with Caddy [#serving-https-with-caddy]
Caddy obtains and renews certificates automatically. Put the two files below in one directory, replace the domain with yours, point its DNS at this host, and open ports 80 and 443:
```yaml title="compose.yaml"
services:
imgyoung:
image: ghcr.io/x-haose/imgyoung:latest
restart: unless-stopped
stop_grace_period: 15s
# No published port: Caddy is the only entry point
volumes:
- imgyoung-data:/data
environment:
# Caddy runs in another container on the same Docker network; use that network's range
IMGYOUNG_TRUSTED_PROXIES: 172.16.0.0/12
caddy:
image: caddy:2
restart: unless-stopped
depends_on:
- imgyoung
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
volumes:
imgyoung-data:
caddy-data:
caddy-config:
```
```text title="Caddyfile"
img.example.com {
# Metrics are for internal scraping only; never forward them from the public entry point
respond /metrics 404
reverse_proxy imgyoung:8721
}
```
The only configuration difference from `deploy/Caddyfile` in the repository is the upstream: Caddy runs in a container, so it reaches ImgYoung by service name, `imgyoung:8721`, instead of `127.0.0.1:8721`.
Notes:
- **Why `IMGYOUNG_TRUSTED_PROXIES` is needed.** By default ImgYoung trusts forwarding headers only from loopback, but Caddy connects from a private address on the Docker network. Without it, login rate limiting treats every visitor as the same client, returned image links use `http://` instead of `https://`, and the session cookie is not marked `Secure`.
- **Check the subnet.** The network Compose creates for the project usually falls inside `172.16.0.0/12`, but once Docker's default pools there are used up it allocates from `192.168.0.0/16` as well. Run `docker network inspect imgyoung_default` (when the project is named after the directory) and look at `Subnet`; if it is outside `172.16.0.0/12`, use the actual subnet.
- **Certificates and Caddy's data.** Certificates live in the `caddy-data` volume; deleting it makes Caddy request new ones.
- **Upload size.** Caddy does not limit request body size by default, so the only limit is "Max image size" in the console (50 MB by default).
- **Link addresses.** With the proxy trusted and no `base_url` configured, links are built from the host and scheme Caddy forwards, i.e. `https://img.example.com/i/...`. You only need `base_url` when the public domain differs from the one Caddy receives (for example with a CDN in front); see [Configuration](../configuration.mdx).
- **Metrics.** The public entry point blocks `/metrics`; a Prometheus on the same network can scrape `imgyoung:8721/metrics` directly, since private addresses are allowed by default. See [Monitoring](../operations/monitoring.mdx).
For Nginx and other proxies, see [Reverse proxy](../operations/reverse-proxy.mdx).
# Release archives (/en/docs/install/binary)
Download the archive for your platform, verify its sha256 and run it directly; suited to Windows and to environments where the install script is inconvenient.
## Picking an archive [#picking-an-archive]
Download the archive for your platform from the [releases page](https://github.com/x-haose/imgyoung/releases). The version in file names has no `v` (e.g. `1.0.0`); the tag in download paths does (e.g. `v1.0.0`):
| OS | amd64 | arm64 |
|---|---|---|
| Linux | `imgyoung__linux_amd64.tar.gz` | `imgyoung__linux_arm64.tar.gz` |
| macOS | `imgyoung__darwin_amd64.tar.gz` (Intel) | `imgyoung__darwin_arm64.tar.gz` (Apple silicon) |
| Windows | `imgyoung__windows_amd64.zip` | `imgyoung__windows_arm64.zip` |
`checksums.txt` next to them lists the sha256 of every archive. Each archive contains:
`deploy/` holds sample configurations for Docker Compose, reverse proxies and systemd; `web/licenses/` holds the licenses of third-party code used by the admin console. The binary depends on none of the other files and can be copied on its own.
## Download and verify [#download-and-verify]
Using 1.0.0 as an example; substitute the version and architecture you want:
```bash title="Terminal"
VERSION=1.0.0
FILE=imgyoung_${VERSION}_linux_amd64.tar.gz
BASE=https://github.com/x-haose/imgyoung/releases/download/v${VERSION}
curl -fLO "$BASE/$FILE"
curl -fLO "$BASE/checksums.txt"
grep " $FILE\$" checksums.txt | sha256sum -c -
tar -xzf "$FILE"
```
```bash title="Terminal"
VERSION=1.0.0
FILE=imgyoung_${VERSION}_darwin_arm64.tar.gz
BASE=https://github.com/x-haose/imgyoung/releases/download/v${VERSION}
curl -fLO "$BASE/$FILE"
curl -fLO "$BASE/checksums.txt"
grep " $FILE\$" checksums.txt | shasum -a 256 -c -
tar -xzf "$FILE"
```
The binary has no developer signature and is not notarized by Apple. Files downloaded with `curl` carry no quarantine attribute and run as is; if you downloaded the archive in a browser, remove the quarantine attribute after extracting, or macOS will block it:
```bash title="Terminal"
xattr -d com.apple.quarantine imgyoung
```
```powershell title="PowerShell"
$Version = "1.0.0"
$File = "imgyoung_${Version}_windows_amd64.zip"
$Base = "https://github.com/x-haose/imgyoung/releases/download/v$Version"
Invoke-WebRequest "$Base/$File" -OutFile $File -UseBasicParsing
Invoke-WebRequest "$Base/checksums.txt" -OutFile checksums.txt -UseBasicParsing
$Expected = foreach ($Line in Get-Content checksums.txt) {
$Hash, $Name = $Line -split '\s+'
if ($Name -eq $File) { $Hash }
}
$Actual = (Get-FileHash $File -Algorithm SHA256).Hash
if (-not $Expected -or $Actual -ne $Expected) { throw "sha256 mismatch: expected $Expected, got $Actual" }
Expand-Archive $File -DestinationPath imgyoung
```
On success Linux and macOS print `: OK` and PowerShell reports no error. On a mismatch, do not use the archive; download it again.
## Running it [#running-it]
```bash title="Terminal"
./imgyoung
```
```powershell title="PowerShell"
cd imgyoung
.\imgyoung.exe
```
Open `http://:8721/` and sign in with `admin` / `imgyoung123`; the first sign-in asks you to change the password right away. `Ctrl+C` shuts it down gracefully.
- **Data directory**: by default `data/` under the **current directory at startup**, not the directory of the binary. Start it from somewhere else and you get a fresh, empty instance. Pin the location with `IMGYOUNG_DATA_DIR`:
```bash
IMGYOUNG_DATA_DIR=/srv/imgyoung ./imgyoung
```
```powershell
$env:IMGYOUNG_DATA_DIR = "D:\imgyoung-data"; .\imgyoung.exe
```
- **Listening address**: `:8721` by default, i.e. every interface; set `IMGYOUNG_LISTEN=127.0.0.1:8721` when only a local reverse proxy should reach it. Other environment variables are listed in [Configuration](../configuration.mdx).
- **Version**: `./imgyoung -version`. This is the only command-line flag; everything else is configured through environment variables and the console.
On Windows the firewall may ask whether `imgyoung.exe` may access the network on first run; for local-only use you can decline.
## Running under a process supervisor [#running-under-a-process-supervisor]
For long-running use, do not just leave it open in a terminal. A supervisor starts it on boot and restarts it after a crash. Also, "Restore" under "Settings → Backups & export" in the console makes the service exit cleanly and relies on the supervisor to start it again so the snapshot can be swapped in; without one, the restore only completes when you start it by hand. Configure the supervisor to **always restart the process when it exits**, including clean exits.
- **Linux**: use the systemd sample in the archive. The result is the same as `--service` of the [install script](./script.mdx), with data in `/var/lib/imgyoung`; that page also explains each setting:
```bash title="Terminal"
sudo install -m 0755 imgyoung /usr/local/bin/imgyoung
sudo install -m 0644 deploy/imgyoung.service /etc/systemd/system/imgyoung.service
sudo systemctl daemon-reload
sudo systemctl enable --now imgyoung
```
- **macOS**: write a launchd service, or use [Docker](./docker.mdx) instead.
- **Windows**: register `imgyoung.exe` as a Windows service with a tool such as WinSW or NSSM, set the working directory or `IMGYOUNG_DATA_DIR`, and configure it to restart on exit.
## Upgrading [#upgrading]
Download and verify the new archive, stop the service, replace the old binary with the new one, and start it again. Leave the data directory as is; the database schema is migrated automatically when the new version starts. Putting the old binary back is not a downgrade: the older version refuses to start on a database migrated by a newer one, so rolling back takes the older version plus a snapshot taken before the upgrade. Before upgrading, consider clicking "Back up now" under "Settings → Backups & export"; see [Upgrading](../operations/upgrade.mdx) for the rollback steps and more.
# Build from source (/en/docs/install/source)
Build the single binary with the admin console from source, and why the build flags are what they are.
## Prerequisites [#prerequisites]
| Tool | Version | Used for |
|---|---|---|
| Go | The version in the `go` directive of `go.mod`, or newer | Compiling the server |
| Node.js | 22 | Building the admin console front end |
| pnpm | The `packageManager` field of `web/package.json`; after `corepack enable` that version is used automatically | Front-end dependencies and build |
| [Task](https://taskfile.dev) | 3 | Build entry point; `task --list` lists every task |
| Git | Any | Getting the source; the Go toolchain derives the version from Git when building |
That is all you need to build. Tools for contributing (linters and so on) are listed in the repository's [CONTRIBUTING.en.md](https://github.com/x-haose/imgyoung/blob/main/CONTRIBUTING.en.md).
## Build and run [#build-and-run]
```bash title="Terminal"
git clone https://github.com/x-haose/imgyoung.git
cd imgyoung
task build
./imgyoung
```
`task build` first builds the front end (`pnpm install --frozen-lockfile` and `pnpm build` in `web/`, output in `web/dist`), then compiles `imgyoung` into the repository root. `task run` builds and then runs it.
Open `http://localhost:8721/` and sign in with `admin` / `imgyoung123`. Data goes to `data/` under the current directory, just like with a [release archive](./binary.mdx), and supervising and upgrading work the same way.
The version shown by `./imgyoung -version` is derived from Git by the Go toolchain: the tag when the checkout sits on one, a pseudo-version otherwise, with a dirty marker when the working tree has uncommitted changes. The build time is written in only by the release pipeline and the `Dockerfile`; binaries from `task build` leave it empty.
## Build flags [#build-flags]
The Taskfile sets two environment variables at the top level, and build tasks such as `task build` and `task run` run under them (only the race detector in `task test` turns cgo on, on Linux / Windows):
```yaml title="Taskfile.yml (excerpt)"
env:
CGO_ENABLED: '0'
GOFLAGS: -tags=nodynamic
```
Without Task, the equivalent manual steps are:
```bash title="Terminal"
(cd web && pnpm install --frozen-lockfile && pnpm build)
CGO_ENABLED=0 go build -trimpath -tags nodynamic -o imgyoung ./cmd/imgyoung
```
- **`CGO_ENABLED=0`**: pure Go throughout. The SQLite driver and image codecs are pure Go or WASM, so no C toolchain is needed; Linux binaries do not depend on libc, cross-compile directly (e.g. with `GOOS=linux GOARCH=arm64`), and run in minimal images without libc such as distroless/static.
- **`-tags nodynamic`**: by default the WebP codec library tries to load the system's libwebp shared library at runtime, which makes the Linux binary depend on the system's dynamic linker, so it cannot even start in minimal images such as distroless/static. With this tag it uses only its bundled WASM implementation; the result is a truly static binary whose behavior does not depend on whether the host has libwebp installed.
- **`-trimpath`**: strips local source paths from the binary.
The release pipeline (`.goreleaser.yaml`) and the image build (`Dockerfile`) use the same flags.
## Build the front end first [#build-the-front-end-first]
The admin console's front end is compiled into the binary with `go:embed`, but the build output is not committed; `web/dist` in the repository holds only a placeholder file. If you skip the front end and run `go build` directly, compilation still succeeds and the API works, but there is no console: `/` returns 404, and the startup log contains a warning:
```text
level=WARN msg="前端产物缺少 index.html,后台页面不可用(API 不受影响)"
```
(The message says the front-end build is missing index.html, so the console is unavailable while the API is unaffected.) When you see it, run `task web` (or the two `pnpm` steps above) and compile again. This is also why ImgYoung does not offer `go install` as an installation method.
## Building an image with Docker [#building-an-image-with-docker]
If you have Docker but neither Go nor Node.js, build with the multi-stage `Dockerfile` in the repository root; both the front end and the Go binary are compiled inside build containers:
```bash title="Terminal"
task docker
```
It is equivalent to the command below and produces the local image `imgyoung:dev`:
```bash title="Terminal"
docker build \
--build-arg VERSION=$(git describe --tags --always --dirty) \
--build-arg COMMIT=$(git rev-parse HEAD) \
-t imgyoung:dev .
```
The build context excludes `.git`, so the version and commit can only come in through `--build-arg`; without them the version shows as `devel`. The image is used exactly like the released one; see [Docker image](./docker.mdx).
# Uploading (/en/docs/guides/upload)
Upload images through the HTTP API, URL fetch or the admin console, plus format and size limits, EXIF handling, idempotency keys, how duplicates are handled and what a successful upload guarantees.
## Upload entry points [#upload-entry-points]
| Entry point | Authentication | Good for |
|---|---|---|
| `POST /api/upload` (multipart form) | Upload token | Scripts, PicGo / PicList custom uploaders |
| `POST /api/upload/url` (JSON) | Upload token | Letting the server fetch an image from another address |
| "Upload" on the **Images** page of the admin console | Console session | Drag and drop, file picker, clipboard paste, fetch from URL |
| S3 protocol `/s3/…` | S3 credential derived from an upload token | Clients that can upload to S3, see [Upload clients](./clients.mdx) |
All four go through the same upload pipeline: format check, pixel budget, quota pre-check, EXIF handling, channel writes, then the database record.
They differ only in where the bytes come from and which options they accept: the S3 entry point cannot set the lifetime per request, does no EXIF processing, and has no idempotency key.
Upload tokens are issued on the **Upload tokens** page of the console, see [Admin console](./admin.mdx#upload-tokens).
## Uploading through the API [#uploading-through-the-api]
```bash title="Terminal"
curl -H "Authorization: Bearer imgy_xxxxxxxx" \
-F "file=@photo.jpg" \
https://img.example.com/api/upload
```
With every optional parameter:
```bash title="Terminal"
curl -H "Authorization: Bearer imgy_xxxxxxxx" \
-H "Idempotency-Key: 7d0c6a2e-photo-0001" \
-F "file=@photo.jpg" \
-F "ttl=604800" \
-F "exif_policy=strip" \
https://img.example.com/api/upload
```
| Form field | Required | Meaning |
|---|---|---|
| `file` | Yes | The image file. The file name is metadata only (shown and searchable in the console); control characters are removed and it is truncated to 255 bytes. Only the first `file` part with a file name is used |
| `ttl` | No | Lifetime in whole seconds (0 to 3153600000) or `never`. Both `0` and `never` mean the image never expires. When omitted, the per-user default applies, then the global default; see [Lifecycle and deduplication](./lifecycle.mdx) |
| `exif_policy` | No | `strip` or `passthrough`. When omitted, the global default applies (`passthrough` out of the box); see [EXIF handling](#exif-handling) |
`ttl` and `exif_policy` are read only from multipart form fields; putting them in the URL query string has no effect. Each field is at most 1 KiB, and a longer one returns 400. Any other form fields are ignored.
The `Idempotency-Key` request header is optional, see [Idempotency keys](#idempotency-keys).
A successful upload returns 200:
```json
{
"id": "R0RjUyP1UW",
"url": "https://img.example.com/i/R0RjUyP1UW"
}
```
- `id` is a 10-character short ID drawn from `0-9A-Za-z`.
- `url` is always absolute. If the **Public site address** (`base_url`) is set, it is used; otherwise the address is derived from the request: the scheme from whether the connection uses TLS, the host from the `Host` header.
When the request arrives through a trusted reverse proxy, `X-Forwarded-Proto` and `X-Forwarded-Host` are used instead (which proxies are trusted is set by the `IMGYOUNG_TRUSTED_PROXIES` environment variable).
If the public domain behind a reverse proxy or CDN differs from the host in the request, you must set `base_url`; see [Configuration](../configuration.mdx) and [Reverse proxy](../operations/reverse-proxy.mdx).
Errors come back as `{"error": "…"}`; see [Status codes](#status-codes). The full API definition is in [API reference: data plane](../api/data-plane.mdx).
## Idempotency keys [#idempotency-keys]
On a flaky network a client may send the same upload twice. Sending an `Idempotency-Key` header keeps the retry from creating a second image:
- Keys are scoped per user; two users can use the same key without affecting each other, so nobody can use an idempotency key to probe for or retrieve someone else's image.
- Within the retention period, a request from the same user with the same key returns the `id` and `url` of the first successful upload and writes nothing to the channels. The retention period is `idempotency_key_ttl_hours`, 24 hours out of the box, counted from that first success.
- A hit does not compare the request content: if you reuse a key with a different file, you still get the first image back. Use a different key for every distinct image.
- Only successful uploads record their key, so a failed request can safely be retried with the same key.
- Of several concurrent requests with the same key, only one is stored; the others return the same result.
- Keys past their retention period are reclaimed by the cleanup sweep, and deleting an image also deletes the keys that point to it. After that, the same key is treated as a new upload.
Both `/api/upload` and `/api/upload/url` honor idempotency keys; the S3 entry point does not.
## Fetching from a URL [#fetching-from-a-url]
Give the server an image address and it downloads and stores the image for you, which is handy for pulling images back from other image hosts. The fetched bytes go through the same upload pipeline as a direct upload, so the format allowlist, pixel budget, EXIF handling and quota all apply; the server never copies a remote file straight into a channel.
```bash title="Terminal"
curl -H "Authorization: Bearer imgy_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/photo.png", "ttl": "never"}' \
https://img.example.com/api/upload/url
```
The body is a JSON object of at most 8 KiB containing only the fields below (any other field returns 400):
| Field | Required | Meaning |
|---|---|---|
| `url` | Yes | Image address, `http` / `https` only |
| `file_name` | No | File name; defaults to the last segment of the URL path, or `fetched` if there is none |
| `exif_policy` | No | Same as the form upload |
| `ttl` | No | Same as the form upload; the number `3600` and the strings `"3600"` and `"never"` are all accepted |
The response and the use of `Idempotency-Key` are the same as for the form upload. The format of the fetched bytes is determined from their content; the `Content-Type` declared by the remote server plays no part.
### Fetch restrictions [#fetch-restrictions]
This entry point makes the server issue an outbound request on the caller's behalf, so fetching is bound by a fixed set of rules that keep it from being used to probe your internal network:
| Rule | Detail | When violated |
|---|---|---|
| Scheme | Only `http` / `https`, checked again on every redirect hop | 400 |
| Destination | Public addresses only. The check runs right before each connection, against the IP actually resolved, so DNS rebinding and redirects into private networks are both caught | 400 |
| Redirects | At most 5 | 502 |
| Size | Same as the per-image limit (`max_upload_mb`). A declared `Content-Length` over the limit is rejected up front; without one, reading stops once the limit is exceeded | 413 |
| Time | 30 seconds for the whole fetch; establishing the TCP connection, the TLS handshake and waiting for response headers are each capped at 10 seconds | 502 |
| Remote status | 2xx only | 502 |
Rejected address ranges:
- IPv4: `0.0.0.0/8`, `10.0.0.0/8`, `100.64.0.0/10`, `127.0.0.0/8`, `169.254.0.0/16` (including the cloud metadata address `169.254.169.254`), `172.16.0.0/12`, `192.0.0.0/24`, `192.168.0.0/16`, `198.18.0.0/15`, `224.0.0.0/4`, `240.0.0.0/4`.
- IPv6: `::/128`, `::1/128`, `::/96`, `64:ff9b::/96`, `64:ff9b:1::/48`, `2002::/16`, `fc00::/7`, `fe80::/10`, `ff00::/8`. IPv4-mapped addresses (such as `::ffff:127.0.0.1`) are judged by the IPv4 address they contain.
A few more details:
- The server connects to the target directly and ignores proxy environment variables such as `HTTP_PROXY`.
- The request carries no `Referer`, and its `User-Agent` is the Go standard library default. Sources with hotlink protection or bot blocking may refuse it, which shows up as a 502.
- A fetch occupies one upload concurrency slot while it runs (see [Formats and limits](#formats-and-limits)).
## Uploading in the admin console [#uploading-in-the-admin-console]
On the **Images** page, click **Upload**. The dialog has two tabs:
- **Local files**: click to pick files or drag images onto the dashed area; multiple files are fine. While the dialog is open, Ctrl / ⌘ + V pastes images straight from the clipboard. At most two files upload at the same time; the rest wait in a queue.
- **Fetch from URL**: enter an image address and click **Fetch**; this is the same as [fetching from a URL](#fetching-from-a-url).
The **Expiry** (Default / Never / Custom hours) and **EXIF** (Default / Strip / Keep) options below apply to files added afterwards; **Default** means the field is not sent.
Closing the dialog does not interrupt uploads in progress; each finished upload shows its link with a copy button.
Console uploads belong to the signed-in account and need no token; the image details show the source as "Console upload / token removed". They share the rate limit and the concurrency gate with token uploads.
## Formats and limits [#formats-and-limits]
Only JPEG, PNG, GIF and WebP are accepted. The format is determined from the file content (magic bytes) and cross-checked against what the decoder recognizes; file extensions and the type declared by the client are ignored.
- SVG is not accepted (it can embed scripts).
- Animated GIFs can be uploaded; they are stored as is and always served as the original, so the animation is kept.
- Animated WebP is not supported; the upload returns 400.
| Limit | Setting | Default | When exceeded |
|---|---|---|---|
| Image size | `max_upload_mb` | 50 MB | 413. Only the file itself counts, so a file exactly at the limit is accepted; the request body as a whole is capped separately at the limit + 64 KiB, and exceeding that is also 413 |
| Pixel budget | `max_pixels_mp` | 50 megapixels (width × height) | 422. Judged from the image header alone, so an oversized image is never fully decoded |
| Concurrent uploads | `max_concurrent_uploads` | 8 | 429 with `Retry-After: 1` |
| Upload rate | `upload_rate_per_min` | 0 (off) | 429 with `Retry-After: 60` |
| Storage quota | Set per user | Unlimited | 403 |
- The **concurrency gate** is a single global gate shared by data plane uploads, URL fetches, console uploads and every S3 request (including GET / HEAD / DELETE). It caps how many requests are processed at once. Uploads of any size are processed in memory and never written to temporary files, so this number bounds the peak memory of uploading. When it is full, requests get 429 immediately instead of queueing.
- The **pixel budget** guards decoded memory, which depends on resolution rather than file size: a 20-megapixel image decodes to about 80 MB of RGBA. That is why only the header is read first, and an oversized image never reaches the decoder.
- The **rate limit** is per user, and uploads by the same user through tokens, the console and S3 PutObject share one allowance. It is a token bucket: up to N uploads can go through back to back (N being the per-minute allowance), after which one more is allowed every 60/N seconds. The buckets live in process memory and reset on restart.
- The **quota** counts every reference in full: each image record counts the full size of its physical file against its owner, and background deduplication does not reduce usage. The pre-check uses the size of the uploaded bytes.
All of these are changed under **Settings → Runtime settings** and take effect on save, see [Configuration](../configuration.mdx). Per-user quotas are set on the **Users** page.
### Status codes [#status-codes]
| Status | Meaning |
|---|---|
| 200 | Success (an idempotency key hit is also 200) |
| 400 | Missing `file` field or unreadable body; image content cannot be decoded (including animated WebP); an invalid `ttl` or `exif_policy`, or one longer than 1 KiB; for URL fetches, an invalid body, a scheme other than http/https, or a destination that is not a public address |
| 401 | Token missing, invalid, revoked or expired, or its owner is disabled |
| 403 | Token lacks the upload scope, or the storage quota is full |
| 413 | The file is over the per-image size limit, or the request body is over the limit + 64 KiB (for URL fetches, the remote content is too large) |
| 415 | Not a supported image format |
| 422 | Resolution exceeds the pixel budget |
| 429 | The concurrency gate is full or the user is uploading too fast; retry after `Retry-After` |
| 502 | URL fetch only: the remote server returned non-2xx, redirected too often, timed out or could not be reached |
| 503 | No healthy channel to write to, or fewer synchronous copies than the minimum succeeded |
## EXIF handling [#exif-handling]
| Policy | Behavior |
|---|---|
| `passthrough` | Stored byte for byte; no metadata is touched. The global default out of the box |
| `strip` | Metadata is removed before storing, as listed below |
The default is `passthrough`: keeping storage providers from seeing the original is the job of [channel encryption](../channels/encryption.mdx), while whether to strip metadata from what visitors with the link can see is the uploader's own choice, so by default the original is left untouched.
The policy for an upload is the form (or JSON) field if given, otherwise the global default `exif_policy`. S3 uploads skip EXIF processing and are stored byte for byte: S3 clients check the upload by size and ETag, so the stored bytes must match what was sent.
`spoof` (fake metadata) is not available yet; sending it returns 400, and it cannot be the global default either.
`strip` works at the byte level: nothing is re-encoded and no pixel changes:
| Format | Removed | Kept |
|---|---|---|
| JPEG | Every APPn segment except APP0 (JFIF), APP2 (ICC color profile) and APP14 (Adobe), which covers EXIF (GPS, camera model, capture time and so on), XMP, IPTC and vendor segments; COM comment segments; any data after EOI (such as the video in a Motion Photo) | Entropy-coded data is kept untouched. If the original has a non-default orientation tag (Orientation), a minimal EXIF segment holding only the orientation is added back, so the photo still displays upright. Segments between the scans of a progressive JPEG are not inspected |
| PNG | `eXIf`, `tEXt`, `zTXt` and `iTXt` chunks; any data after IEND | `gAMA`, `iCCP` and other chunks that affect color rendering |
| WebP | `EXIF` and `XMP` chunks, clearing the matching flags in the VP8X header and fixing the RIFF length; any data beyond the declared RIFF length | Pixel chunks (including animation frames) |
| GIF | Nothing | GIF has no standard container for EXIF / XMP |
- An image's SHA-256 (the hash shown in the console and the basis of deduplication) is computed over the bytes actually stored, after stripping.
- With `passthrough`, GPS and other metadata in the original can be read by anyone who has the link and fetches the original bytes.
The variants produced by [on-demand processing](./access.mdx#on-demand-processing) are re-encoded and carry no metadata, but the original itself stays reachable.
### Why processing happens at upload time [#why-on-upload]
The EXIF policy takes effect at the moment of upload, and the processed bytes are what gets stored; the original is not
reprocessed on access according to whatever the current setting is.
The reason is that caching on the access side rests on "the bytes behind a given `/i/` link never change": responses
carry `Cache-Control: immutable`, and browsers and CDNs can safely cache them for a year. If processing happened on
access instead, changing the global policy would change the bytes served by the same link: the CDN cache would have to
be purged wholesale, and old bytes already cached in visitors' browsers could not be invalidated at all.
The main benefit of processing on access would be keeping the original untouched in the channel and sanitizing only on
the way out. Privacy towards storage providers is already handled by channel encryption, and processing at upload with
`passthrough` as the default covers that need, so it is not worth giving up immutable caching for.
## Uploading the same image more than once [#uploading-the-same-image-more-than-once]
Uploads are not deduplicated on the way in: uploading the same file twice gives two short IDs, and two copies are written to the channels at first.
This is deliberate: the upload path has no branch such as "instant upload of a known file"; every upload goes fully
through validation, channel writes and recording, which keeps the surface for failures as small as possible.
Deduplication only needs the hashes computed during upload, so in the background it is a pure database operation with no
need to decode images again.
The background deduplication loop (every 300 seconds out of the box, `dedup_scan_seconds`) merges physical files whose stored bytes are identical into one, and removes the extra copy after the delete grace period. After a merge:
- Each upload keeps its own short ID, lifetime, access count and owner, and every link keeps working.
- Quotas count every reference in full, so a merge does not reduce usage.
The same photo uploaded with different EXIF policies is stored as different bytes and is not merged automatically; its perceptual hashes are close, so the pair lands in **Duplicates** for an administrator to decide.
See [Lifecycle and deduplication](./lifecycle.mdx#deduplication). Duplicates caused by client retries are best avoided at the source with [idempotency keys](#idempotency-keys).
## What a successful upload means [#what-a-successful-upload-means]
Before returning, an upload writes to healthy channels one after another in priority order until the number of successful writes reaches `min_success` (**Minimum synced replicas**, 1 out of the box):
1. If a write fails or times out (each write gets at most 10 minutes), the next healthy channel is tried; the first channel that succeeds is the primary channel for this upload.
2. Once enough copies exist, the record is written to the metadata database and the upload returns. The remaining channels are marked as pending and filled by background replication, which does not slow the upload down.
3. If there are not enough healthy channels, or not enough writes succeed after trying them all, the upload returns 503. Objects already written become orphans, which reconciliation reclaims after a 48-hour grace period.
With `min_success` above 1, an upload waits until several channels have the bytes before returning, in exchange for having multiple replicas from the start; if fewer channels are healthy than that number, every upload returns 503.
Channel priority, health checks and replication are covered in [Storage channels](../channels/index.mdx).
# Upload clients (/en/docs/guides/clients)
Connect PicGo, PicList, Typora, the aws CLI, rclone and other clients to ImgYoung, either as a custom web uploader or over the S3 protocol.
## Two ways to connect [#two-ways-to-connect]
| | Custom web uploader | S3 protocol |
|---|---|---|
| Client needs to | Send a multipart POST and read the link from a JSON response | Upload to S3-compatible storage (SigV4 signing, path-style addressing) |
| Typical clients | PicGo with the web-uploader plugin, PicList "advanced custom" uploader | PicList's built-in AWS S3, PicGo with the s3 plugin, aws CLI, rclone, S3 SDKs |
| Credential | The upload token itself | Access key and secret derived from an upload token |
| Returned link | The server returns `/i/` | The client builds `/o//` from the key |
| Lifetime | Can be set per upload | Defaults only |
| EXIF processing | Policy can be set per upload; the global default otherwise | None; bytes are stored as uploaded |
| Idempotency key | Supported | Not supported |
| Uploading the same name again | Always a new image | Same object key overwrites, S3 style |
Both end up in the same upload pipeline with the same format, size, quota and rate limit rules; see [Uploading](./upload.mdx).
Credentials always come from the **Upload tokens** page of the console. Issue one named token per client, so a leaked client can be cut off by revoking just its token.
## Custom web uploader [#custom-web-uploader]
The client POSTs the image as a multipart form to `/api/upload` with the file in the `file` field and an `Authorization: Bearer ` header, then takes the link from the `url` field of the JSON response.
`url` is absolute. If your public domain behind a reverse proxy or CDN differs from the host the server sees, set **Public site address** under **Settings → Runtime settings** first; otherwise the returned link points at the address inside the proxy.
### PicGo: the web-uploader plugin [#picgo-the-web-uploader-plugin]
PicGo has no generic HTTP uploader of its own. Search for and install the `web-uploader` plugin (npm package `picgo-plugin-web-uploader`) in the plugin settings; a new uploader called "自定义Web图床" appears:
| Plugin field | Config key | Value |
|---|---|---|
| API地址 (API URL) | `url` | `https://img.example.com/api/upload` |
| POST参数名 (POST field name) | `paramName` | `file` |
| JSON路径 (JSON path) | `jsonPath` | `url` |
| 自定义请求头 (custom headers) | `customHeader` | `{"Authorization": "Bearer imgy_xxxxxxxx"}` |
| 自定义Body (custom body) | `customBody` | Optional extra form fields, e.g. `{"ttl": "never", "exif_policy": "strip"}` |
Write the values in `customBody` as strings.
### PicList: advanced custom uploader [#piclist-advanced-custom-uploader]
Fill in PicList's built-in "advanced custom" uploader with the same information: endpoint `https://img.example.com/api/upload`, method POST, file field name `file`, header `Authorization: Bearer imgy_xxxxxxxx`, and `url` as the JSON path of the link in the response.
To set a lifetime or EXIF policy, add `ttl` and `exif_policy` as extra form fields.
## S3 protocol [#s3-protocol]
ImgYoung implements a subset of the S3 protocol under `/s3`, and the credential is your upload token: revoke the token and this route stops working too.
The protocol lives under the `/s3` prefix because S3 bucket paths are single segments at the site root, and mounting them at the root would take over the admin console's top-level routes.
### Getting the S3 settings [#getting-the-s3-settings]
On the **Upload tokens** page of the console, open **More actions** at the end of an active token's row and choose **S3 client settings**. The dialog lists everything you need to copy:
| Dialog field | Example | Notes |
|---|---|---|
| Access key ID | `imgy_AbCdEfG` | The token's identifying prefix (12 characters) |
| Secret access key | 64 hex characters | Derived from the master key for this token and never stored; you can come back and view it any time |
| Bucket | `images` | There are no buckets; any bucket name is accepted, and the bucket name is not part of the object key |
| Region | `us-east-1` | Regions are not distinguished; any value is accepted |
| Custom endpoint | `https://img.example.com/s3` | The trailing `/s3` is the protocol prefix and must stay |
| Custom domain | `https://img.example.com/o/` | Used to build image links; contains your user id |
The endpoint and domain are built from the **Public site address**, or from the address you opened the console with if that setting is empty.
- **Path-style addressing is required** (requests look like `/s3//`). A self-hosted server has no bucket subdomains, so turn on any "path style" or "ForcePathStyle" switch your client has.
- You can only view the S3 settings of your own tokens; administrators cannot view other users' either.
- Rotating a token changes both the access key and the secret, so update the client with the new values. Changing the master key invalidates every S3 secret.
- If the token is revoked or expired, or its owner is disabled, signature verification always fails.
### Supported operations [#supported-operations]
| Operation | Request | Behavior |
|---|---|---|
| PutObject | `PUT /s3//` | Runs the upload pipeline. The `ETag` response header is the SHA-256 of the uploaded body, and `x-amz-meta-image-id` carries the image's short ID |
| HeadBucket | `HEAD /s3/` | 200 whenever the credential is valid |
| HeadObject | `HEAD /s3//` | The object's metadata headers |
| GetObject | `GET /s3//` | The stored original; no on-demand processing, not counted as an access, not subject to hotlink protection; same cache headers as `/o/` aliases |
| DeleteObject | `DELETE /s3//` | Deletes the image the key currently points to; returns 204 even if the object does not exist |
Not supported: listing buckets and objects (`ListBuckets`, `ListObjects`), multipart upload, `CopyObject`, bucket creation, ACLs, presigned URLs (query string signing) and SigV2. These requests get a 4xx error.
ACL headers sent by a client do not cause an error and have no effect.
Signing and request bodies:
- Only `AWS4-HMAC-SHA256` signatures in the `Authorization` header are accepted, and requests whose timestamp is more than 15 minutes off the server clock are rejected.
- `UNSIGNED-PAYLOAD` is supported, and so are the `aws-chunked` streaming bodies that aws CLI v2 and others use by default. When a payload hash is declared, the server checks it against the actual body.
Values used for an upload:
- The object key is the whole path after the bucket name (URL-decoded), at most 1024 bytes, with no control characters and no leading `/`; it is unique within one user.
- The file name is the last segment of the object key; the lifetime is the per-user default, then the global default.
- No EXIF processing is done and the global EXIF default does not apply: the stored bytes are exactly the uploaded bytes, so the ETag returned by PUT matches the one GetObject returns later.
Errors come back as S3-style XML, and clients act on the `Code`:
| Situation | Status | Code |
|---|---|---|
| Signature mismatch, unknown access key, token no longer valid | 403 | `SignatureDoesNotMatch` |
| Request time more than 15 minutes off | 403 | `RequestTimeTooSkewed` |
| `Authorization` header missing or unparsable | 400 | `AuthorizationHeaderMalformed` |
| Signature is not `AWS4-HMAC-SHA256` | 400 | `InvalidRequest` |
| Over the per-image size limit | 400 | `EntityTooLarge` |
| Not a supported image, or over the pixel budget | 400 | `InvalidRequest` |
| Storage quota full | 403 | `QuotaExceeded` |
| Object key too long / with invalid characters / missing | 400 | `KeyTooLongError` / `InvalidArgument` / `InvalidRequest` |
| Object does not exist, or the image it points to has expired | 404 | `NoSuchKey` |
| No healthy channel to write to during upload, or fewer synchronous copies than the minimum succeeded | 503 | `ServiceUnavailable` |
| Object temporarily unreadable (GET / HEAD) | 503 | `ServiceUnavailable` |
| Any other server error | 500 | `InternalError` |
When the upload concurrency gate is full or the rate limit is hit, the response is 429 with a JSON body rather than XML.
### Object key aliases [#object-key-aliases]
An image uploaded over S3 has, besides its short ID, an address based on its object key:
```text
https://img.example.com/o//
```
- The user id is the immutable account identifier, not the username: `default` for the built-in administrator, and a random 10-character string for every other account. You can find it in the ID column of the **Users** page or in the **Custom domain** field of the S3 settings dialog. Object keys are only unique within a user, which is why the link needs it. It is the immutable ID rather than the username because usernames can change: after a rename, someone else could take the old name, and links shared earlier would then point at their images.
- `/o/` and `/i/` are two entrances to the same image, with identical on-demand processing, format negotiation, hotlink protection and access counting; only caching differs, see below and [Accessing images](./access.mdx#caching).
- The short ID is in the `x-amz-meta-image-id` header of the PutObject response, and the image also shows up on the **Images** page.
### Overwriting a key [#overwriting-a-key]
A second PutObject to the same object key overwrites it, S3 style:
- The object key now points to the newly uploaded image, and the `/o/` link shows the new image.
- The old image is not deleted, since its short ID may already have been shared: the short ID link keeps working, and it keeps using storage and quota; delete it on the **Images** page if you no longer need it.
- DeleteObject only deletes the image the key currently points to.
Because object keys can be overwritten, responses for `/o/` links (and S3 GetObject) carry `Cache-Control: public, no-cache` with a strong `ETag`: browsers and CDNs may cache them but must check with this server, sending the `ETag`, before each use, and get a 304 when nothing changed.
After an overwrite, the next check returns the new image. The short link `/i/` always points to the same image and is still cached as immutable for a year.
## Client configuration examples [#client-configuration-examples]
Replace `imgy_AbCdEfG`, `` and `` below with the values from the **S3 client settings** dialog.
### PicList: built-in AWS S3 [#piclist-built-in-aws-s3]
PicList ships an "AWS S3" uploader. Fill it in by config key (the labels in the UI vary between versions):
| Config key | Value |
|---|---|
| `accessKeyID` | **Access key ID** from the dialog |
| `secretAccessKey` | **Secret access key** from the dialog |
| `bucketName` | `images` |
| `uploadPath` | Object key template, e.g. `{year}/{month}/{md5}.{extName}` |
| `region` | `us-east-1` |
| `endpoint` | `https://img.example.com/s3` |
| `pathStyleAccess` | On |
| `urlPrefix` | `https://img.example.com/o/` |
| `disableBucketPrefixToURL` | On. Otherwise, with path-style addressing PicList inserts the bucket name into the link and produces an address that does not work |
### PicGo: the s3 plugin [#picgo-the-s3-plugin]
PicGo has no built-in S3 uploader. Install the `s3` plugin (npm package `picgo-plugin-s3`) in the plugin settings, and an "Amazon S3" uploader appears.
For links, this plugin recommends its output URL template (`outputURLPattern`), in which `{path}` is the full object key after upload:
| Config key | Value |
|---|---|
| `accessKeyID` / `secretAccessKey` | **Access key ID** and **Secret access key** from the dialog |
| `bucketName` | `images` |
| `uploadPath` | e.g. `{year}/{month}/{md5}.{extName}` |
| `region` | `us-east-1` |
| `endpoint` | `https://img.example.com/s3` |
| `pathStyleAccess` | On (labelled ForcePathStyle in the UI) |
| `outputURLPattern` | `https://img.example.com/o//{path}` |
Using a content hash such as `{md5}` in the object key means re-uploading the same image yields the same key, and different images never overwrite each other.
### Typora [#typora]
Typora does not upload to S3 or a custom endpoint by itself; it hands images to PicGo. In Typora's preferences under Image, set the upload service to `PicGo (app)` or `PicGo-Core (command line)`.
- With `PicGo (app)`, Typora calls the running PicGo app, so configure PicGo with either of the methods above.
- With `PicGo-Core (command line)`, PicGo-Core reads `~/.picgo/config.json`. Install the plugin with `picgo install s3` (or `picgo install web-uploader`), then write the config:
```json title="~/.picgo/config.json"
{
"picBed": {
"uploader": "aws-s3",
"current": "aws-s3",
"aws-s3": {
"accessKeyID": "imgy_AbCdEfG",
"secretAccessKey": "",
"bucketName": "images",
"uploadPath": "{year}/{month}/{md5}.{extName}",
"region": "us-east-1",
"endpoint": "https://img.example.com/s3",
"pathStyleAccess": true,
"outputURLPattern": "https://img.example.com/o//{path}"
}
},
"picgoPlugins": {
"picgo-plugin-s3": true
}
}
```
```json title="~/.picgo/config.json"
{
"picBed": {
"uploader": "web-uploader",
"current": "web-uploader",
"web-uploader": {
"url": "https://img.example.com/api/upload",
"paramName": "file",
"jsonPath": "url",
"customHeader": "{\"Authorization\": \"Bearer imgy_xxxxxxxx\"}"
}
},
"picgoPlugins": {
"picgo-plugin-web-uploader": true
}
}
```
### aws CLI [#aws-cli]
```ini title="~/.aws/credentials"
[imgyoung]
aws_access_key_id = imgy_AbCdEfG
aws_secret_access_key =
```
```ini title="~/.aws/config"
[profile imgyoung]
region = us-east-1
s3 =
addressing_style = path
multipart_threshold = 64MB
```
```bash title="Terminal"
# Upload
aws --profile imgyoung --endpoint-url https://img.example.com/s3 \
s3 cp ./photo.png s3://images/blog/2026/photo.png
# Read metadata and delete
aws --profile imgyoung --endpoint-url https://img.example.com/s3 \
s3api head-object --bucket images --key blog/2026/photo.png
aws --profile imgyoung --endpoint-url https://img.example.com/s3 \
s3 rm s3://images/blog/2026/photo.png
```
- `aws s3 cp` switches to multipart upload for files above `multipart_threshold` (8 MB by default), and multipart upload is not supported. Raise it above the per-image size limit, or use `aws s3api put-object` for a single-request upload.
- `aws s3 ls` and `aws s3 sync` rely on listing objects and do not work.
The resulting link is `https://img.example.com/o//blog/2026/photo.png`.
### rclone [#rclone]
```bash title="Terminal"
rclone config create imgyoung s3 \
provider=Other \
access_key_id=imgy_AbCdEfG \
secret_access_key= \
endpoint=https://img.example.com/s3 \
region=us-east-1 \
force_path_style=true \
no_check_bucket=true
rclone copyto ./photo.png imgyoung:images/blog/2026/photo.png
```
- `no_check_bucket=true` makes rclone skip its bucket check before uploading; the server does not support creating buckets.
- Stick to commands that act on a single object, such as `copyto` and `deletefile`. `ls`, `sync` and directory-level `copy` rely on listing objects and do not work.
### Other S3 SDKs [#other-s3-sdks]
Any S3 SDK works with the same settings: endpoint `https://img.example.com/s3`, path-style addressing on, any region (such as `us-east-1`), only PutObject / HeadObject / GetObject / DeleteObject, and no automatic switch to multipart upload.
# Accessing images (/en/docs/guides/access)
The two forms of image links, caching and conditional requests, on-demand resizing and WebP negotiation, the proxy and 302 access modes, hotlink protection, and how accesses are counted.
## Link forms [#link-forms]
| Link | Available for | Notes |
|---|---|---|
| `/i/` | Every image | The `url` from the upload response; the short ID has 10 characters from `0-9A-Za-z` |
| `/o//` | Images uploaded over S3 | An alias by object key, which may contain `/`; see [Upload clients](./clients.mdx#object-key-aliases) |
Both links are public and need no sign-in. They share one serving pipeline: hotlink protection, access counting, on-demand processing and failover reads across channels are identical.
- A missing, deleted or expired image returns 404 (plain text). Links stop working as soon as an image is deleted, and an image with a lifetime returns 404 the moment it expires, without waiting for the cleanup sweep to reclaim it; see [Lifecycle and deduplication](./lifecycle.mdx).
- If no healthy channel holds a usable replica, or every candidate channel fails to read, the response is 503 (plain text).
- If looking up the image metadata fails (a database fault), the response is 500 (plain text) and can be retried later.
- GET and HEAD are supported. HEAD returns the same status and headers as GET without a body and is not counted as an access; when no on-demand processing is needed it does not read the object from the channel, while a HEAD request that needs processing still produces the variant.
The full API definition is in [API reference: image access](../api/image-access.mdx).
## Caching and conditional requests [#caching]
The image behind a short link never changes and may be cached for a long time; an `/o/` alias can be overwritten to point at a new image, so caches must check before each use:
| Header | Short link `/i/` | Alias `/o/` |
|---|---|---|
| `Cache-Control` | `public, max-age=31536000, immutable` | `public, no-cache` |
| `ETag` | `""` for the original, `"--"` for a variant | Same |
| `Vary` | `Accept` when the output format comes from `Accept` negotiation, see [WebP negotiation](#webp-negotiation) | Same |
| `Last-Modified` | Upload time for the original; not set for variants | Same |
| `Content-Type` | The type detected from the content at upload for the original; the target format's type for variants | Same |
| `X-Content-Type-Options` | `nosniff` | `nosniff` |
A request whose `If-None-Match` exactly equals the current `ETag` gets 304, which also carries `Cache-Control`, `ETag` and, where applicable, `Vary`. Only `If-None-Match` is evaluated; `If-Modified-Since` is ignored.
`public, no-cache` lets browsers and CDNs store the response, but they must check with this server, sending the `ETag`, before each use, and get only a 304 when nothing changed; after an overwrite, the next check returns the new image.
Two kinds of response are never cached and carry `Cache-Control: no-store`: the fallback after an on-demand processing failure, and 302 direct-link redirects; both are described below.
The content behind a short link changes in only one case: an administrator merges a near-duplicate pair, and the links of the merged image start serving the kept image's content, while browsers and CDNs keep the old content until their cache expires. See [Lifecycle and deduplication](./lifecycle.mdx#deduplication).
## On-demand resizing and formats [#on-demand-processing]
Add query parameters to a link to get a resized or converted version:
```text
https://img.example.com/i/R0RjUyP1UW?w=400
https://img.example.com/i/R0RjUyP1UW?w=800&format=webp
https://img.example.com/i/R0RjUyP1UW?format=jpeg
```
| Parameter | Values | Notes |
|---|---|---|
| `w` | A value in **Width tiers** (`process_widths`), `100,200,400,800,1600` out of the box | Shrinks proportionally to that width; any other width returns 400. If the target is not smaller than the original width, nothing is enlarged and the image is only re-encoded to the target format |
| `format` | A value in **Target formats** (`process_formats`; `webp`, `jpeg`, `png` out of the box), or `auto` | Forces the output format; values not in the list return 400 |
Widths and formats are allowlists: accepting arbitrary values would let anyone saturate the CPU and the disk cache, so only a few presets are offered; with fixed presets, the result for a given set of parameters can also be served from the cache again and again. Change them in the **On-demand processing** group under **Settings → Runtime settings**; changes apply immediately.
### WebP negotiation [#webp-negotiation]
Without `format`, or with `format=auto`, the output format follows the request's `Accept` header:
- If `Accept` contains `image/webp` and `webp` is among the target formats, the output is WebP.
- Otherwise the original format is kept. Negotiation only ever upgrades to WebP; a WebP original is never converted for older clients.
- Every negotiated response carries `Vary: Accept`, including originals served unprocessed after negotiation and 304 responses; responses with an explicit `format` do not. GIFs take no part in negotiation and do not carry it either.
Every modern browser lists `image/webp` in `Accept`, so opening the `/i/` link of a JPEG or PNG in a browser yields WebP by default.
To get the original bytes, ask for the original's own format, for example `?format=jpeg` on a JPEG: when the target format equals the original format and there is no resizing, the original is served without re-encoding.
### Processing rules [#processing-rules]
- If there is no resizing and the format equals the original's, nothing is processed and the original is served.
- GIFs are always served as the original; `w` and `format` are ignored (invalid values do not cause a 400 either), so animations survive.
- A JPEG original with an EXIF orientation tag is turned upright before resizing, so the width applies to the image as displayed.
- Encoding: WebP quality `process_quality_webp` (80 out of the box) and effort `process_webp_method` (4); JPEG quality `process_quality_jpeg` (85); PNG is lossless at maximum compression. Output carries no metadata.
- Results are cached under `variants/` in the data directory, up to `variants_cache_mb` in total (10240 MB out of the box); beyond that the least recently used entries are evicted. Changing the encoding settings makes later requests produce new cache entries, and the old ones age out.
- Concurrent first requests for the same image and parameters are processed once and share the result.
- At most `process_workers` requests (4 out of the box) are processed at the same time; waiting for a slot and reading the original share one timeout, `process_timeout_ms` (5000 ms out of the box).
- Originals over the pixel budget, or larger in bytes than the current per-image size limit, are not processed.
When processing fails (no slot within the timeout, a decoding error and so on), the original is served instead, with `Cache-Control: no-store` and no `ETag`, so that the full-size original is never cached as the content of the parameterized link.
## Access modes: proxy and 302 [#access-modes-proxy-and-302]
**Access mode** (`access_mode`) under **Settings → Runtime settings** has two values:
- **proxy** (default): ImgYoung reads the image from the healthy channels holding a ready replica, in priority order, and relays it; if a read fails it moves straight on to the next channel.
- **redirect**: for channels that allow direct links, ImgYoung answers with a 302 and visitors download straight from the channel's public address, bypassing this host.
Proxy is the default because only when the image bytes pass through this host do all of the following hold at once:
hotlink protection applies to every request, on-demand processing and WebP negotiation work, a failed channel read
switches to another channel instantly, access counts are accurate, and channels' direct links are never exposed. The
traffic load is meant to be carried by a CDN in front; see [CDN](../operations/cdn.mdx). Redirect is for small VPSes
with limited bandwidth and no CDN; the trade-offs are in the table below.
In redirect mode, ImgYoung goes through the healthy channels holding a ready replica in priority order and picks the first one that:
- has **Allow 302 direct links** turned on in its channel settings;
- is not encrypted (ciphertext cannot be handed to visitors);
- has `public_base_url` configured.
The 302 target is `/`, where the in-channel key looks like `_` with no file extension. `public_base_url` must point at the directory that corresponds to the channel's `path`.
If no channel qualifies, the request is proxied as usual. Direct link and encryption settings are covered in [Storage channels](../channels/index.mdx).
Even in redirect mode, these requests are still proxied by this host:
- Requests that need on-demand processing: those with `w`, an explicit `format` different from the original's, or a negotiated WebP. Since browsers all declare WebP support, browser requests for JPEG / PNG are transcoded through the proxy by default; to send browsers through the 302 as well, remove `webp` from **Target formats**.
- The fallback response after a processing failure.
- Requests matching `If-None-Match`, which get a 304 from this host instead of a redirect.
| | proxy | redirect |
|---|---|---|
| Image traffic | Goes through this host | Served by the channel directly |
| Failover | Every request can switch channels | If the channel behind the 302 fails, visitors see its error; this host only redirects elsewhere once a health check marks that channel unhealthy |
| On-demand processing | Available | Requests that need it are still proxied |
| Hotlink protection | Checked on every access | Only the 302 step is checked; the channel's public address itself is unprotected |
| Encrypted channels | Work | Cannot be linked directly; requests fall back to the proxy |
302 responses carry `Cache-Control: no-store`, so neither browsers nor CDNs cache the redirect: its target changes with channel health, priority and direct-link settings, and deleting or expiring an image must take effect immediately. The redirect itself carries no image bytes, so each visit back to this host costs only a few database queries. Every visit reaches this host first, and access counting works as usual.
Object keys in channels have no file extension, so each write hands the content-sniffed `Content-Type` to the storage backend along with the bytes; object storage channels keep it, and opening a 302 link directly in a browser displays the image normally.
## Hotlink protection [#hotlink-protection]
Hotlink protection allows or rejects a request based on its `Referer` header. Rules are managed under **Settings → Hotlink protection** (administrators only).
### Rule syntax [#rule-syntax]
| Rule | Matches |
|---|---|
| `example.com` | Only `example.com` itself |
| `*.example.com` | Any subdomain of `example.com` (such as `blog.example.com` or `a.b.example.com`), but not `example.com` itself |
- Rules contain only a host name, with no scheme, port or path; they are case-insensitive and stored in lower case.
- Single-label host names (such as `localhost`) and IPv4 addresses are allowed; a rule is at most 253 characters.
- Only the host name in the `Referer` is compared; the port plays no part.
- Adding a duplicate returns 409. Adding and removing rules take effect immediately without a restart, and both are recorded in the audit log.
### How a request is judged [#how-a-request-is-judged]
- With no rules, hotlink protection is off and every request is allowed.
- With rules:
- With a `Referer`: the request is allowed only if its host matches a rule; a `Referer` that cannot be parsed counts as no match.
- Without a `Referer` (typed into the address bar, download tools, pages with `Referrer-Policy: no-referrer`): the **Allow empty referer** switch decides, which is the `allow_empty_referer` setting, on out of the box.
- Rejected requests get 403 (plain text) and are not counted as accesses.
Only the two public entrances, `/i/` and `/o/`, are covered. Thumbnails and previews in the admin console and S3 GetObject are not affected.
Rules live only in the metadata database and are managed through the console or the [API](../api/admin-referer.mdx); there is no environment variable or config file for them.
Remember to add the sites where you use your images (blog, documentation site), including any subdomains you need.
A `Referer` can be forged or left out, and with **Allow empty referer** on, every request without one gets the image.
When images go through a CDN, cache hits never reach this host, so its rules cannot see them and you need equivalent rules on the CDN; in redirect mode, channel public addresses are outside its reach as well.
## No private images [#no-private-images]
Every image link is public: anyone who knows the link can fetch the image, without logging in; there are no signed links
or other access checks. A short ID is just a random identifier, not a secret: privacy never depends on its length, and
it does not matter if a link is guessed or enumerated, because the image is public to begin with.
Truly private images would need short-lived signed links, checked by this host on every access: no year-long caching
and no 302 to a channel's direct link. That conflicts with an access path designed around "public, immutable,
cacheable for a long time", so ImgYoung does not offer private images. Do not upload images that must not be public;
if you only want to limit which sites can embed your images, use [hotlink protection](#hotlink-protection).
## Browsing in the console is not counted [#console-browsing]
Thumbnails and previews in the admin console are served from `/api/admin/images//thumb`, which:
- requires a console session and ignores hotlink protection;
- runs the same pipeline as `/i/` (including `w` and `format`);
- does not count accesses, update the last access time, extend lifetimes, or take the image off the cleanup list.
So administrators browsing images do not skew access statistics or cold-image decisions. Share links copied from the console are still `/i/` addresses.
## Access counts and top images [#access-counts-and-top-images]
An access is counted for every `/i/` or `/o/` GET request that reaches an image (after passing hotlink protection, with valid processing parameters), including 304 and 302 responses. Not counted: HEAD requests, requests rejected with 400 for an invalid `w` or `format`, console thumbnails and previews, S3 GetObject, and requests rejected by hotlink protection.
Counts are kept in memory and written to the database in batches every `activity_flush_seconds` (60 seconds out of the box). However often an image is accessed within one window, it gains 1, so **Accesses** reflects the number of windows with at least one access, not exact page views. Accesses are not written one request at a time because SQLite allows only one writer at a time, and per-request updates would compete with uploads and cleanup for the write lock; the cold-image threshold is measured in days and the counts feed trends and rankings, so this precision is enough.
Each batch write also:
- updates the **Last access** time;
- removes the pending-cleanup mark;
- with **Renew on access** on, pushes back the expiry of images that have a lifetime and have not yet expired; see [Lifecycle and deduplication](./lifecycle.mdx).
In the console you can see:
- **Accesses** and **Last access** in the image details;
- on the administrator's **Dashboard**, the total **Accesses** with an **Est. traffic** figure, and the 10 **Top images** by access count.
The traffic estimate is accesses × original size: 304, 302 and variant responses all count as the full original (an overestimate), while repeat accesses within one window count once (an underestimate).
For exact request counts and traffic, use your reverse proxy or CDN logs and the [metrics](../operations/monitoring.mdx).
# Lifecycle and deduplication (/en/docs/guides/lifecycle)
How lifetimes are resolved across three levels and renewed on access, expiry sweeps and the three-stage delete, tagging and reviewing cold images, and automatic merging of exact duplicates alongside manual review of near-duplicates.
## Lifetime (TTL) [#lifetime-ttl]
Each image can have a lifetime after which it is deleted automatically. The lifetime is resolved across three levels; the first one that is defined wins:
| Level | Where it is set | Values |
|---|---|---|
| Per upload | The `ttl` form field, the `ttl` JSON field of a URL fetch, or **Expiry** in the console upload dialog | Seconds; `0` or `never` means forever |
| Per-user default | **Users** page → **Edit** → **Default TTL (hours)** | Empty follows the global default; `0` means forever; a positive number is hours |
| Global default | **Default lifetime** (`default_ttl_hours`) under **Settings → Runtime settings** | `0` means forever (the default); a positive number is hours |
- An explicit `0` or `never` on an upload overrides both the user and the global default, so that image never expires.
- S3 uploads cannot set a lifetime per request and use the user default, then the global default.
- The expiry time is computed and stored at upload. Changing the user or global default later does not affect existing images.
- To make an image temporary, give it a short lifetime; there is no separate temporary folder or temporary upload type.
- Per-upload lifetimes, per-user defaults and the global default are all capped at 876000 hours (100 years); larger values are rejected. Every other runtime setting that converts to a duration (sweep intervals, the delete grace period, retention periods, timeouts and so on) is capped at 100 years in its own unit as well.
The lifetime of an existing image is changed in its details (by the image's owner or an administrator):
- **Change expiry** → enter a number of days and click **Extend**: the expiry becomes N days from now, and that duration becomes the image's new lifetime (renewals use it from then on).
- **Make permanent**: removes the expiry.
## Renewal on access [#renewal-on-access]
**Renew on access** (`ttl_renew_on_access`) is on out of the box. With it on, every public access to an image that has a lifetime pushes its expiry back to "the time the access was recorded + its lifetime".
In effect the lifetime turns into "expires after this long without a single access": images in use stay, images nobody looks at get cleaned up. With it off, the expiry time stays fixed.
- Accesses are collected in memory and written to the database in batches every `activity_flush_seconds` (60 seconds out of the box); renewal happens at that write. An image that has already expired at the moment of the write is not renewed: expiry is final.
- Only public accesses through `/i/` and `/o/` count; console thumbnails and previews and S3 GetObject do not. See [Browsing in the console is not counted](./access.mdx#console-browsing).
- Images without a lifetime are unaffected.
## Expiry sweeps [#expiry-sweeps]
The cleanup sweep runs once when the process starts and then every `cleanup_sweep_seconds` (60 seconds out of the box). Each round, in order:
1. deletes expired images (at most 500 per round; any backlog continues next round);
2. physically deletes files whose grace period has passed (see the next section);
3. tags cold images for cleanup (see [Cold images](#cold-images));
4. reclaims upload idempotency keys past their retention period;
5. reclaims old audit log entries if a retention period is set (`audit_retention_days`; 0, the default, keeps them forever).
As soon as an image expires, its public links return 404 and it can no longer be renewed by access; the cleanup sweep reclaims the record and the file afterwards. Until then it shows as **Expired** on the **Images** page and can still be deleted by hand. Images that expire while the process is down are deleted in the sweeps after it restarts.
## The delete flow [#the-delete-flow]
Every deletion takes the same path: expiry, deleting in the console (one image, or up to 100 at a time), deleting from the cleanup review, and S3 DeleteObject.
### Delete the reference [#delete-the-reference]
In one transaction, the image record is deleted (together with any idempotency keys pointing to it) and the reference count of its physical file goes down by one. The link returns 404 from then on.
If Cloudflare cache purging is configured, a CDN purge is queued for `/i/` and every variant parameter combination generated for the image; `/o/` alias links are not included. See [CDN](../operations/cdn.mdx).
### Mark for deletion [#mark-for-deletion]
When the reference count reaches 0, the physical file is marked for deletion at now + **Delete grace period** (`delete_grace_minutes`, 0 out of the box, meaning the next sweep deletes it).
### Delete physically [#delete-physically]
In the first sweep after that time, the file's variant cache is removed, the database records are deleted inside a transaction that re-checks the file is still unreferenced, and a deletion of the object is queued for every channel holding a replica.
Channel deletions run asynchronously with retries; objects that still survive become orphans, which reconciliation reclaims.
**Reference counts**: one physical file can be shared by several image records (the result of deduplication merges). Deleting one record does not affect the others; the physical file enters the delete flow only once the last reference is gone. **References** in the image details shows the current count.
The delete confirmations in the console tell you roughly when the physical file will go, based on the current grace period. A user can only be deleted after every image they own has been deleted.
## Cold images [#cold-images]
Images nobody has accessed for a long time are tagged for cleanup and wait on the **Cleanup** page for an administrator's decision. **They are never deleted automatically.**
Deletion cannot be undone, so the machine only finds candidates and a person decides; merging near-duplicates follows the same principle.
- The threshold is **Cold image threshold** (`cold_cleanup_days`), 90 days out of the box; 0 stops tagging.
- Tagging: on each cleanup sweep, images whose last public access (or upload time, if never accessed) is older than the threshold and which are not exempt get tagged.
- The tag is removed when:
- the image is publicly accessed (at the next batch write of access counts);
- an administrator decides to **Keep** it;
- an administrator removes its cleanup exemption (the next sweep judges it again);
- the image is deleted.
- Raising the threshold or setting it to 0 does not remove existing tags.
- Cold tagging and lifetimes are independent: images with a lifetime can be tagged too, and exempt images still expire.
### Reviewing [#reviewing]
The **Cleanup** page (administrators only) lists images from the longest-unaccessed down, and works on single images or selections:
- **Keep**: sets the cleanup exemption on the image, so it is never tagged as cold again; its lifetime and renewal are unaffected. The exemption can be removed in the image details.
- **Delete**: runs the delete flow above.
Only administrators can set cleanup exemptions, as part of the same authority as cold-image decisions, so an image's owner cannot exempt their own images to dodge cleanup. The **Cleanup exempt** switch in the image details can be turned on before an image ever goes cold; regular users see the state but cannot change it.
The **Exempt only** filter on the **Images** page lists every exempt image.
## Deduplication [#deduplication]
Deduplication never runs on the upload path, so it never slows uploads down. The background loop runs once at startup and then every `dedup_scan_seconds` (300 seconds out of the box); each round merges exact duplicates first, then scans for near-duplicates.
### Exact duplicates: merged automatically [#exact-duplicates-merged-automatically]
Physical files whose stored bytes are identical (same SHA-256) can be merged with no risk at all, so this happens automatically:
- the oldest copy is kept, the image records of the others are pointed at it, and their reference counts are added to it;
- the extra files are marked for deletion and removed after the delete grace period;
- files of different users are merged as well. Every image record keeps its own short ID, owner, lifetime and access count, and every link keeps working;
- quotas count every reference in full, so merging does not reduce anyone's usage.
### Near-duplicates: reviewed by hand [#near-duplicates-reviewed-by-hand]
Every physical file gets a 64-bit perceptual hash (pHash) at upload. When two files' hashes differ in no more bits (Hamming distance) than **Similarity threshold** (`dedup_phash_max_distance`, 10 out of the box), the pair becomes a candidate on the **Duplicates** page.
- A higher threshold is looser and produces more false positives; 0 turns similarity scanning off. The console notes that distances of 0–2 are near identical.
- Even a distance of 0 only makes a candidate and is never merged automatically: equal perceptual hashes do not mean equal bytes.
- Scanning is incremental: each round compares only new files against the existing ones, computing distances one by one in memory; at self-hosted scale no index structure is needed. After a restart everything is rescanned once, and dismissed pairs do not come back.
### Deciding, and which image to keep [#deciding-and-which-image-to-keep]
The **Duplicates** page (administrators only) shows each candidate pair side by side as Left and Right, with format, dimensions, size, reference count and distance, closest pairs first:
- **Keep this one** → confirm **Merge**: every reference of the other image now points to the kept one, the merged file is removed after the grace period, and any other candidates involving it are dropped. **This cannot be undone.**
- **Dismiss**: the pair is never shown again; several pairs can be dismissed at once.
After a merge, every link of the merged image (including other users' references) serves the kept image's content, which may differ in size, format and even picture. So keep the higher-resolution or better-format image, and choose **Dismiss** when the two are actually different pictures.
Browsers and CDNs that already cached the old content keep showing it until their cache expires.
Merges and dismissals are recorded in the audit log (actions "Merge duplicates" and "Dismiss duplicates").
## Related settings [#related-settings]
| Setting | Label in the console | Default |
|---|---|---|
| `default_ttl_hours` | Default lifetime | 0 (forever) |
| `ttl_renew_on_access` | Renew on access | On |
| `cold_cleanup_days` | Cold image threshold | 90 days |
| `delete_grace_minutes` | Delete grace period | 0 minutes |
| `cleanup_sweep_seconds` | Cleanup sweep interval | 60 seconds |
| `activity_flush_seconds` | Activity flush interval | 60 seconds |
| `idempotency_key_ttl_hours` | Idempotency key retention | 24 hours |
| `dedup_scan_seconds` | Dedup scan interval | 300 seconds |
| `dedup_phash_max_distance` | Similarity threshold | 10 |
| `audit_retention_days` | Audit log retention | 0 (forever) |
All of them are changed under **Settings → Runtime settings** and take effect on save; see [Configuration](../configuration.mdx).
# Admin console (/en/docs/guides/admin)
What each page of the admin console is for, how administrators and regular users differ, quotas and upload rate limits, the full lifecycle of upload tokens, and sign-in, sessions and interface preferences.
## Signing in [#signing-in]
The admin console lives at the site root, for example `https://img.example.com/`.
On first start the built-in administrator signs in as `admin` with the factory default password `imgyoung123`. After that sign-in the console goes straight to **Set the admin password**.
The server enforces this: a session still using the factory password can only change the password, sign out and read the current account; every other console API (including console uploads, issuing tokens and managing channels) returns 403 with `须先修改出厂默认密码` (the factory default password must be changed first), whether the request comes from the console or a script.
The default password is public, so do this before the instance is reachable from the internet. If you forget the password, see [Password reset](../operations/password-reset.mdx).
There is no environment variable for setting the initial administrator password, and no bootstrap token provided through an environment variable: a one-off seed like that stops having any effect after its first use, yet stays in the deployment files and leaves people guessing where a value came from. On a headless deployment, sign in once, change the password, and issue a token in the console.
## A tour of the pages [#a-tour-of-the-pages]
The sidebar has three groups, and regular users see only part of it:
| Group | Pages | Visible to |
|---|---|---|
| Content | Dashboard, Images, Upload tokens | Everyone |
| Site | Storage channels, Users, Duplicates, Cleanup, Audit log | Administrators |
| Other | Guide | Everyone |
| Other | Settings | Administrators |
The top bar has page search (⌘K / Ctrl+K opens the command palette), the language switch and the theme switch. The account menu at the bottom of the sidebar holds **Change password** and **Sign out**.
### Dashboard [#dashboard]
Administrators see the whole site:
- Stat cards: Images, Storage used (original bytes in total, replicas not double counted), Users, Accesses (with an estimated traffic figure), Pending cleanup, Possible duplicates.
- **Storage channels**: the health and stored bytes of each channel.
- **Alerts**: alerts that are still open; each resolves itself once its condition no longer holds. **History** leads to the alert history in Settings. Alert kinds are channel unhealthy, replication failed, channel over quota, channel drain failed and backup failed.
- **Top images**: the 10 images with the most accesses.
- When no healthy channel lives off this host's disk, a **Your data only lives on this host** warning appears at the top: image replicas and metadata snapshots exist only on this host, so losing the host loses everything. The condition is that no channel is healthy, not draining and using a driver other than local; it also shows when there is no healthy channel at all. Adding an object storage or cloud drive channel clears it; if you already have one, check on the **Storage channels** page why it is unhealthy.
Regular users see their own image count, space used and quota. While the site has no images yet, the dashboard shows a three-step quick start.
How accesses and the traffic estimate are counted is described in [Accessing images](./access.mdx).
### Images [#images]
A grid of images: administrators see the whole site (**All images**), regular users only their own (**My images**).
- Search by short ID or file name.
- **Filter** by **Upload token**, **Uploaded** time and **Exempt only**; administrators can also filter by **Owner**.
- **Select** lets you pick images and **Delete selected**, at most 100 at a time.
- **Upload**: uploads straight from the console, see [Uploading](./upload.mdx#uploading-in-the-admin-console).
Clicking an image opens its details:
- **Share link**: the full link, with **Copy as** URL, Markdown, HTML or BBCode; below it the available resize widths, each copying a link with `?w=`. When a public site address is set, links use its domain.
- Metadata: format, dimensions, size, owner, source token, references, upload time, last access, accesses, expiry (with **Change expiry**), cleanup exemption and SHA-256.
- **Replicas**: the replica state of this image on each channel (Pending / Uploading / Ready / Failed), attempt count and last error.
- **Delete**; administrators also get **Audit log**, which opens the audit entries targeting this image.
Lifetimes and cleanup exemptions are explained in [Lifecycle and deduplication](./lifecycle.mdx).
### Upload tokens [#upload-tokens]
Issue and manage the credentials for the data API; see [Upload tokens](#upload-tokens) below.
### Storage channels [#storage-channels]
Create, edit, check and delete channels, set their priority, encryption and 302 direct links, and watch each channel's replication progress. Form fields and per-backend configuration are covered in [Storage channels](../channels/index.mdx); a few points matter when working in the console:
- Cloud drive templates have two fields above the authorization control, **App client ID** and **App client secret**, which are stored encrypted with the channel configuration. One-click authorization requires the client ID first; when pasting a token that was authorized with your own app, fill both in too, because the token can only be refreshed by the app that issued it.
- When editing a channel, credential-type parameters are masked: besides passwords, secrets and tokens, this includes access key IDs, WebDAV usernames, client IDs and the like. Leave them unchanged to keep the stored values.
- A channel that fails to load (its configuration cannot be decrypted, the driver cannot be built and so on) shows as **Unhealthy** in the list, with a reason starting with `装载失败:` (load failed). Every health check round retries loading it, and **Probe again** retries loading first as well; it becomes healthy and serves reads and writes again only once it loads and passes the probe.
- With **Encrypt new channels by default** turned on in Settings, a new channel whose **Server-side encryption** stays at **Inherit the global default** is encrypted, so **Allow 302 direct links** is unavailable; to use direct links, set server-side encryption to **Force off**.
- If the provider refreshes the authorization token during **Test connection**, the result is followed by "The provider refreshed the authorization token during the test. The form now holds the new token and will save it; the old one may no longer work." and the authorization field shows **Authorized**; just save. When editing, a stored token that is reused and gets refreshed is written straight back to the channel.
- **Export rclone config** in a channel's row menu (not offered for draining channels) downloads the channel's rclone config file, `imgyoung-channel-.rclone.conf`. With it, a stock rclone can read and write the channel's data without ImgYoung; an encrypted channel also gets a crypt remote, through which reads come back as plaintext.
The file contains the channel's credentials and OAuth tokens (plus the decryption passwords for an encrypted channel), so keep it offline and safe like the master key; every download is recorded in the audit log. The file's contents and usage are covered in [Channel encryption](../channels/encryption.mdx) and [Storage channels](../channels/index.mdx).
When you delete a channel, **Also delete what this service stored on the channel** is ticked by default and the confirm button reads **Delete and purge**: image replicas and metadata snapshots (and any leftover deep-check files) are deleted one by one in the background, the channel shows as **Draining** meanwhile, and it is removed once they are gone. Files this service did not write are left alone.
Untick it and the button becomes **Remove only**: the channel is removed immediately, and the image replicas and metadata snapshots on it are left as they are for you to deal with at the provider if needed.
If **Delete and purge** fails to delete objects:
- The reason is written into the channel's check result on the very first failure, visible in the channel list, and a "Channel drain failed" alert opens. A purge run that merely times out or is interrupted by a shutdown does not count as a failure.
- Retries then continue automatically with backoff and never stop, so a channel that was only temporarily unavailable finishes draining on its own once it recovers.
- If the credentials are revoked or the bucket no longer exists and retrying cannot succeed, use **Remove now** on the channel: it is removed immediately and any remote objects not yet deleted are left as they are. For a channel you already know cannot be purged, choose **Remove only** when deleting it in the first place.
### Users [#users]
Lists every account with its image count, usage / quota and default TTL. You can create (**New user**), **Edit**, **Reset password** and **Delete**; see [Roles and permissions](#roles-and-permissions) and [Quotas and upload limits](#quotas-and-upload-limits) below.
### Duplicates and Cleanup [#duplicates-and-cleanup]
Two review queues: **Duplicates** compares pairs of images with close perceptual hashes side by side so you can merge or dismiss them; **Cleanup** lists cold images nobody has accessed for a long time so you can keep or delete them. See [Lifecycle and deduplication](./lifecycle.mdx).
### Audit log [#audit-log]
An append-only record of administrative actions, filterable by **Action**, **Actor** (user ID), **Target** (image short ID, channel ID, token ID and so on, exact match) and **Time range**. Recorded actions:
| Object | Actions |
|---|---|
| Channels | Create channel, Edit channel, Delete channel, Export channel rclone config |
| Tokens | Issue token, Revoke token, Rotate token, Delete token |
| Users | Create user, Update user (including password changes and resets), Delete user |
| Images | Update image (lifetime, cleanup exemption), Delete image (including S3 DeleteObject) |
| Reviews | Merge duplicates, Dismiss duplicates |
| Settings | Update settings, Add referer allowlist entry, Remove referer allowlist entry |
| Data | Export data, Create backup, Restore backup |
Uploads themselves and automatic expiry are not audited. Retention is set by **Audit log retention** (`audit_retention_days`); 0, the default, keeps entries forever.
### Guide [#guide]
A built-in quick reference whose curl, PicGo and S3 client examples are filled in with the current site's address. Visible to every user.
### Settings [#settings]
Administrators only, with four tabs:
- **Runtime settings**: every runtime setting, grouped into access & links, upload limits, lifecycle, on-demand processing, deduplication, reliability & channels, CDN cache purge, and alerts & audit. Click **Save changes** and they apply immediately without a restart.
The **Bootstrap configuration** card below shows the environment variables read-only (listen address, data directory, trusted proxy networks, networks allowed on `/metrics`), the server version and the built-in login rate limit. See [Configuration](../configuration.mdx).
When a network variable is not set, its default networks are shown; when it is explicitly set to an empty string, **Trusted proxy networks** shows "Empty: forwarded headers are never trusted" and **Networks allowed on /metrics** shows "Empty: open to no one".
- **Hotlink protection**: the Referer allowlist and the **Allow empty referer** switch; see [Accessing images](./access.mdx#hotlink-protection).
- **Backups & export**: metadata snapshots are written to every healthy channel on the **Backup interval**, each channel keeping its own most recent copies. The snapshot table lists each snapshot with its size, time and **Stored on** (the channels holding it, in priority order), with **Back up now**, **Download** and **Restore**; **Export JSON** exports all metadata.
If some channels fail during **Back up now**, an error such as `备份失败:快照 imgyoung-….db 有 k/n 个渠道未写成: …` lists them, and the channels that succeeded keep the snapshot; with no healthy channel the error is `没有健康渠道可存放快照`. **Download** and **Restore** read the snapshot from the healthy channels holding it in priority order, moving on to the next one when a read fails. See [Backup and restore](../operations/backup.mdx).
- **Alert history**: every alert's open and resolve record. For the alert webhook, see [Monitoring](../operations/monitoring.mdx).
## Roles and permissions [#roles-and-permissions]
Accounts are created only by administrators on the **Users** page; there is no sign-up. There are two roles:
| Capability | Administrator | User |
|---|---|---|
| Upload (token, console, S3) | Yes | Yes |
| View and delete images | Whole site | Own only |
| Change image lifetimes | Whole site | Own only |
| Set cleanup exemptions | Yes | No |
| Issue and rotate own tokens, view own tokens' S3 settings | Yes | Yes |
| View, revoke and delete other users' tokens | Yes | No |
| Storage channels, Users, Duplicates, Cleanup, Audit log, Settings | Yes | No |
Regular users cannot see each other's images or tokens; opening someone else's image details returns 404 `图片不存在` (image not found). Image links themselves are public: anyone with a link can load it.
There can be several administrators. The built-in administrator (user ID `default`) is protected: it cannot be disabled, demoted or deleted, though it can be renamed. No administrator can delete their own account.
On the **Users** page:
- **New user**: username, initial password (at least 8 characters; usable right away), role, quota and default TTL.
- **Edit**: change the username, role, quota or default TTL, or tick **Disabled**. Disabling ends all of the user's sessions at once, and their tokens can no longer upload.
- **Reset password**: sets a new password and ends all of the user's sessions immediately.
- **Delete**: refused while the user still owns images, so delete those first; the user's tokens and sessions go with the account, and audit entries stay.
## Quotas and upload limits [#quotas-and-upload-limits]
- **Quota**: set per user when creating or editing the account, in MB or GB; empty or 0 means unlimited. Every reference counts in full: each image counts the complete size of its physical file, and deduplication merges do not reduce usage. There is only this one way of counting, so nobody can lower their usage by uploading the same images as someone else. An upload that would exceed the quota gets 403.
Users see their space used and quota on the dashboard; administrators see the **Usage / quota** column on the **Users** page.
- **Upload rate limit**: `upload_rate_per_min` under **Settings → Runtime settings**, applied to every user alike; 0 (the default) turns it off. Uploads by the same user through tokens, the console and S3 share one allowance.
- **Concurrent uploads**: `max_concurrent_uploads`, shared by the whole site, 8 out of the box.
What happens when a limit is hit, and the status codes, are covered in [Uploading](./upload.mdx#formats-and-limits).
## Upload tokens [#upload-tokens-1]
Upload tokens are the credentials for the data API (`/api/upload`, `/api/upload/url`) and the S3 protocol. They can only upload: they cannot sign in to the console or call the console API.
There are exactly two kinds of credentials: a password plus session for the console, and upload tokens for the data API and S3 (S3 signatures use the same tokens). Tokens cannot call the console API so that a leaked token can do no more than upload as its user; the console API is additionally protected against CSRF. A token's permission is also limited to uploading: issuing one per client, each revocable on its own, is enough to contain a leak.
### Issuing [#issuing]
On the **Upload tokens** page, click **Issue token**:
- **Purpose**: for example `picgo` or `blog-script`, at most 64 bytes. A user cannot have two active tokens with the same name; if the old token of that name has expired, issuing a new one revokes the expired record automatically.
- **Expiry**: Never, 7, 30, 90 or 365 days. An expired token stops working exactly as if it were revoked.
After issuing, a dialog shows the token secret (`imgy_` followed by 43 characters) **this one time only**; once closed it can never be shown again. The database keeps only its SHA-256 and a 12-character identifying prefix; a token is a high-entropy random string, so a plain hash is enough and no slow password hash is needed.
Use it as the request header `Authorization: Bearer `.
### List and status [#list-and-status]
The list shows name, prefix, status (Active / Expired / Revoked), expiry, last used and issue time. **Last used** is written in batches about once a minute.
Every image records the token it was uploaded with, and the **Images** page can filter by **Upload token**.
### Row actions [#row-actions]
Open **More actions** at the end of a row:
| Action | Applies to | Effect |
|---|---|---|
| **S3 client settings** | Your own active tokens | Shows the S3 access key, secret and endpoint for this token; you can view them as often as you like. See [Upload clients](./clients.mdx) |
| **Rotate** | Your own active tokens | Atomically issues a new token with the same name and revokes the old one. The new secret is also shown only once; the old token and its S3 credential stop working immediately. A token that never expired still never expires; one with an expiry gets the same duration again, counted from now |
| **Revoke** | Active tokens | Stops working immediately: clients using it can no longer upload, and its S3 credential stops working too. The record stays |
| **Delete record** | Revoked or expired tokens | Removes it from the list; audit entries stay. Active tokens cannot be deleted; revoke them first |
### Administrators viewing other users' tokens [#administrators-viewing-other-users-tokens]
Administrators can use **View by user** to switch to any user's token list and **Revoke** or **Delete record** there.
**Rotate** and **S3 client settings** are only available for your own tokens: both hand out working credentials, so using them on someone else's token would amount to taking over that user's upload access.
## Sessions and security [#sessions-and-security]
| Mechanism | How it works |
|---|---|
| Password storage | argon2id (64 MiB memory, 3 iterations, 1 lane) with a random salt per password. Passwords need at least 8 characters |
| Login rate limit | Counted separately per client IP and per username: 5 attempts in a row each, then one more every 12 seconds; beyond that the response is 429. A successful sign-in resets the count. Behind a trusted reverse proxy, the client IP comes from `X-Forwarded-For` |
| Username enumeration | An unknown username and a wrong password return the same error and take about the same time to check |
| Sessions | Signing in issues a 256-bit random session ID in the cookie `imgyoung_session`, marked `HttpOnly` and `SameSite=Lax`, plus `Secure` when the client reaches the site over HTTPS. The server stores only its hash, so a leaked database cannot be used to hijack sessions. Sessions last a fixed 30 days and are not extended by use |
| CSRF | Each session is bound to a CSRF token that every state-changing request must send in the `X-CSRF-Token` header; the console does this automatically |
| Factory password | While the built-in administrator still uses the factory default password, the server only allows changing the password, signing out and reading the current account; every other console API returns 403 |
| Changing passwords | **Change password** asks for the current password; afterwards every device of that user is signed out and must sign in again |
| Disabling and resets | Disabling a user or resetting their password ends all of their sessions at once; a disabled user cannot sign in and all of their tokens stop working |
| Page hardening | Every response carries `X-Frame-Options: DENY` and `X-Content-Type-Options: nosniff`; console pages also carry a Content Security Policy that allows scripts and API calls only from this site and forbids embedding in other pages |
Both the HTTPS check and the client IP depend on forwarding headers from your reverse proxy, which are trusted only from the networks in `IMGYOUNG_TRUSTED_PROXIES`. See [Reverse proxy](../operations/reverse-proxy.mdx).
## Language and theme [#language-and-theme]
- Language: the language button in the top bar switches between 简体中文 and English. On a first visit the browser language decides (Chinese browsers get Simplified Chinese, everything else English); after that your choice is remembered for a year.
- Theme: the theme button in the top bar offers Light, Dark and System. Dark is the default, and the choice is remembered as well.
Both preferences are stored only in the current browser and do not follow your account.
# Channels and failover (/en/docs/channels)
What a channel is, where uploads go, how replicas are filled in, how reads fail over, and the rules for health checks, editing, deleting and usage alerts.
## What a channel is [#what-a-channel-is]
A channel is one rclone backend instance: a backend type (`s3`, `webdav`, `local`…) plus its parameters, plus a few ImgYoung attributes:
| Attribute | Meaning |
|---|---|
| Channel name | Unique, up to 64 bytes; used in the UI, logs, metrics and alerts |
| Priority | Integer, higher wins; the admin console accepts 0–1000 and defaults to 100 |
| Server-side encryption | Whether data is stored through rclone crypt; fixed at creation, see [Channel encryption](./encryption.mdx) |
| Allow 302 direct links | Whether reads may be redirected to the channel's public URL, see [Object storage and WebDAV](./object-storage.mdx) |
Backend parameters are handed to rclone as a map, never through `rclone.conf`; the whole parameter set is encrypted with the master key (AES-256-GCM) and stored in the metadata database. Channels are managed on the **Storage channels** page of the admin console, which only admins can see; the API is under [admin channels](../api/admin-channels.mdx). Channel objects returned by the API (the list, the responses to create, edit and probe again, and `channels` in the metadata export) carry `created_at`: when the channel was created (UTC). It is part of the key derivation for encrypted channels and never changes.
**Every channel ends up holding a replica of every image.** Channels mirror each other in full; nothing is sharded. Size each channel for the whole library.
The daily reconciliation only removes orphans this service wrote, meaning objects that meet all of these: the key has this service's object key format `_`; the metadata database has no replica record of any state for it on this channel; it was modified no earlier than the channel's bind time; and it is older than 48 hours. Deep-check leftovers `.imgyoung-probe-*` are also removed once older than 48 hours. Files other programs put under the same path are only counted (the `foreign` field of the reconciliation log), never deleted. Two ImgYoung instances must not share a channel path: objects written by the other instance look like orphans to this one and get deleted.
The bind time is when the channel record started pointing at its current storage location: it equals the creation time for a new channel and is reset when [editing](#editing-a-channel) changes the configuration; restoring a snapshot (from the admin console, or by placing it as `/imgyoung.db.restore` and starting) resets it for every channel. The full rules are under "Reconciliation GC" in [Metadata backups](../operations/backup.mdx).
## The built-in local channel [#the-built-in-local-channel]
On first start with a fresh metadata database (an empty channels table), ImgYoung creates a channel named `local`: backend `local`, priority 100, stored under `/blobs`. Uploads work with no configuration at all.
- It is only seeded while the channels table is empty. Once you delete or rename it, restarts do not bring it back.
- It lives on the same machine as the metadata database and the master key, so losing the host loses everything. While it is the only channel, the Storage channels page shows "Only the local disk is configured". Add an off-site channel and existing images are replicated to it automatically; metadata snapshots are written to every healthy channel on each round, so the new channel holds snapshots from the next backup on.
## Priority: primary and backup channels [#priority-primary-and-backup-channels]
There is no separate "primary" switch; priority alone expresses primary and backup:
- **Primary channel**: the highest-priority channel that is healthy and not draining. The list marks it **Primary**.
- **Backup channels**: everything else. Normally uploads do not write to them directly and asynchronous replication copies replicas over; they are written synchronously only when a higher channel fails mid-upload or when `min_success` is above 1.
- On equal priority, the channel created first wins.
When the primary turns unhealthy, the next healthy channel takes over with no manual step; once the original recovers, it is primary again.
## Upload writes [#upload-writes]
Each upload walks the currently healthy channels from highest to lowest priority and writes synchronously until `min_success` copies have succeeded:
1. If a write fails (including the 10-minute per-write timeout), the next healthy channel is tried.
2. As soon as enough copies exist, the upload succeeds. The first channel that accepted the write holds the image's first replica.
3. Every other channel, including temporarily unhealthy ones, gets a pending replica for asynchronous replication.
`min_success` lives under **Settings → Runtime settings → Access & links → Minimum synced replicas** (default 1, minimum 1). When there are not enough healthy channels or every write fails, the upload returns 503 with `存储渠道暂不可用,请稍后重试` ("storage channels temporarily unavailable, retry later"; S3 uploads get 503 `ServiceUnavailable`) and no metadata is recorded; copies that did get written become orphans and are removed by the daily reconciliation.
If `min_success` exceeds the number of healthy channels, every upload fails. Setting it to 2 means an upload only succeeds once two channels hold it, at the cost of write latency and a dependency on several channels being up.
## Asynchronous replication [#asynchronous-replication]
Replica state lives in a replica table in the metadata database, and that table is the single source of truth. The job queue (River, in the same SQLite database) only triggers work, and at most one job per image and channel is in flight.
| Replica state | Meaning |
|---|---|
| `pending` | Waiting to be replicated |
| `uploading` | Replication in progress |
| `done` | Ready to serve reads |
| `failed` | Still failing after all retries |
- **Copying**: the replica is read from the highest-priority healthy channel that already has it and streamed into the target channel. The queue runs 4 jobs at a time (shared with delete and drain jobs); one replication job may run for at most 15 minutes.
- **Retries**: a failed job goes back to `pending` and River retries it with exponential backoff, roughly n⁴ seconds after the n-th failure (1 s, 16 s, 81 s…), up to 25 attempts spanning about 20 days. If the last attempt fails, the replica becomes `failed`.
- **Periodic reconciliation**: at startup and every 10 minutes after, ImgYoung adds missing replica rows for every channel in service, resets `failed` replicas on healthy channels to `pending`, and re-queues every unfinished replica (jobs already queued are de-duplicated). The "Replica reconciliation every … s" line under **Settings → Runtime settings → Bootstrap configuration** shows this interval.
- **Survives restarts**: jobs and replica states are in SQLite. A graceful shutdown cancels in-flight replication jobs; their replica states stay in the table and the next startup reconciliation re-queues them. Jobs that were running when the process was killed are rescheduled by River after about 16 minutes.
- **Backfilling a new channel**: creating a channel registers a pending replica for every existing image in the same transaction, and replication starts right away. If an upload racing with the creation missed the new channel, the periodic reconciliation fills the gap.
- **Daily reconciliation GC**: every 24 hours each healthy channel is compared with the database in both directions. Objects this service wrote that have no replica record are deleted once older than 48 hours (orphans, as defined at the top of this page); replicas the database marks ready but the channel no longer has go back to `pending` and are replicated again (phantoms).
## Read failover [#read-failover]
To serve an image, the candidate channels are those that are **healthy, not draining, and hold a ready replica of that image**, ordered by priority.
- **Proxy mode** (`access_mode = proxy`, the default): candidates are read in order; if one fails, the next is tried, invisibly to the visitor. With no candidates, or when all of them fail, the response is 503 with `渠道暂不可用` ("channel temporarily unavailable").
- **Redirect mode** (`access_mode = redirect`): the first candidate that allows 302 direct links, is not encrypted and has a public URL gets a 302; if there is none, the image is proxied. Requests with processing parameters (such as `?w=`) are always proxied.
A channel the replica has not reached yet is not a candidate for that image. Access modes and caching are covered in [Access and links](../guides/access.mdx).
## Health checks [#health-checks]
Health decides whether a channel takes uploads and reads. There are two kinds of check:
| | Light check | Deep check |
|---|---|---|
| What it does | Stats an object that never exists, `.imgyoung-health-probe` (never written); either a result or "not found" counts as reachable | Writes a small object under a random key `.imgyoung-probe-`, reads it back and compares, then deletes it |
| Cost | Read-only, no writes or deletes | One write, read and delete |
| Timeout | 10 s | 20 s for the write and read, plus 10 s for the delete |
| When | Periodic loop every `health_check_seconds` (default 60 s) | After creating a channel, after saving an edit, on **Probe again**, on **Test connection**; and in the periodic loop when an unhealthy channel passes its light check |
The periodic loop follows one rule: **a light check can only mark a channel down, never up.**
- Healthy channels get only a light check each round; a failure marks them unhealthy.
- Unhealthy channels get a light check first; if it passes, a deep check follows, and only a full write–read–delete pass restores them.
- A read-only success never overrides a failed deep check. Read-only credentials, for example, pass the light check but cannot write; such a channel is not revived by light checks.
The periodic loop uses only the read-only light check because, on pay-per-request storage, writing and deleting a probe object every minute is a pointless expense; problems on the write path are caught by the event-driven deep checks and the confirmation before recovery.
Channels are checked in parallel with independent timeouts, so one slow channel never drags the others down. Draining channels are not checked. A round in progress at shutdown is discarded rather than recorded.
Results are written back to the database and shown in the **Status** column of the Storage channels page: **Healthy**, **Unhealthy** or **Draining**. Hovering **Unhealthy** shows the failure reason (translated into a troubleshooting hint, never containing credentials); the small text below shows when the last check ran, or **Not probed yet**. Common reasons are listed under "Common errors" in [Object storage and WebDAV](./object-storage.mdx).
If a channel's configuration cannot be decrypted or its backend fails to initialize, that channel is skipped (log line `渠道装载失败,本渠道跳过,健康检查时重试`) and the rest keep serving. The list shows it as **Unhealthy**, with a reason starting with `装载失败:` ("load failed:"). Every health check round retries loading it first, and **Probe again** also tries to load it first; once loaded, it still has to pass a deep check before it is healthy and takes part in routing. If no channel at all can be loaded at startup, startup fails.
## Replica watermark [#replica-watermark]
The **Replicas** column shows the channel's replica counts: ready, pending (including in progress) and failed. A pending count that never drops, or any failures, means writes to that channel are failing: check the reason in the Status column and run **Probe again**. Once the channel is healthy, failed replicas are retried at the next periodic reconciliation.
## Test connection [#test-connection]
**Test connection** at the bottom of the create and edit forms runs a deep check with the current form contents, **without saving or creating anything**:
- Success shows "Connection OK (write, read and delete probes passed)"; failure shows "Connection failed:" followed by the reason, translated the same way and with the credentials you typed scrubbed out.
- It never creates a bucket and never applies encryption: it verifies connectivity and write, read and delete permissions on the backend.
- When editing, masked credentials are restored from the stored values before the check runs; if a stored token gets refreshed during the test, the new token is written straight back to the channel.
- If the provider refreshes a token submitted in this form during the test (which can happen even when the test fails), the new token is handed back to the form: below the result the form notes "The provider refreshed the authorization token during the test. The form now holds the new token and will save it; the old one may no longer work.", the authorization field shows **Authorized**, and saving submits the new token. See [Cloud drives and OAuth](./cloud-drives.mdx).
## Creating a channel [#creating-a-channel]
On the Storage channels page click **New channel**, choose a storage type, then fill in the form:
Choose a type: **Object storage** and **Cloud drives** hold the built-in templates, **Advanced** holds **Custom** (an rclone backend name plus raw parameters). Template fields are described in [Object storage and WebDAV](./object-storage.mdx) and [Cloud drives and OAuth](./cloud-drives.mdx).
Fill in the channel name, the template fields and the priority, and choose **Server-side encryption**; object storage also offers **Allow 302 direct links** and **Create the bucket automatically**.
Click **Test connection**, and once it passes, **Create**.
Creation first builds the backend once from the parameters (templates that need authorization first fill in what the authorization implies, such as the OneDrive drive) and rejects invalid ones outright; after the channel is stored, a deep check runs immediately. A failed deep check does not undo the creation: the channel stays, marked **Unhealthy**, with the notice "Channel created, but the probe failed:" plus the reason. Fix the configuration and click **Probe again**.
## Editing a channel [#editing-a-channel]
**Edit** in the row menu opens the same form as creation. You can change the channel name, the template fields (or custom parameters), the priority and **Allow 302 direct links**.
- **Fixed**: server-side encryption (the form only shows **Encrypted** or **Not encrypted**; the API rejects changes with 409); the storage type (an edit keeps the template the channel was created with; custom channels and the built-in local channel open in the custom form); **Create the bucket automatically** only exists at creation.
- **Masked credentials**: credentials are never sent back to the form; they are replaced with the fixed placeholder `__imgyoung_secret_keep__`. What gets masked follows the rclone backend's option metadata: every parameter rclone flags as a password or as sensitive. Besides passwords, keys and authorization tokens, that includes access key IDs (Access Key, SecretId, Key ID), the WebDAV user name, the app client ID, OneDrive's `drive_id` and so on, in template forms and custom parameters alike; these fields are shown as masked inputs. Submitting the placeholder unchanged keeps the stored value and typing a new value replaces it; putting the placeholder on a parameter that is not a credential is rejected (400).
- The configuration is replaced as a whole, not merged. Saving rebuilds only this channel's driver, so in-flight reads and writes on other channels are unaffected; a deep check follows immediately, and a failure shows "Channel saved, but the probe failed:" plus the reason.
- **Bind time reset**: any change to the backend name or to any configuration parameter (the public URL included) resets the channel's bind time to the moment of saving. Re-entering the same password counts as a change, because rclone encodes passwords with a random IV. Changing only the name, the priority or **Allow 302 direct links** does not reset it, and neither do token refresh write-backs. After a reset, objects in this service's key format that predate it at the new location are only counted, never removed; see "Reconciliation GC" in [Metadata backups](../operations/backup.mdx).
Editing never triggers a backfill. After changing the bucket, prefix or endpoint, the channel's existing replicas are still recorded as ready; reads from it fail and fall through to other channels until the daily reconciliation notices the missing objects and replicates them again. To move storage, create a new channel, wait until its pending replica count reaches zero, then delete the old one.
## Deleting a channel [#deleting-a-channel]
**Delete** in the row menu opens a confirmation dialog; the checkbox "Also delete what this service stored on the channel" picks the path:
| | Delete and purge (checked, default) | Remove only (unchecked) |
|---|---|---|
| Immediately | The channel turns **Draining** and leaves upload routing, replication and health checks | The channel and its replica records are deleted |
| Remote objects | Objects this service wrote (image replicas, metadata snapshots and so on) are deleted one by one by a background drain job, then the channel is removed; files other programs wrote are left alone | Image replicas and metadata snapshots are left as they are; clean them up at the provider if needed |
| Use it when | Retiring a channel normally | Credentials are revoked, the bucket is gone, or draining is stuck |
Both paths are protected against deleting **the only replica**: if any image has no ready replica on another channel (draining channels do not count), the delete is refused with 409 and `该渠道持有部分图片的唯一副本,删除会丢数据;请先迁移或删除相关图片` ("this channel holds the only replica of some images; deleting it would lose data"). The check and the delete run in one transaction, so deleting two channels one after the other can never remove every copy of an image. Replicas still pending on other channels do not count; wait until they are ready.
Draining in detail:
- The drain job first deletes image objects batch by batch according to the replica records and removes each record as it goes. Progress is kept in the database, so an interrupted drain resumes where it stopped.
- Once the image replicas are gone, it deletes everything under `_imgyoung/` (including the metadata snapshots under `_imgyoung/backup/`) and the deep-check leftovers `.imgyoung-probe-*` in the root, and only then removes the channel record. Files other programs wrote are left alone. The completion log `渠道已清理完毕并删除` carries the fields `objects` (image replicas) and `system_objects` (system objects).
- One drain run may last up to an hour; after a timeout the next run continues with what is left.
- As soon as deleting an object fails, the reason is written to the Status column (starting with `清理渠道对象失败,会自动重试`, "draining failed, retrying automatically") and a **Channel drain failed** alert opens. Draining keeps retrying with backoff and never gives up, so it finishes on its own once the channel is reachable again. A drain run's own timeout or a shutdown does not count as a failure.
- If the credentials are revoked or the bucket is gone, use **Remove now**, the only entry in a draining channel's row menu: it stops draining and removes the channel at once, leaving any remaining remote objects untouched.
## Exporting the rclone config [#exporting-the-rclone-config]
**Export rclone config** in the row menu (not offered for draining channels) downloads the channel's rclone config file `imgyoung-channel-.rclone.conf`. With it, a stock rclone can read and write this channel's data without ImgYoung:
- Remote `imgyoung-`: the channel's backend, with exactly the parameters ImgYoung hands to the backend, access credentials and OAuth tokens included.
- Remote `imgyoung--crypt`: encrypted channels only; a crypt layer pointing at the channel's storage path, so reads and writes through it are plaintext.
- Comments at the top of the file describe each remote and show example commands.
The file holds the channel's credentials (and, for encrypted channels, the decryption passwords); the dialog warns "Keep it offline and safe, just like the master key: anyone who has it can read and write all of this channel's data." Every download is recorded in the audit log as "Export channel rclone config". How to use the file, and what to watch out for, is under "Self-rescue" in [Channel encryption](./encryption.mdx).
## Usage monitoring and alerts [#usage-monitoring-and-alerts]
Channel usage is the total size of the images whose replicas on that channel are ready. It is a logical figure: it excludes metadata snapshots and orphans, and it is not your provider's bill.
- It is refreshed every `usage_report_seconds` (default 300 s) into the metric `imgyoung_channel_storage_bytes{channel="…"}`; the **Storage channels** card on the **Dashboard** shows bytes per channel and each channel's share.
- The alert threshold is **Settings → Runtime settings → Reliability & channels → Channel usage alert threshold** (`channel_quota_gb`, in GB of 2³⁰ bytes); 0 reports without alerting. Exceeding it opens a **Channel over quota** alert.
Four alert kinds concern channels. They are evaluated on the health check cadence, recorded under **Settings → Alert history**, and delivered to the alert webhook when one is configured:
| Alert | Opens when | Resolves when |
|---|---|---|
| Channel unhealthy | A channel in service is marked unhealthy | It passes its health check again |
| Replication failed | The channel has `failed` replicas | It has none left (on a healthy channel, the periodic reconciliation resets them for another try) |
| Channel over quota | Usage exceeds the threshold | Usage drops or the threshold is raised |
| Channel drain failed | Deleting an object from a draining channel fails | The channel is removed (drained or removed now) |
Other relevant metrics: `imgyoung_channels_healthy_total` (healthy channels), `imgyoung_channels_total` (loaded channels that are not draining, unhealthy ones included) and `imgyoung_channel_puts_total{channel, result}` (object writes to a channel, `result` being `ok` or `error`, counting both upload writes and replication). Scraping and alert delivery are covered in [Monitoring and alerts](../operations/monitoring.mdx).
## Next [#next]
# Object storage and WebDAV (/en/docs/channels/object-storage)
What each built-in object storage template asks for, why buckets must exist beforehand, public URLs and 302 direct links, custom mode and generic WebDAV, and how to read probe errors.
## Choosing a storage type [#choosing-a-storage-type]
After **New channel** on the Storage channels page, you first pick a type:
- **Object storage**: **Amazon S3**, **Cloudflare R2**, **Tencent Cloud COS**, **Alibaba Cloud OSS**, **Backblaze B2**, **MinIO / self-hosted** and **S3-compatible (generic)**. All of them use rclone's `s3` backend and differ only in the preset `provider`, endpoint and region.
- **Cloud drives**: WebDAV (later on this page) and the drives that need authorization (see [Cloud drives and OAuth](./cloud-drives.mdx)).
- **Advanced**: **Custom**, where you enter an rclone backend name and raw parameters.
Templates expose only the fields that matter and preset the rest. Saving merges them into the full backend parameters; editing reverses the merge to refill the form, including fields that were folded into the endpoint (account ID, region).
Template names, field labels and help texts follow the admin console language. Error messages come from the server and are always in Chinese; the tables below quote them as they appear, with a translation.
## Object storage templates [#object-storage-templates]
| Template | Fields to fill | Set by the template |
|---|---|---|
| Amazon S3 | Access key ID, Secret access key, Region (list), Bucket | `provider=AWS`; the endpoint follows from the region |
| Cloudflare R2 | Access key ID, Secret access key, Account ID, Bucket | `provider=Cloudflare`, `region=auto`, `endpoint=https://.r2.cloudflarestorage.com` |
| Tencent Cloud COS | SecretId, SecretKey, Region (list), Bucket | `provider=TencentCOS`, `endpoint=cos..myqcloud.com` |
| Alibaba Cloud OSS | Access key ID, Secret access key, Region (list), Bucket | `provider=Alibaba`, `endpoint=.aliyuncs.com` |
| Backblaze B2 | Key ID, Application key, Region (typed), Bucket | `provider=Other`, `endpoint=https://s3..backblazeb2.com` |
| MinIO / self-hosted | Access key ID, Secret access key, Endpoint, Bucket | `provider=Minio` |
| S3-compatible (generic) | Access key ID, Secret access key, S3 API endpoint, Bucket | `provider=Other` |
Every object storage template also has an optional **Public URL**, described below. The fields:
| Key | Label | Meaning |
|---|---|---|
| `access_key_id`, `secret_access_key` | Access key ID / Secret access key (COS: SecretId / SecretKey; B2: Key ID / Application key) | Credentials |
| `region` | Region | Region. Lists show the region name followed by its code, such as US East (Ohio) `us-east-2`. AWS, COS and OSS offer a list of common regions; Alibaba values carry the `oss-` prefix (such as `oss-cn-hangzhou`). If your region is missing, use the S3-compatible template with an explicit endpoint. B2 takes free text (such as `us-west-004`) matching the endpoint shown in the B2 console |
| `account_id` | Account ID | Cloudflare account ID, used to build the R2 endpoint |
| `endpoint` | Endpoint / S3 API endpoint | Full URL, such as `https://minio.example.com` |
| `bucket` | Bucket | Bucket name, optionally followed by `/prefix` to confine the channel to a folder: `my-bucket` or `my-bucket/imgyoung`. This value is the channel's storage path. COS bucket names must include the APPID suffix, such as `mybucket-1250000000` |
| `public_base_url` | Public URL | Optional public URL prefix, see below |
## Create the bucket first [#create-the-bucket-first]
ImgYoung adds `no_check_bucket=true` to every `s3` channel, so rclone neither checks for nor creates the bucket before uploading.
The reason: by default rclone attempts to create the bucket before uploading. Scoped credentials, common with R2 and MinIO, can read and write one bucket but not create buckets; when the bucket already exists that attempt returns 403 and the write fails, while the read-only light check keeps passing. The channel looks healthy yet uploads fail. Buckets are expected to exist already, so the attempt is skipped by default.
So:
- **Create the bucket in your provider's console first**, then create the channel.
- If your credentials can create buckets and you want ImgYoung to do it, turn on **Create the bucket automatically** in the create form. ImgYoung then tries to create the bucket once (an existing bucket is fine); if that fails the whole creation is aborted with `自动创建存储桶失败(请确认密钥具备建桶权限,或先在控制台建好桶并关闭此项): ` plus the cause ("creating the bucket failed; check the key can create buckets, or create it in the console and turn this off"). The switch only exists at creation, and **Test connection** never creates a bucket.
- In custom mode, setting `no_check_bucket=false` explicitly restores rclone's check-and-create-if-missing before uploads.
## Public URL and 302 direct links [#public-url-and-302-direct-links]
**Public URL** (`public_base_url`) is optional: the URL prefix under which the bucket (or a CDN in front of it) serves objects publicly, such as `https://cdn.example.com`. A channel can only issue 302 direct links once it is set.
A direct link is `/`, and the object key is relative to the channel's storage path. If the **Bucket** field includes a prefix, the public URL must point at that prefix too: with `my-bucket/imgyoung` in **Bucket** and a CDN serving the whole bucket, the public URL is `https://cdn.example.com/imgyoung`. A trailing `/` is stripped.
An image is redirected only when all of these hold:
1. **Settings → Runtime settings → Access & links → Access mode** is `redirect · 302 to direct links`;
2. the channel has **Allow 302 direct links** on and is not encrypted;
3. the channel has a public URL;
4. the channel is healthy, holds a ready replica of the image, and is the highest-priority channel meeting the conditions above.
Otherwise the image is proxied. With **Allow 302 direct links** on but no public URL, the form warns "public_base_url is not set, so direct links will not take effect and access falls back to proxying".
The 302 response carries `Cache-Control: no-store`, so neither browsers nor CDNs cache the redirect, and switching channels, retiring one or an image expiring takes effect immediately. Objects are written with a `Content-Type` sniffed from their content, so even though object keys have no extension, a browser opening a direct link displays the image.
Public read access on the bucket has to be enabled at the provider; ImgYoung does not check it. Public read also means anyone holding a direct link can fetch the object without going through ImgYoung, so hotlink protection only applies to requests that do. See [Access and links](../guides/access.mdx) for the full picture of access modes.
## Custom mode [#custom-mode]
**Advanced → Custom** takes an rclone backend name and its parameters directly. Use it for backends the templates do not cover — any rclone backend works, such as `sftp`, `ftp`, `azureblob`, or `local` on another disk.
- **rclone backend**: the backend name, i.e. the `type` value in `rclone.conf`, such as `s3`, `sftp` or `local`.
- **Backend parameters**: key–value pairs whose keys are the backend's option names, written as in `rclone.conf`; only letters, digits and underscores are allowed. Copying from the output of `rclone config show` works (put the `type` line into **rclone backend**).
- Two reserved keys are not passed to rclone: `path` is the storage path inside the backend (bucket and prefix, or a folder), and `public_base_url` is the public URL. A `local` channel without `path` uses `/blobs`.
rclone stores password-type options (options rclone flags as passwords, such as `pass` for `sftp`, `ftp` and `webdav`) in an encoded form. ImgYoung handles this: plain values are encoded, and values that already are encoded (for example copied from `rclone.conf`) are kept as they are. In rare cases a plain password happens to look like an encoded value and is passed through unencoded, which shows up as an authentication failure; run `rclone obscure ` and enter its output instead.
```text title="Example: an SFTP channel"
rclone backend sftp
Backend parameters host = files.example.com
user = imgyoung
pass =
path = /srv/imgyoung
```
## WebDAV [#webdav]
The generic WebDAV template sits in the **Cloud drives** group of the type picker and covers Nutstore, Nextcloud / ownCloud, Synology, Alist and the like. They all use rclone's `webdav` backend and differ only in the server URL and the server type, so there is one template for all of them.
| Key | Label | Meaning |
|---|---|---|
| `url` | Server URL | WebDAV root URL. Nutstore `https://dav.jianguoyun.com/dav/`; Nextcloud / ownCloud `https://domain/remote.php/dav/files/username/`; Alist `https://domain/dav/`; Synology `https://domain:5006/` |
| `vendor` | Server type | A wrong choice breaks folder listing or chunked uploads; if unsure, choose **Other (Nutstore, Alist, Synology, …)** |
| `user` | Username | WebDAV user name |
| `pass` | Password | Nutstore requires an **app password** generated in its security settings, not the login password |
| `path` | Folder | Folder for the images, `imgyoung` suggested; empty means the root |
Server type options and the rclone `vendor` value each maps to:
| Option | `vendor` |
|---|---|
| Nextcloud | `nextcloud` |
| ownCloud 10 | `owncloud` |
| ownCloud Infinite Scale | `infinitescale` |
| SharePoint Online | `sharepoint` |
| SharePoint (self-hosted, NTLM) | `sharepoint-ntlm` |
| Fastmail Files | `fastmail` |
| rclone serve webdav | `rclone` |
| Other (Nutstore, Alist, Synology, …) | `other` |
WebDAV channels have no public URL, and the form hides **Allow 302 direct links** and **Create the bucket automatically**; reads are always proxied. Chinese cloud drives such as Aliyun Drive and Baidu Netdisk are also connected over WebDAV, see [Cloud drives and OAuth](./cloud-drives.mdx).
## Common errors [#common-errors]
Failure reasons from **Test connection**, and the reason shown when hovering **Unhealthy** in the Status column, are translated from the underlying error into the hints below (case-insensitive, first match from the top). An error that matches nothing is shown as its first 200 characters; **Test connection** additionally scrubs credentials from it (the values of parameters rclone flags as passwords or sensitive).
| Hint shown | Underlying error contains | What to check |
|---|---|---|
| `认证失败(403):检查 Access Key / Secret Key 及其权限` — authentication failed (403) | `InvalidAccessKeyId`, `SignatureDoesNotMatch`, `StatusCode: 403` (S3-type backends only) | Mistyped or revoked keys, or missing read, write or delete permission on the bucket. Read-only keys fail at the write step of the deep check |
| `应用凭证无效:检查 Client ID / Client Secret 是否与签发令牌的应用一致` — invalid app credentials | `invalid_client`, `unauthorized_client` | A drive channel's app client ID or secret is wrong, or belongs to a different app than the one that issued the token |
| `授权已失效:刷新令牌被拒绝,请重新授权` — authorization expired | `invalid_grant` | The token was revoked or has lapsed; use **Authorize again** in the edit form |
| `认证失败(401):检查用户名、密码或授权令牌是否有效` — authentication failed (401) | `401 Unauthorized`, `unauthenticated`, `InvalidAuthenticationToken` | The WebDAV user name or password (Nutstore needs an app password), or a drive's authorization token |
| `无访问权限(403):检查账号对存储位置的权限` — access denied (403) | `AccessDenied`, `access denied`, `403 Forbidden` | On non-S3 backends, the account lacks read, write or delete permission on the folder |
| `存储桶不存在或无权访问:检查桶名与凭证权限` — bucket missing or inaccessible | `NoSuchBucket`, `no such bucket`, `BucketRegionError` | The bucket name (COS needs the APPID suffix); whether the bucket exists |
| `区域不匹配:检查所选区域与存储桶实际所在区域` — region mismatch | `PermanentRedirect`, `AuthorizationHeaderMalformed` | The region or endpoint does not match the bucket's actual region |
| `无法解析服务端点域名:检查 endpoint 配置` — cannot resolve the endpoint | `no such host`, `server misbehaving` | A typo in the endpoint, or DNS on the server |
| `无法连接服务端点:检查 endpoint 地址与端口` — cannot connect | `connection refused`, `connection reset` | Port, scheme or firewall |
| `TLS 证书校验失败:检查端点证书或改用正确协议` — TLS verification failed | `certificate`, `x509`, `tls handshake` | A self-signed certificate, or `https` used for an `http` service |
| `连接超时:检查端点地址与网络连通性` — timed out | Timeouts, including the check's own 10 s / 20 s limits | Network reachability or a very slow service |
| `存储路径无写入权限:检查目录权限` — no write permission | `permission denied`, `read-only file system` | Folder permissions of a `local` channel; in the container image the process runs as uid 65532, so mounted folders must be writable by it |
Common rejections when saving the form (returned immediately; no channel is created):
| Message | Cause |
|---|---|
| `渠道参数非法: 字段 必填` | A required template field is empty; the field is named by its key, such as `渠道参数非法: 字段 bucket 必填` |
| `渠道名已存在` | The channel name is taken (409) |
| `渠道参数非法: 初始化 rclone 后端 "…": …` | Building the backend from these parameters failed; the rclone message after the colon gives the cause |
| `渠道参数非法: 未知的 rclone 后端 "…"` | A misspelt backend name in custom mode |
| `渠道参数非法: 配置键 "…" 非法(仅允许字母数字下划线)` | A custom parameter key contains other characters (such as `-`) |
| `渠道参数非法: 自动创建存储桶失败(…)` | **Create the bucket automatically** is on but the key cannot create buckets |
| `渠道参数非法: 后端 "onedrive" 授权后的配置失败: …` | Selecting the drive for a newly authorized OneDrive channel failed when querying the provider |
| `渠道参数非法: 字段 "…" 不是凭证,不能沿用已存值` | The mask value `__imgyoung_secret_keep__` was put on a parameter that is not a credential |
| `渠道参数非法: 字段 "…" 无已存原值可保留,请重新填写` | The mask value was submitted for a parameter that has no stored value |
Once a channel exists but shows **Unhealthy**, fix its configuration and use **Probe again** in the row menu: it runs a deep check right away and refreshes the status. The probe and status rules are in [Channels and failover](./index.mdx).
# Cloud drives and OAuth (/en/docs/channels/cloud-drives)
Which drives need authorization, the two ways to authorize, how tokens are refreshed and stored, how Chinese cloud drives connect over WebDAV through a self-hosted aggregator, and the limits every drive channel shares.
## Supported drives [#supported-drives]
In the type picker of **New channel**, the **Cloud drives** group holds the generic [WebDAV](./object-storage.mdx) template plus six templates that need OAuth authorization:
| Template | rclone backend | Backend-specific field |
|---|---|---|
| OneDrive | `onedrive` | Region |
| Google Drive | `drive` | — |
| Dropbox | `dropbox` | — |
| pCloud | `pcloud` | Data region |
| Box | `box` | — |
| Yandex Disk | `yandex` | — |
After the backend-specific field, all six templates have **App client ID**, **App client secret**, **Authorization** and **Folder**, in that order:
- **Region** (OneDrive): **Global**, **Microsoft Cloud for US Government**, **Microsoft Cloud Germany** or **China (operated by 21Vianet)**. It selects the authorization and API endpoints and must match the account's region.
- **Data region** (pCloud): **United States** or **Europe**. It must match the region chosen when the account was registered, or the token exchange fails.
- **App client ID / App client secret**: the identity and secret of an OAuth app you registered on the provider's developer platform. One-click authorization requires the client ID. When pasting a token, fill in both if the token was obtained with your own app; leave them empty for tokens from rclone's built-in app. Token renewal uses these two values, so they must belong to the app that issued the token. Both are stored encrypted with the channel and shown masked when editing.
- **Authorization**: the token, filled in by one of the two authorization paths below; the form cannot be saved without it.
- **Folder**: the folder on the drive that holds the images, `imgyoung` suggested; empty means the root.
The endpoints, scopes and rclone's built-in app identity needed for authorization all come from the rclone backend's own registration; ImgYoung has no per-provider code.
**OneDrive drives**: when a new authorization comes in, saving or testing the connection runs the rest of rclone's own configuration flow after the authorization step, taking the default answer at every question: it selects the account's default drive and writes `drive_id` and `drive_type`, the same choice `rclone config` makes when you accept every default. If the query to the provider fails, saving returns 400 and Test connection shows the reason. Editing with the stored authorization keeps the existing choice. For a SharePoint document library or another drive, see [Other OneDrive drives](#other-onedrive-drives).
**pCloud tokens**: pCloud tokens never expire (their expiry is the zero time) and carry no refresh token; they save normally.
## Option 1: paste a token [#option-1-paste-a-token]
Get a token with rclone on any computer that has a browser, then paste it into the form. The ImgYoung server needs no public domain, which makes this the only option for deployments without one.
On a computer with a browser, install rclone and run the command shown in the form (Google Drive as an example):
```bash title="Terminal"
rclone authorize "drive"
```
A browser opens the provider's sign-in and consent page; complete it.
The terminal prints something like:
```text
Paste the following into your remote machine --->
{"access_token":"…","token_type":"Bearer","refresh_token":"…","expiry":"…"}
<---End paste
```
Copy everything between the two marker lines.
In the **Authorization** field switch to **Paste a token**, paste it, fill in the remaining fields, run **Test connection** and save.
- Both a plain token JSON and rclone's encoded config blob (what `rclone authorize` prints when given a config blob argument) are accepted; ImgYoung extracts the token.
- A token that expires must include a refresh token, otherwise it cannot be renewed once the access token expires; saving then fails with `渠道参数非法: 缺少刷新令牌,访问令牌过期后无法续期(字段 token)` ("missing refresh token"). Tokens that never expire (such as pCloud's) need none. Content that cannot be parsed fails with `渠道参数非法: 令牌格式非法(字段 token)` ("malformed token").
- A token from `rclone authorize ""` is issued to rclone's built-in app: leave the app client ID and secret empty, and rclone renews it with the built-in app as well.
- To use your own app, run `rclone authorize "" ` (the app must register rclone's local callback URL `http://127.0.0.1:53682/`), then enter the same app's client ID and secret in the form.
## Option 2: one-click authorization in the admin console [#option-2-one-click-authorization-in-the-admin-console]
This runs the whole OAuth authorization-code flow inside the admin console. It requires an app of your own, because OAuth callback URLs must be registered with the provider in advance, and rclone's built-in apps only register a local loopback address that a browser cannot use to reach a remote server.
Set **Settings → Runtime settings → Access & links → Public site address** to ImgYoung's public address, such as `https://img.example.com`. Without it, starting authorization fails with `一键授权需先在设置中填写对外站点地址;或改用粘贴令牌方式` ("set the public site address first, or paste a token").
Create an app on the provider's developer platform and register this callback URL — the public site address plus a fixed path:
```text
https://img.example.com/api/admin/channel-oauth/callback
```
The two must match character for character (scheme, host, port and path).
Enter your app's credentials in the **App client ID** and **App client secret** fields above the **Authorization** field, then click **Authorize with …** under **One-click authorization**. The button stays disabled until a client ID is filled in. A new browser tab opens the provider's consent page.
When it finishes, the callback page reads "授权成功,请回到后台继续。" ("authorized, return to the console"), the **Authorization** field turns **Authorized**, and you can save.
- The callback requires an admin session, so finish the authorization **in the same browser you are signed in to the console with**.
- An authorization session is valid for 10 minutes and the form waits for at most about 11 minutes; after that it shows "Timed out waiting for authorization, please start again".
- The token is never written into the callback page; the form collects it with a one-time identifier, and it can be collected only once.
- A failed token exchange shows `换取令牌失败,请核对应用凭证与回调地址` ("token exchange failed; check the app credentials and the callback URL") in the form; refusing or cancelling on the provider's page shows `授权被拒绝或取消:` plus the reason the provider returned.
- When re-authorizing an existing channel, leaving the two app credential fields masked keeps the stored values.
- The client ID and secret are stored with the channel, so rclone renews the token with the same app once it expires.
## Token renewal and persistence [#token-renewal-and-persistence]
- Backend parameters are handed to rclone as a map with a write-back hook. When the access token nears expiry, rclone refreshes it itself and writes the new token back through the hook; ImgYoung immediately encrypts it with the master key and stores it in the channel configuration. Any other parameter rclone writes back at runtime takes the same path.
- If storing fails, only a log line is written (`后端写回参数无法落库`) and the new token stays in use in memory; after a restart the old token from the database is used again.
- If a backend built temporarily while saving a channel refreshes the token, the new token is stored with the configuration.
- When a token gets refreshed during **Test connection**, there are two cases:
- A stored token being kept (the **Authorization** field left at **Authorized · using the stored token** while editing) is written straight back to the channel.
- A token submitted in this form (refreshed even if the test fails) is handed back to the form: below the result the form notes "The provider refreshed the authorization token during the test. The form now holds the new token and will save it; the old one may no longer work.", the **Authorization** field shows **Authorized**, and saving submits the new token. In custom mode the new token goes into the `token` row of the backend parameters.
- **There is exactly one refresher.** ImgYoung runs no refresh loop of its own: some providers invalidate the old refresh token on every refresh, so two refreshers would keep invalidating each other. The read-only requests of the periodic light check (every 60 s by default) are enough for rclone to refresh on time.
- When editing a drive channel, the **Authorization** field shows **Authorized · using the stored token**; leave it to keep the token, or click **Authorize again** to replace it.
- An [exported rclone config file](./encryption.mdx) carries the token too. Do not use that file while ImgYoung is still using the channel: with providers such as Box, whose refresh tokens are single-use, the two would keep invalidating each other's tokens. Once ImgYoung refreshes the token, the copy in the file is stale and you need to export again.
When creating or editing a channel and the save ultimately fails (invalid parameters, the backend failing to build, the OneDrive drive lookup failing, a duplicate channel name and so on), a token the provider refreshed during the save is neither stored nor handed back to the form. With providers whose refresh tokens are single-use, such as Box, the original token is then dead and you have to authorize again. Running **Test connection** before saving lowers the risk: a token refreshed during the test is handed back to the form, so the save already uses the new one.
## Other OneDrive drives [#other-onedrive-drives]
The template only ever selects the account's default drive. For a SharePoint document library or another drive, enter `drive_id` and `drive_type` yourself under **New channel → Advanced → Custom**.
On a computer with a browser, create a OneDrive remote interactively with `rclone config`, pick the target drive when it asks, then show the parameters it wrote:
```bash title="Terminal"
rclone config
rclone config show onedrive-tmp
```
Copy the values into the custom form:
| Key | Value |
|---|---|
| rclone backend | `onedrive` |
| `token` | `token` from the `rclone config show` output |
| `drive_id` | Same source |
| `drive_type` | Same source (`personal`, `business` or `documentLibrary`) |
| `region` | Only outside the global cloud: `us`, `de` or `cn` |
| `client_id`, `client_secret` | Only if you used your own app in rclone |
| `path` | Folder for the images, such as `imgyoung` |
Custom mode stores the token as given; to re-authorize, replace the value of `token`. After saving, delete the temporary remote on your computer so that two places do not refresh the same token:
```bash title="Terminal"
rclone config delete onedrive-tmp
```
## Chinese cloud drives: WebDAV through a self-hosted aggregator [#chinese-cloud-drives-webdav-through-a-self-hosted-aggregator]
rclone has no backends for Aliyun Drive, Baidu Netdisk, 115, Quark and similar Chinese cloud drives. The open-source aggregators that do support them (Alist and the like) are licensed AGPL-3.0: porting their code into ImgYoung would force the whole project onto the AGPL, and would leave ImgYoung chasing these drives' API and anti-abuse changes indefinitely.
So ImgYoung does not port them. You deploy such an aggregator yourself and have it serve WebDAV, and ImgYoung connects with the generic WebDAV template. The two are separate processes that only talk over a network protocol, so the license does not carry over, and maintaining the drive integrations stays upstream.
Deploy the aggregator following its own documentation, mount the drive you want, and enable WebDAV.
Create a dedicated account for ImgYoung with read, write and delete permissions. Deep checks, the daily reconciliation and channel draining all delete objects; without delete permission a channel cannot pass a deep check.
Create a WebDAV channel in ImgYoung: set **Server URL** to the aggregator's WebDAV address (for Alist, `https://domain/dav/`), choose **Other (Nutstore, Alist, Synology, …)** as **Server type**, enter the account and password, and set **Folder** to a dedicated folder under the drive's mount path in the aggregator.
Run **Test connection** to confirm write, read and delete all pass, give the channel a priority below your object storage channels, and save.
## Limits of drive channels [#limits-of-drive-channels]
- **Always proxied**: drives rarely offer stable public direct links, so drive templates have no public URL, and the form hides **Allow 302 direct links** and **Create the bucket automatically**. Even in `redirect` access mode, images on a drive are served through ImgYoung.
- **Rate and traffic limits**: drives limit request rates and traffic far more tightly than object storage, so heavy image traffic easily triggers throttling or anti-abuse measures. The periodic light check sends one read-only request per channel per round.
- **Full mirror**: like any channel, a drive channel ends up storing every image in the library.
That makes drives best suited as **low-priority backup channels**: give them a priority below your object storage channels so that normally they only receive asynchronous replication, and reads move to them only when the channels above are down. This trade-off is expressed entirely through priority, see [Channels and failover](./index.mdx).
# Channel encryption (/en/docs/channels/encryption)
Store only ciphertext at your provider with rclone crypt — what it protects against, how to turn it on, why it is fixed at creation, where the keys come from, and how to decrypt a channel with stock rclone without ImgYoung.
## What it protects against [#what-it-protects-against]
Channel encryption is aimed at the **storage provider**. Free or third-party storage may scan or review what you store, sometimes closing accounts over false positives. With encryption on, both object contents and object names on the channel are ciphertext.
It does **not** protect against the server itself. ImgYoung holds the master key, and format checks, metadata stripping, hashing, deduplication and on-demand processing all run on plaintext before encryption, so none of them are affected; anyone with the server and the master key can read everything.
The provider can still see the number of objects, their sizes (the plaintext size can be worked out from the ciphertext size to within 16 bytes), modification times and access patterns.
## How it works [#how-it-works]
An encrypted channel wraps its backend in rclone's `crypt` backend:
- **Contents** are split into 64 KiB blocks and encrypted with XSalsa20-Poly1305, which also authenticates them.
- **Object names** use the `standard` mode: each path segment is encrypted with AES-256 in EME mode and written as lower-case base32; folder names are encrypted too. A given name always encrypts to the same ciphertext, which is what makes lookups by name possible.
- **Key material**: rclone derives the actual keys with scrypt from a password and a salt. ImgYoung derives a separate password and salt for every channel, see [Where the keys come from](#where-the-keys-come-from).
Uploads, proxied reads, replication and health checks all pass through this layer transparently. Everything on the channel is encrypted: images, deep-check temporary objects, and the metadata snapshots (`_imgyoung/backup/`) that each backup round writes to the channel.
## Turning it on [#turning-it-on]
There are two levels, and the channel level wins:
| Where | Values | Default |
|---|---|---|
| **Settings → Runtime settings → Access & links → Encrypt new channels by default** (`encrypt_new_channels`) | On / off | Off |
| **Server-side encryption** in the new-channel form | **Inherit the global default**, **Force on**, **Force off** | Inherit the global default |
"Inherit the global default" is resolved at the moment of creation; changing the global setting later does not affect existing channels. Through the API, omitting the `encrypted` field means inherit.
Encrypted channels carry an **Encrypted** badge in the list; the edit form only shows **Encrypted** or **Not encrypted**.
## Fixed at creation [#fixed-at-creation]
The encryption attribute is set at creation and cannot change afterwards. The API rejects changes with 409 and `加密属性创建后不可变(变更会造成密文/明文混存且不可恢复);如需调整请新建渠道并迁移数据` ("encryption cannot change after creation; create a new channel and migrate").
The reason: after flipping the switch, one channel would hold objects written under the old setting next to objects written under the new one, and the old objects could not be read back under the new setting.
To change encryption, let replication do the migration:
Create a new channel with the encryption setting you want. It backfills a replica of every existing image on its own.
Wait until the new channel's **Replicas** column shows zero pending and no failures.
Delete the old channel. Every image now has a ready replica on the new channel, so the only-replica protection does not block it.
## No 302 direct links on encrypted channels [#no-302-direct-links-on-encrypted-channels]
Ciphertext cannot be handed to a browser, so encryption and **Allow 302 direct links** exclude each other:
- In the new-channel form, choosing **Force on** disables **Allow 302 direct links**; with direct links on, **Force on** cannot be selected.
- The same applies when you choose **Inherit the global default** while the global default is on.
- The API rejects a request asking for both with 400 and `渠道参数非法: 加密渠道不允许直链(密文无法直出)` ("invalid channel parameters: encrypted channels cannot serve direct links").
- With `access_mode = redirect`, images on encrypted channels are always proxied.
## Where the keys come from [#where-the-keys-come-from]
Each encrypted channel's password and salt are derived from the **master key** and never stored on their own:
| Item | Value |
|---|---|
| Algorithm | HKDF-SHA256, empty salt (equivalent to 32 zero bytes), 32 bytes of output |
| Input key | The 32 bytes of the master key |
| Password info | `channel:::crypt:password` |
| Salt info | `channel:::crypt:salt` |
| Given to rclone | The lower-case hex string of the 32-byte result (64 characters), as crypt's `password` and `password2` respectively |
- The **channel ID** and **created at** come from the `id` and `created_at` columns of the `channels` table in the metadata database. `created_at` is UTC text written by SQLite, such as `2026-10-15 08:00:00`, and is used verbatim.
- The creation time is part of the input because SQLite reuses IDs of deleted channels; binding to the ID alone would let a new channel derive the same keys as a deleted one.
- Keys of different channels are independent; leaking one channel's keys does not expose the others.
The **master key** comes from the environment variable `IMGYOUNG_MASTER_KEY` (64 hex characters) or, when that is unset or empty, from `/master.key`. It encrypts the channel configurations as well as deriving the channel keys. It is not part of the data export or of metadata snapshots.
The first start with a given master key records a fingerprint derived from it in the metadata database (the key cannot be recovered from it), and every later start checks it:
- `master.key` is generated (with permissions 0600) only when a brand-new metadata database finds no master key.
- Once a fingerprint is recorded, a missing master key (no environment variable and no `master.key`) or one that does not match makes startup fail with an explanation; no new key is generated.
- When restoring a snapshot from another instance, switch to that instance's master key as well.
Without the master key no encrypted channel's password can be derived, so the ciphertext stays unreadable; the stored channel configurations cannot be decrypted either, and ImgYoung refuses to start. Keep a copy of the master key somewhere separate from the server. Configuration is covered in the [configuration reference](../configuration.mdx), backup strategy in [Metadata backups](../operations/backup.mdx).
## Self-rescue: reading a channel with stock rclone [#self-rescue-reading-a-channel-with-stock-rclone]
crypt is a published rclone format. Even with ImgYoung gone, stock rclone can read every object on an encrypted channel. The easy way is to export each channel's rclone config file in advance; without an exported file you can still derive everything by hand from the metadata database and the master key.
Each backup round writes the metadata snapshot to every healthy channel, and the copy on an encrypted channel is ciphertext as well. As long as one unencrypted channel holds a snapshot, or you have kept the encrypted channels' exported files offline, you can get a snapshot back after losing the host. If every channel is encrypted and there is no exported file, deriving by hand needs the channel's ID and creation time — and those are recorded inside the snapshot. In such a deployment, export a config file for every encrypted channel and keep it offline with the master key.
### With an exported config file [#with-an-exported-config-file]
On the Storage channels page choose **Export rclone config** in the row menu, then **Download config file**, to get `imgyoung-channel-.rclone.conf`. There is one file per channel; export again after changing a channel's configuration.
Keep it offline together with the master key. The file alone is enough to read and write all of this channel's data (an encrypted channel's decryption passwords are in it); the master key is what you need to restore ImgYoung itself.
On any machine with rclone, run the commands from the comments at the top of the file. For an encrypted channel read `imgyoung--crypt:`, which yields plaintext:
```bash title="Terminal"
rclone --config imgyoung-channel-3.rclone.conf ls imgyoung-3-crypt:
rclone --config imgyoung-channel-3.rclone.conf copy imgyoung-3-crypt: ./imgyoung-restore
```
For an unencrypted channel read the backend remote plus the storage path, such as `imgyoung-3:my-bucket/imgyoung`; the comments at the top of the file give that path.
The file exported for an encrypted R2 channel looks like this (the comments at the top are always in Chinese):
```ini title="imgyoung-channel-3.rclone.conf"
# ImgYoung 渠道 "r2-backup"(ID 3,创建于 2026-10-15 08:00:00 UTC)的 rclone 配置。
# 文件含渠道凭证与解密密码,与主密钥一样离线妥善保存。
#
# [imgyoung-3] 渠道后端(s3),参数与 ImgYoung 所用一致
# [imgyoung-3-crypt] 渠道的加密层,指向渠道存储路径 imgyoung-3:my-bucket/imgyoung,经它读写的是明文
#
# 用法:
# rclone --config imgyoung-channel-3.rclone.conf ls imgyoung-3-crypt:
# rclone --config imgyoung-channel-3.rclone.conf copy imgyoung-3-crypt: ./imgyoung-restore
[imgyoung-3]
type = s3
access_key_id =
endpoint = https://.r2.cloudflarestorage.com
no_check_bucket = true
provider = Cloudflare
region = auto
secret_access_key =
[imgyoung-3-crypt]
type = crypt
filename_encryption = standard
password =
password2 =
remote = imgyoung-3:my-bucket/imgyoung
```
- The backend remote has exactly the parameters ImgYoung hands to the backend, credentials and OAuth tokens included; password-type parameters are in rclone's encoded form, which rclone uses as is. s3 channels carry `no_check_bucket = true`.
- For drive channels holding an OAuth token, the comments add two lines: rclone refreshes the token when using this file and writes it back into the file; with providers whose refresh tokens are single-use, such as Box, the file and an ImgYoung still using the channel invalidate each other's tokens, so do not use both at once. Also, once ImgYoung refreshes the token, the copy in the file is stale; with Box and the like its refresh token is invalidated and you need to export again.
- A `local` channel's path is a path on the server (for the built-in local channel `/blobs`, which is relative if the data directory is configured as a relative path); change it to the real location when using the file on another machine.
- If a parameter value cannot be written into an rclone config file (a line break, for example), the export returns 422 and the error names only the remote and the parameter key. Every download is recorded in the audit log as "Export channel rclone config".
### Without an exported file: deriving by hand [#without-an-exported-file-deriving-by-hand]
You need:
- the master key (64 hex characters);
- the channel's ID and creation time;
- the backend credentials and the channel's storage path (bucket and prefix, drive folder, and so on).
#### Get the channel ID and creation time [#get-the-channel-id-and-creation-time]
Every channel in the metadata export JSON and in the channel list API has `id` and `created_at`. If all you have is the metadata database file, query it with `sqlite3`; the database is `/imgyoung.db`, and snapshot files are SQLite databases of the same shape:
```bash title="Terminal"
sqlite3 imgyoung.db "SELECT id, name, created_at FROM channels WHERE encrypted = 1;"
```
```text title="Example output"
3|r2-backup|2026-10-15 08:00:00
```
#### Compute the password and salt [#compute-the-password-and-salt]
This script uses only the Python standard library to compute the password and salt with HKDF-SHA256:
```python title="derive.py"
import hashlib, hmac, sys
master = bytes.fromhex(sys.argv[1].strip())
channel_id, created_at = sys.argv[2], sys.argv[3]
prk = hmac.new(b"\x00" * 32, master, hashlib.sha256).digest()
for usage in ("password", "salt"):
info = f"channel:{channel_id}:{created_at}:crypt:{usage}".encode()
print(usage, hmac.new(prk, info + b"\x01", hashlib.sha256).hexdigest())
```
```bash title="Terminal"
python3 derive.py "$(cat master.key)" 3 "2026-10-15 08:00:00"
```
It prints two lines: `password <64 hex characters>` and `salt <64 hex characters>`.
#### Configure rclone [#configure-rclone]
First create a plain remote for the channel's backend, using the same parameters as the channel (Cloudflare R2 as an example):
```bash title="Terminal"
rclone config create imgyoung-r2 s3 provider=Cloudflare \
access_key_id= secret_access_key= \
endpoint=https://.r2.cloudflarestorage.com region=auto
```
Then layer a crypt remote on top. `remote` points at the channel's storage path (the value of the bucket field in the template, prefix included). Do not drop `--obscure`: it tells rclone to treat both hex strings as plain passwords and encode them:
```bash title="Terminal"
rclone config create imgyoung-crypt crypt \
remote=imgyoung-r2:/ \
password= password2= \
filename_encryption=standard --obscure
```
#### Read the data [#read-the-data]
```bash title="Terminal"
rclone ls imgyoung-crypt:
rclone copy imgyoung-crypt: ./imgyoung-restore
```
### What you get [#what-you-get]
Image objects are named `_`, without an extension; the short ID is the ID in the image link. Metadata snapshots are under `_imgyoung/backup/`.
# Configuration (/en/docs/configuration)
Every bootstrap setting (environment variable) and runtime setting (admin console), with defaults, valid ranges and when changes take effect, plus how to keep the master key safe.
## Two kinds of settings [#two-kinds-of-settings]
ImgYoung has exactly two kinds of settings. There is no configuration file, and the only command-line flag is `-version` (prints version, commit and build time, then exits).
| | Bootstrap settings | Runtime settings |
|---|---|---|
| What | What the process needs before it can start: listen address, data directory, master key, trusted proxies, networks allowed on `/metrics` | Every other operational parameter |
| Source | Environment variables `IMGYOUNG_*` only | The `settings` table in the metadata database; environment variables are not read |
| Changing | Change the variable and restart the process | Edit under **Settings** in the admin console; takes effect on save, no restart |
| Where to see them | The read-only **Bootstrap configuration** card at the bottom of **Settings → Runtime settings** | **Settings** in the admin console |
The split exists so that "where does this value come from" always has exactly one answer: environment variables only get the process started, and some of them also set trust boundaries; runtime settings have a single writer, the admin console, with no seeding from environment variables and no configuration file.
Runtime settings live in the metadata database, so they are included in metadata snapshots and restored with them. Configure a new instance once in the admin console; settings added by an upgrade are filled in with their factory defaults by the database migration.
## Bootstrap settings [#bootstrap-settings]
| Variable | Default | Format | Purpose |
|---|---|---|---|
| `IMGYOUNG_LISTEN` | `:8721` | `host:port`, e.g. `127.0.0.1:8721` or `[::1]:8721`; port `0` lets the OS pick one | HTTP listen address. ImgYoung only speaks plain HTTP; HTTPS is the reverse proxy's job |
| `IMGYOUNG_DATA_DIR` | `data` (relative to the working directory) | Directory path; created with mode `0750` if missing | Data directory: metadata database `imgyoung.db`, master key file `master.key`, the built-in local channel's `blobs/`, and the variant cache `variants/` |
| `IMGYOUNG_MASTER_KEY` | Empty (use `master.key` in the data directory) | 64 hex characters (32 bytes) | Master key. Checked against the master key fingerprint stored in the database at every start; a missing or mismatched key refuses to start. See [Master key](#master-key) below |
| `IMGYOUNG_TRUSTED_PROXIES` | `127.0.0.0/8,::1/128` | Comma-separated CIDRs or single IPs; a single IP means a network containing only that address | `X-Forwarded-For` / `X-Forwarded-Proto` / `X-Forwarded-Host` are honored only when the connecting peer is in one of these networks; see [Reverse proxy & HTTPS](./operations/reverse-proxy.mdx) |
| `IMGYOUNG_METRICS_ALLOW` | `127.0.0.0/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7` | Same as above | Clients in these networks may read `/metrics`; everyone else gets 403. See [Monitoring & alerts](./operations/monitoring.mdx) |
- An **unset** variable takes its default; a variable **set to an empty string** (or whitespace only) is explicitly empty. Surrounding whitespace is trimmed from every value. What empty means:
- `IMGYOUNG_TRUSTED_PROXIES` empty: forwarded headers are never trusted;
- `IMGYOUNG_METRICS_ALLOW` empty: `/metrics` is open to no one, every request gets 403;
- `IMGYOUNG_MASTER_KEY` empty: same as unset, `master.key` in the data directory is used;
- `IMGYOUNG_LISTEN` and `IMGYOUNG_DATA_DIR` do not accept an empty value; setting either to an empty string fails
startup, and the error names the variable. A compose file that says `${VAR}` while the host has no such variable
hands the container exactly that empty string; to get the default, leave the variable unset.
The **Bootstrap configuration** card in the admin console shows an empty network list as "Empty: forwarded headers are never trusted" or "Empty: open to no one".
- Both network lists are parsed at startup. An entry that is neither a CIDR nor an IP fails startup, and the error names the variable and the bad entry. The listen address is checked when the port is bound and the master key when it is parsed; invalid values also fail startup.
- The Docker image sets `IMGYOUNG_DATA_DIR=/data`; `deploy/imgyoung.service` sets it to `/var/lib/imgyoung`.
Where to set environment variables for each deployment:
```bash title="Terminal"
IMGYOUNG_LISTEN=127.0.0.1:8721 IMGYOUNG_DATA_DIR=/srv/imgyoung ./imgyoung
```
```bash title="Terminal"
docker run -d --name imgyoung --restart unless-stopped \
-p 127.0.0.1:8721:8721 -v imgyoung-data:/data \
-e IMGYOUNG_TRUSTED_PROXIES=172.16.0.0/12 \
ghcr.io/x-haose/imgyoung:latest
```
Keep the default `:8721` listen address inside the container and restrict exposure on the host side with `-p 127.0.0.1:8721:8721`.
```yaml title="compose.yaml"
services:
imgyoung:
environment:
IMGYOUNG_TRUSTED_PROXIES: 172.16.0.0/12
```
Run `docker compose up -d` afterwards to recreate the container.
Use a drop-in instead of editing `/etc/systemd/system/imgyoung.service` directly: rerunning the install script with `--service` overwrites that file.
```bash title="Terminal"
sudo systemctl edit imgyoung
```
```ini title="In the editor"
[Service]
Environment=IMGYOUNG_LISTEN=127.0.0.1:8721
```
```bash title="Terminal"
sudo systemctl restart imgyoung
```
## Runtime settings [#runtime-settings]
Runtime settings are edited under **Settings** in the admin console, which only administrators can see. **Allow empty referer** sits on the **Hotlink protection** tab; everything else is on the **Runtime settings** tab, grouped as below.
- Saving submits only the fields you changed. All changes are validated together; if any value is invalid, none of them is applied and the reason is shown.
- Changes apply immediately: background loops re-time themselves with a new interval right away instead of finishing the old one.
- Every applied change writes an audit log entry (action `config.update`, with old and new values); the Cloudflare API token is only recorded as set or empty.
- The same operations are available as `GET /api/admin/config/full` and `PUT /api/admin/config`, whose body is a JSON object mapping setting keys to new values; see the [API reference](./api/admin-overview.mdx).
- Every duration setting is capped at 100 years (876,000 hours), converted to its own unit: 36,500 days, 876,000 hours,
52,560,000 minutes, 3,153,600,000 seconds, or 3,153,600,000,000 milliseconds; larger values are rejected on save. The
"Valid values" column below lists only the lower bound for these settings.
"Default" below is the factory value; "Valid values" is what the server accepts.
### Access & links [#access--links]
| Key | UI label | Default | Valid values | Notes |
|---|---|---|---|---|
| `access_mode` | Access mode | `proxy` | `proxy` / `redirect` | `proxy`: images are read from a channel and served by ImgYoung; a failed read moves on to the next channel immediately. `redirect`: original-image requests get a 302 to a channel that allows direct links, sent with `Cache-Control: no-store`; see [CDN](./operations/cdn.mdx) |
| `base_url` | Public site address | Empty | Site root with scheme, e.g. `https://img.example.com`; a trailing `/` is removed on save | Empty means it is derived from each request. Required when the public domain differs from what reaches ImgYoung through a CDN or proxy, for CDN cache purging, and for one-click cloud drive authorization; see [Reverse proxy & HTTPS](./operations/reverse-proxy.mdx) |
| `min_success` | Minimum synced replicas | `1` | ≥ 1 | Channels that must be written synchronously before an upload succeeds; if the healthy channels cannot provide that many, the upload fails with 503 |
| `encrypt_new_channels` | Encrypt new channels by default | Off | On / off | Whether a new channel is encrypted when the form does not choose explicitly; encryption is fixed when the channel is created |
### Upload limits [#upload-limits]
| Key | UI label | Default | Valid values | Notes |
|---|---|---|---|---|
| `max_upload_mb` | Max image size | `50` | ≥ 1, MB (1 MB = 1024 × 1024 bytes) | Applies to the image itself: a file exactly at the limit is accepted, anything larger gets 413; a multipart request body is additionally capped at "this limit + 64 KiB". Also caps URL imports, S3 uploads, and the source image read for on-demand processing: after lowering it, older images above the new limit fall back to the original instead of being resized or converted |
| `max_pixels_mp` | Pixel budget | `50` | ≥ 1, megapixels | Images whose width × height exceeds `max_pixels_mp × 1,000,000` are rejected (422) |
| `max_concurrent_uploads` | Concurrent uploads | `8` | ≥ 1 | One global limit shared by data-plane uploads, URL imports, admin console uploads and every request on the S3 endpoint; when full, requests get 429 (`Retry-After: 1`) immediately |
| `upload_rate_per_min` | Upload rate limit | `0` | ≥ 0, per minute | Per user, shared between tokens and admin console uploads; burst equals the per-minute allowance. Excess requests get 429 (`Retry-After: 60`). `0` disables it |
### Lifecycle [#lifecycle]
| Key | UI label | Default | Valid values | Notes |
|---|---|---|---|---|
| `default_ttl_hours` | Default lifetime | `0` | ≥ 0, hours | Used when an upload sets no `ttl` and the user has no default of their own. `0` means never expires |
| `ttl_renew_on_access` | Renew on access | On | On / off | A public access (GET) to a time-limited image that has not yet expired moves its expiry to the time the access is flushed plus its lifetime. An image past its expiry is unreachable right away and is never renewed |
| `cold_cleanup_days` | Cold image threshold | `90` | ≥ 0, days | Images without public access for this long go to **Cleanup** for a manual decision; nothing is deleted automatically. `0` disables marking |
| `delete_grace_minutes` | Delete grace period | `0` | ≥ 0, minutes | Wait between a stored image losing its last reference and its channel objects being deleted. The deletion time is fixed when the reference count reaches zero, using the value at that moment; later changes do not affect it. `0` deletes on the next cleanup sweep |
| `cleanup_sweep_seconds` | Cleanup sweep interval | `60` | ≥ 1, seconds | Cadence for expiring references, physical deletion, cold-image marking, and reclaiming idempotency keys and audit log entries |
| `activity_flush_seconds` | Activity flush interval | `60` | ≥ 1, seconds | Interval for batch-writing access counts, last-access times and variant cache access times |
| `idempotency_key_ttl_hours` | Idempotency key retention | `24` | ≥ 1, hours | Upload retries carrying the same `Idempotency-Key` within this window return the first result; older keys are reclaimed |
### On-demand processing [#on-demand-processing]
| Key | UI label | Default | Valid values | Notes |
|---|---|---|---|---|
| `process_widths` | Width tiers | `[100,200,400,800,1600]` | Array of positive integers, pixels; may be empty | `?w=` accepts only these values, anything else gets 400; empty disallows resizing |
| `process_formats` | Target formats | `["webp","jpeg","png"]` | Non-empty subset of `webp`, `jpeg`, `png` | `?format=` accepts only these values. With `webp` included, browsers that support WebP automatically get WebP for JPEG / PNG images |
| `exif_policy` | Default EXIF policy | `passthrough` | `passthrough` (keep) / `strip` (remove metadata) | Default EXIF handling at upload; a single upload can override it with the `exif_policy` form field; does not apply to S3 uploads, which are stored byte for byte |
| `process_quality_webp` | WebP quality | `80` | 0–100 | |
| `process_quality_jpeg` | JPEG quality | `85` | 1–100 | |
| `process_webp_method` | WebP effort | `4` | 0–6 | Higher is slower and smaller |
| `process_workers` | Processing concurrency | `4` | ≥ 1 | Concurrent processing jobs; time spent waiting for a slot counts toward the timeout |
| `process_timeout_ms` | Processing timeout | `5000` | ≥ 1, milliseconds | Includes queueing; on timeout the original is served with `Cache-Control: no-store` |
| `variants_cache_mb` | Variant cache limit | `10240` | ≥ 1, MB | Variants are cached in `/variants/`; beyond the limit the least recently accessed are evicted |
### Deduplication [#deduplication]
| Key | UI label | Default | Valid values | Notes |
|---|---|---|---|---|
| `dedup_scan_seconds` | Dedup scan interval | `300` | ≥ 1, seconds | Each round first merges byte-identical images automatically, then scans for similar ones |
| `dedup_phash_max_distance` | Similarity threshold | `10` | ≥ 0, Hamming distance | Images whose perceptual hashes are at most this far apart become **Duplicates** candidates for a manual decision. `0` disables similarity scanning; identical images are still merged |
### Reliability & channels [#reliability--channels]
| Key | UI label | Default | Valid values | Notes |
|---|---|---|---|---|
| `health_check_seconds` | Health check interval | `60` | ≥ 1, seconds | Cadence of channel health checks and alert evaluation |
| `backup_interval_hours` | Backup interval | `24` | ≥ 1, hours | Interval between automatic metadata snapshots. At startup, a backup runs right away if any healthy channel lacks a snapshot within this interval. See [Backup & restore](./operations/backup.mdx) |
| `backup_keep` | Backups to keep | `7` | ≥ 1 | Snapshots are written to every channel that is healthy and not being cleaned up; after each write, that channel keeps only the newest this many |
| `usage_report_seconds` | Usage report interval | `300` | ≥ 1, seconds | Only controls how often the `imgyoung_channel_storage_bytes` metric is refreshed; usage alerts are evaluated on the health check interval |
| `channel_quota_gb` | Channel usage alert threshold | `0` | ≥ 0, GB (1 GB = 2³⁰ bytes) | Opens an alert when a channel's stored data (sum of image sizes of its completed replicas) exceeds this; writes are not blocked. `0` disables the alert |
### CDN cache purge [#cdn-cache-purge]
| Key | UI label | Default | Valid values | Notes |
|---|---|---|---|---|
| `cdn_cloudflare_zone_id` | Cloudflare Zone ID | Empty | Text | |
| `cdn_cloudflare_api_token` | Cloudflare API token | Empty | Text | Needs the Cache Purge permission. Stored encrypted with the master key and never shown by any API, page or log. Leaving the field empty keeps the current value; **Clear token** removes it |
Purging is enabled only when the Zone ID, the API token and `base_url` are all set. The badge next to the group title reads **Purge enabled** or **Purge disabled**, and the startup log prints the same state. See [CDN](./operations/cdn.mdx).
### Alerts & audit [#alerts--audit]
| Key | UI label | Default | Valid values | Notes |
|---|---|---|---|---|
| `alert_webhook_url` | Alert webhook URL | Empty | Empty, or an `http` / `https` URL with a host | Receives a JSON POST when an alert opens or resolves; payload format in [Monitoring & alerts](./operations/monitoring.mdx). Empty means alerts are recorded but not sent |
| `audit_retention_days` | Audit log retention | `0` | ≥ 0, days | Cleanup sweeps delete audit entries older than this. `0` keeps them forever |
### Hotlink protection tab [#hotlink-protection-tab]
| Key | UI label | Default | Valid values | Notes |
|---|---|---|---|---|
| `allow_empty_referer` | Allow empty referer | On | On / off | With a non-empty Referer allowlist, whether requests without a Referer (typed into the address bar, download tools) are allowed. With an empty allowlist hotlink protection is off and this has no effect |
The allowlist rules are managed on the same tab and are not part of the `settings` table.
## Master key [#master-key]
The master key is 32 random bytes. It is used to:
- encrypt channel configurations (including each channel's credentials) and the Cloudflare API token before they are stored;
- derive the crypt password and salt of every encrypted channel;
- derive the S3 secret of every upload token (never stored, recomputed on demand).
Upload tokens and login passwords are stored as hashes and do not depend on the master key.
### Generation and verification [#generation-and-verification]
At startup the master key is taken from `IMGYOUNG_MASTER_KEY` when it is non-empty, in which case `master.key` is
neither read nor created; otherwise it is read from `master.key` in the data directory. The key is then checked
against the **master key fingerprint** recorded in the database. The fingerprint lives in the single-row table
`master_key_fingerprint`; it is derived from the master key with HKDF-SHA256 and cannot be reversed into the key.
- **No fingerprint in the database yet** (a brand-new database, or one that has never recorded a fingerprint): if
neither source has a key, `master.key` is generated in the data directory (64 hex characters plus a newline, mode
`0600`, written to a temporary file and renamed) and a warning is logged telling you to back it up; the key's
fingerprint is then written to the database.
- **A fingerprint is already recorded**: the master key must be present and must match. If `IMGYOUNG_MASTER_KEY` is
unset or empty and there is no `master.key` in the data directory, or the key does not match the fingerprint,
startup is refused with an explanation, and no new key is generated. Only a brand-new database ever gets a generated `master.key`.
- If `master.key` exists but is not valid 64-character hex, startup fails rather than generating a new key: a new key would make every existing ciphertext useless.
To generate a master key yourself:
```bash title="Terminal"
openssl rand -hex 32
```
When switching from the file to the variable, copy the contents of `master.key` into the variable verbatim; if you get it wrong, the fingerprint check fails and the process refuses to start.
### Keeping it safe [#keeping-it-safe]
The master key is **not** in metadata snapshots and **not** in the metadata export. Back it up separately, somewhere other than the server and the snapshots. Copy it right after the first start:
```bash title="Terminal"
cp data/master.key /safe/place/imgyoung-master.key
```
```bash title="Terminal"
docker cp imgyoung:/data/master.key ./imgyoung-master.key
```
```bash title="Terminal"
docker compose cp imgyoung:/data/master.key ./imgyoung-master.key
```
```bash title="Terminal"
sudo cp /var/lib/imgyoung/master.key ./imgyoung-master.key
```
### Losing it, or starting with the wrong one [#losing-it-or-starting-with-the-wrong-one]
The master key cannot be rotated. Once the database has recorded the fingerprint, both a wrong key and a missing key are stopped at startup:
- A different master key (for example a mistyped `IMGYOUNG_MASTER_KEY`): startup is refused and the error tells you to
switch back to the original key; with the original key back, the process starts normally.
- A missing master key (for example `master.key` was deleted from the data directory, or a `${VAR}` in the compose
file is not set on the host): startup is refused and no new key is generated; put the original key back and the process starts normally.
Actually losing the master key is irreversible: the database can no longer be started, and nothing encrypted with the key can be recovered:
- channel configurations, including each channel's credentials, cannot be decrypted;
- data on encrypted channels, including metadata snapshots stored there, can never be decrypted;
- the Cloudflare API token cannot be decrypted;
- the S3 secret of every upload token can no longer be computed.
Metadata snapshots, the export and the images in your channels cannot bring back a lost master key. For disaster
recovery, the new machine must have the same master key in place before its first start; after restoring another
instance's snapshot, switch to that instance's master key, or the fingerprint does not match and startup is refused.
See [Backup & restore](./operations/backup.mdx).
# Reverse proxy & HTTPS (/en/docs/operations/reverse-proxy)
Put Caddy or Nginx in front of ImgYoung and get IMGYOUNG_TRUSTED_PROXIES, base_url and the upload size limit right.
## Why a reverse proxy [#why-a-reverse-proxy]
ImgYoung listens on plain HTTP and does not terminate TLS. When serving the public internet, put a reverse proxy in front of it:
- the proxy obtains certificates and serves HTTPS;
- whether the session cookie gets `Secure`, and which scheme returned links use when `base_url` is not set, both come from the proxy's `X-Forwarded-Proto`;
- the proxy blocks `/metrics` at the edge so metrics stay on the internal network;
- ImgYoung itself can then listen on loopback only.
## Caddy [#caddy]
Caddy obtains and renews certificates automatically, sets `X-Forwarded-For` / `X-Forwarded-Proto` / `X-Forwarded-Host` by default, and forwards `Host` unchanged.
```text title="deploy/Caddyfile"
# Caddy 反向代理示例:Caddy 自动申请并续期 HTTPS 证书。把域名换成你的,DNS 指向本机。
# Caddy 默认就会设置 X-Forwarded-For / -Proto / -Host,并忽略客户端自带的这些头。
# ImgYoung 直接运行在本机(非容器)时,Caddy 的连接来自回环地址,默认受信任,无需额外配置。
# ImgYoung 在容器里时,不论 Caddy 在宿主机还是另一个容器,ImgYoung 看到的对端都是 Docker 网络里的地址,
# 要给 ImgYoung 设 IMGYOUNG_TRUSTED_PROXIES 为该网络的网段(docker network inspect 查看);
# Caddy 也在容器里时,把 reverse_proxy 改成 imgyoung:8721。
img.example.com {
# 指标只给内网抓取,公网入口一律不转发
respond /metrics 404
reverse_proxy 127.0.0.1:8721
}
```
## Nginx [#nginx]
```nginx title="deploy/nginx.conf"
# Nginx 反向代理示例:放进 http {} 块。域名与证书路径换成你的。
# ImgYoung 直接运行在本机(非容器)时,Nginx 的连接来自回环地址,默认受信任,无需额外配置。
# 其余情况要给 ImgYoung 设 IMGYOUNG_TRUSTED_PROXIES 为它看到的对端所在网段:ImgYoung 在容器里时,
# 不论 Nginx 在宿主机还是另一个容器,对端都是 Docker 网络里的地址(docker network inspect 查看);
# Nginx 在别的主机上时,是 Nginx 的地址。
server {
listen 80;
server_name img.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on; # nginx 1.25.1 起的写法;更早的版本改成 listen 443 ssl http2;
server_name img.example.com;
ssl_certificate /etc/nginx/certs/img.example.com/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/img.example.com/privkey.pem;
# 不小于后台「单图上限」(出厂 50MB),多出的部分留给 multipart 表单开销;Nginx 默认只有 1MB
client_max_body_size 60m;
# 指标只给内网抓取,公网入口一律不转发
location = /metrics {
return 404;
}
location / {
proxy_pass http://127.0.0.1:8721;
proxy_set_header Host $host;
# ImgYoung 从 X-Forwarded-For 自右向左取第一个非信任代理的地址作客户端地址,
# 登录限速与 /metrics 放行都按它判定;少了这一行,所有请求都会被当成来自 Nginx 本身
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 决定会话 cookie 是否带 Secure,以及未配置 base_url 时返回链接的协议与域名
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}
```
None of the `proxy_set_header` lines in the example are optional:
- `Host $host`: S3 signature verification covers the `Host` header, so a proxy that rewrites it breaks S3 clients with signature mismatches;
- `X-Forwarded-For`: without it every request looks like it came from Nginx itself; see the next section;
- `X-Forwarded-Proto` and `X-Forwarded-Host`: ImgYoung reads their first value, so overwrite them with `proxy_set_header` instead of appending your value after whatever the client sent.
## Trusted proxies: IMGYOUNG_TRUSTED_PROXIES [#trusted-proxies-imgyoung_trusted_proxies]
ImgYoung derives three things from each request: the **client address** (the login rate limit key and the `/metrics` allow check), **whether the first hop was HTTPS** (the session cookie's `Secure` flag), and the **public host** (for returned links when `base_url` is not set). `X-Forwarded-*` headers are honored only when the TCP peer address is inside `IMGYOUNG_TRUSTED_PROXIES`; when the variable is unset, the default `127.0.0.0/8,::1/128` trusts a proxy on the same host only. Setting it to an empty string (or only whitespace) trusts no proxy at all, so forwarding headers are always ignored; the **Bootstrap configuration** card in the admin console then shows **Empty: forwarded headers are never trusted**.
When the peer is trusted:
1. All addresses from every `X-Forwarded-For` header are collected in order, split on commas; entries that do not parse are skipped.
2. They are checked **from right to left**, skipping addresses inside trusted networks. The first address outside them is the client.
3. If every hop is inside a trusted network (an internal client), the leftmost one is used; if the header is missing or has no usable address, the peer address is used.
4. The first value of `X-Forwarded-Proto` decides HTTPS: `https` (case-insensitive) means secure.
5. The first value of `X-Forwarded-Host` is used as the public host.
When the peer is not trusted, all `X-Forwarded-*` headers are ignored: the client is the TCP peer, and HTTPS is judged from the connection itself (always false, since ImgYoung does not terminate TLS).
**Why spoofing does not work.** Each proxy appends the peer it saw to the end of `X-Forwarded-For`; a client can only control the leftmost part. A client at `203.0.113.7` sending `X-Forwarded-For: 1.2.3.4` arrives through a local Nginx as `1.2.3.4, 203.0.113.7`; scanning from the right, the first untrusted address is `203.0.113.7`, and the forged `1.2.3.4` is ignored. A request that connects to ImgYoung directly has an untrusted peer, so none of its forwarding headers are believed.
Values by topology:
| Topology | Peer address ImgYoung sees | `IMGYOUNG_TRUSTED_PROXIES` |
|---|---|---|
| Proxy and ImgYoung (binary or systemd) on the same host | `127.0.0.1` / `::1` | The default |
| ImgYoung in Docker, proxy on the host | The Docker bridge gateway, not loopback | The gateway's network, e.g. `172.16.0.0/12`; check the actual gateway with `docker network inspect` |
| Proxy and ImgYoung as two containers in one Compose project | The proxy container's address on the project network | The project network's subnet |
| Proxy on another host | The proxy host's internal address | That address or its network |
| A CDN in front of the proxy | The proxy; the rightmost `X-Forwarded-For` hop is a CDN node | The proxy's network plus the CDN's origin-pull ranges, so the client resolves to the real visitor |
If the peer is outside the trusted networks, every proxied request appears to come from the proxy itself: all visitors share one login rate limit, and if the proxy's address happens to be in `IMGYOUNG_METRICS_ALLOW` (Docker networks are by default) and the proxy forwards `/metrics`, anyone can read the metrics. A proxy that does not set `X-Forwarded-For` has the same effect.
## Listen on loopback only [#listen-on-loopback-only]
With a proxy in front, do not expose ImgYoung's port directly:
Set `IMGYOUNG_LISTEN=127.0.0.1:8721`. For systemd, add `Environment=IMGYOUNG_LISTEN=127.0.0.1:8721` with `sudo systemctl edit imgyoung` and restart; see [Configuration](../configuration.mdx).
Keep the default listen address inside the container and publish the port on host loopback only:
```bash title="Terminal"
docker run -d --name imgyoung --restart unless-stopped \
-p 127.0.0.1:8721:8721 -v imgyoung-data:/data \
-e IMGYOUNG_TRUSTED_PROXIES=172.16.0.0/12 \
ghcr.io/x-haose/imgyoung:latest
```
`-p 8721:8721` publishes on every interface, and Docker's own forwarding rules bypass host firewalls such as ufw.
```yaml title="compose.yaml"
services:
imgyoung:
ports:
- "127.0.0.1:8721:8721"
environment:
IMGYOUNG_TRUSTED_PROXIES: 172.16.0.0/12
```
If the proxy runs in the same Compose project, drop `ports` entirely and let the proxy reach `imgyoung:8721`.
## When base_url is required [#when-base_url-is-required]
With `base_url` (**Settings → Runtime settings → Access & links → Public site address**) empty, ImgYoung derives the public address from each request: the scheme from the HTTPS check, the host from `X-Forwarded-Host` when the peer is trusted, otherwise from `Host`. Set it explicitly when:
- **the public domain differs from what reaches ImgYoung**: a CDN pulls from a different hostname, the proxy rewrites `Host` without sending `X-Forwarded-Host`, the proxy is not trusted, and so on. Otherwise the `url` returned by uploads, links copied in the admin console, and the endpoint and link prefix in the **S3 client settings** dialog are all wrong.
- **CDN cache purging is enabled**: purge jobs run in the background with no request to derive from, so they use `base_url` only; see [CDN](./cdn.mdx).
- **one-click cloud drive authorization is used**: the callback URL must match what is registered with the provider character for character and is built from `base_url` only; see [Cloud drives](../channels/cloud-drives.mdx).
## Keep the upload size limit in step with the proxy [#keep-the-upload-size-limit-in-step-with-the-proxy]
ImgYoung's max image size (`max_upload_mb`, 50 MB out of the box) applies to the file itself, so a file exactly at the limit is accepted; the whole request body may be up to the max image size plus 64 KiB, which leaves room for multipart boundaries and short fields such as `exif_policy` and `ttl`. The proxy's body limit should be at least that:
- Nginx allows only 1 MB by default; the example sets `client_max_body_size 60m;`.
- The Caddy example sets no body limit; if you add `request_body` with `max_size`, keep it at or above the max image size plus 64 KiB.
When you raise the max image size in the admin console, raise the proxy limit too, or large images are rejected with 413 by the proxy before they ever reach ImgYoung.
# CDN (/en/docs/operations/cdn)
Putting a CDN in front of ImgYoung — cache headers, WebP negotiation and Vary, cache keys for on-demand processing, Cloudflare cache purging, and how redirect mode and hotlink protection behave behind a CDN.
## Recommended setup: proxy mode plus a CDN [#recommended-setup-proxy-mode-plus-a-cdn]
Let the CDN pull from ImgYoung (through your [reverse proxy](./reverse-proxy.mdx)) and keep the access mode at `proxy`:
- ImgYoung reads image bytes from the channels and fails over between them on each origin pull, so the CDN sees one stable origin;
- on-demand processing (`?w=`, `?format=`) and WebP negotiation keep working, and the CDN caches the results;
- the public domain is the CDN's, so set `base_url` in the admin console (**Settings → Runtime settings → Access & links → Public site address**), and add the CDN's origin-pull ranges to `IMGYOUNG_TRUSTED_PROXIES` so login rate limiting counts real visitors; see [Reverse proxy & HTTPS](./reverse-proxy.mdx).
Cache only the image routes `/i/*` and `/o/*`; mark `/api/*` and `/s3/*` as not cacheable. The admin console's `index.html` is served with `no-cache`, and files under `/assets/` have content hashes in their names and a one-year cache lifetime, so the CDN's default rules are fine for them.
## Cache headers [#cache-headers]
Responses from the image routes:
| Response | `Cache-Control` | `ETag` | `Vary` |
|---|---|---|---|
| Original under `/i/` | `public, max-age=31536000, immutable` | `""` | `Accept` when a JPEG / PNG / WebP is requested without `format` (or with `format=auto`) |
| Variant under `/i/` (resized or converted) | same | `"--"`, width `0` when not resized | `Accept` when the target format was negotiated |
| Original or variant under a `/o/` alias | `public, no-cache` | same as above | same as above |
| Original served as a fallback after processing failed | `no-store` | none | none |
| `If-None-Match` matches | 304 with the matching `Cache-Control`, `ETag` and `Vary` | | |
| 302 (`redirect` mode) | `no-store` | none | none |
| Invalid parameters (400), hotlink rejection (403), not found or expired (404), metadata lookup failure (500, retryable) | none | none | none |
Originals also carry `Last-Modified` (the upload time). The content behind `/i/` never changes, which is why it gets a one-year immutable lifetime.
`/o//` is an alias chosen by an S3 client, and uploading again with the same object key points it at the new image. Its responses may therefore be cached but must be revalidated with the ETag before each use: an unchanged image gets 304, a re-pointed alias gets the new image, a deleted one gets 404. Behind a CDN, `/i/` links cache better.
## WebP negotiation and Vary [#webp-negotiation-and-vary]
When a JPEG / PNG image is requested without a `format` parameter (or with `format=auto`), and the `Accept` header contains `image/webp` and `webp` is among the target formats, the response is a WebP variant; otherwise it is the original format. In other words, **the same URL returns different formats depending on `Accept`**, and both responses (304s included) carry `Vary: Accept`.
The CDN must cache separately per `Accept` (by honoring `Vary: Accept` or adding `Accept` to the cache key); otherwise a cached WebP may be sent to clients that cannot display it. Cloudflare lets you configure `Vary` handling in Cache Rules; for other CDNs, check their documentation. If your CDN cannot do this, remove `webp` under **On-demand processing → Target formats** in the admin console: negotiation stops, at the cost of `?format=webp` no longer being accepted.
## Processing parameters and cache keys [#processing-parameters-and-cache-keys]
- `w` must be one of the width tiers and `format` one of the target formats or `auto`; anything else gets 400. The allowlist bounds how many variants an image can have, so arbitrary parameters cannot create unbounded cache entries.
- ImgYoung ignores any other query parameter, but the CDN's default cache key includes it: appending a random parameter bypasses the cache and hits the origin. Restrict the cache key's query string to `w` and `format`.
- Always write the parameters in the order `?w=…&format=…`. Cache purging only clears URLs in that order (see below); a different order is a different cache entry as far as the CDN is concerned.
## Cloudflare cache purging [#cloudflare-cache-purging]
After an image is deleted, CDN edges keep serving its `/i/` links under the one-year cache lifetime. With Cloudflare cache purging configured, ImgYoung clears the edge cache when an image is deleted.
Configure three settings under **Settings → Runtime settings**:
| Setting | Where | Notes |
|---|---|---|
| Cloudflare Zone ID | **CDN cache purge** group | ID of the zone your site is in |
| Cloudflare API token | **CDN cache purge** group | Needs the Cache Purge permission for that zone; stored encrypted with the master key and never shown again |
| Public site address (`base_url`) | **Access & links** group | Used to build the public URLs to purge; must match the domain on the CDN |
With all three set, the group shows **Purge enabled**; if any is missing, nothing is purged. The process also logs the purge state when it starts. Changes apply on save without a restart.
**When it fires**: the moment an image reference is deleted, whatever the source — deletion in the admin console (single or batch), `DeleteObject` on the S3 endpoint, or expiry of its lifetime. Purging does not wait for the delete grace period or for the channel objects to be removed. Images re-pointed by a deduplication merge are not purged.
**What gets purged**: `/i/`, plus for every variant that was actually generated for the image either `?w=` and `?w=&format=` (resized variants) or `?format=` (format-only variants).
**How**: as an asynchronous background job calling Cloudflare's purge-by-URL endpoint `POST /zones//purge_cache`, at most 100 URLs per call. Failures are retried with backoff by the job queue and never block the deletion itself.
**Not covered**: `format=auto` forms and URLs with other query parameters or a different parameter order; these only go away when the CDN's own cache expires. `/o/` alias links need no purging, since they are revalidated with the origin every time.
## Redirect mode and CDNs [#redirect-mode-and-cdns]
In `redirect` access mode, a request for an original gets a 302 to a channel that allows direct links, is not encrypted and has a public address configured. The bytes then come from the channel (or its own domain or CDN) and do not pass through the CDN in front of ImgYoung. Without such a channel ImgYoung falls back to proxying.
- Every 302 carries `Cache-Control: no-store`, so neither the CDN nor browsers cache the redirect itself: every request comes back to ImgYoung, and channel switches, deletions and expiry take effect immediately.
- Requests that need processing (`w` / `format` parameters, or WebP negotiation) are still processed and served by ImgYoung, not redirected. To make browsers' original-image requests redirect as well, remove `webp` from the target formats.
If you already have a CDN in front of ImgYoung, use `proxy` mode: the CDN already carries the traffic. `redirect` is meant for servers without a CDN and with little upstream bandwidth, or for channels that come with their own public CDN domain (such as a custom domain bound to an object storage bucket).
## Hotlink protection behind a CDN [#hotlink-protection-behind-a-cdn]
ImgYoung's Referer allowlist only applies to requests that reach the origin:
- requests served from the CDN cache never reach ImgYoung and are not checked; the default cache key does not include the Referer, so once an allowlisted site has caused an image to be cached, any site can load it from the CDN;
- ImgYoung's 403 carries no `Cache-Control`; if the CDN caches that 403, allowlisted visitors are rejected too.
Behind a CDN, enforce hotlink protection on the CDN (a rule that blocks by Referer) and leave ImgYoung's allowlist empty or use it only as an extra layer. In `redirect` mode the channel's direct links are exposed, so hotlink protection does not hold there either.
# Backup & restore (/en/docs/operations/backup)
Automatic metadata snapshots, restoring from a snapshot, disaster recovery after losing the host, the metadata export, and reconciliation GC.
## What to back up [#what-to-back-up]
Objects in channels are named by content hash; which image belongs to which short link, user and token lives only in the metadata database. Lose the metadata database and the images in your channels can no longer be matched up. Lose the master key and the instance refuses to start, and channel credentials and encrypted channel data can no longer be decrypted.
| What | Where | How |
|---|---|---|
| Master key | `/master.key`, or the `IMGYOUNG_MASTER_KEY` variable | Copy it offline right after the first start and keep it apart from the snapshots; see [Configuration](../configuration.mdx#master-key) |
| Metadata database | `/imgyoung.db` | Automatic snapshots (written to every healthy channel), plus a downloaded copy kept somewhere else |
| Images | Your channels | Configure several channels so replication keeps more than one copy of each image; the built-in local channel stores its data in `/blobs/` |
| rclone config of encrypted channels | **Export rclone config** on the **Storage channels** page | Export one per encrypted channel and keep it offline like the master key; see [Channel encryption](../channels/encryption.mdx) |
| Deployment configuration | Environment variables, systemd drop-ins, `compose.yaml`, proxy configuration | Your own configuration management |
`/variants/` is the variant cache; it is regenerated on demand and needs no backup. Do not copy `imgyoung.db` while the service is running: the database runs in WAL mode and recent writes may still be in `imgyoung.db-wal`. For a consistent copy, download a snapshot from the admin console, or stop the service first.
## Automatic metadata snapshots [#automatic-metadata-snapshots]
- **How**: each round, SQLite's `VACUUM INTO` writes one consistent snapshot to `/backup-snapshot.tmp` (needs free space of roughly the database size), which is deleted once it has been written to the channels.
- **Where**: the same snapshot is written to **every** healthy channel that is not being deleted, under the object key `_imgyoung/backup/imgyoung-.db` (e.g. `imgyoung-20261015-083000.123.db`) in the channel's path; on the built-in local channel that is `/blobs/_imgyoung/backup/`.
- **Encryption**: snapshots written to an encrypted channel are encrypted along with everything else on it.
- **When**: every **Backup interval** (`backup_interval_hours`, 24 hours by default), counted from process start; changing the interval restarts the countdown from the moment of the change. Scheduled backups and **Back up now** run one at a time; if both happen at once, one waits for the other.
- **At startup**: if any healthy channel lacks a snapshot from within the last interval, a backup runs immediately; otherwise it is skipped. The first start after adding a channel therefore fills it in, and frequent restarts do not push the rotated history out with same-day snapshots.
- **Retention**: after a successful write, each channel keeps only its own newest **Backups to keep** (`backup_keep`, 7 by default) snapshots; older ones are deleted.
- **Failure**: a channel that fails does not stop the others from being written, but if any channel was not written the round counts as failed and opens a `backup_failed` alert; the channels that were written keep the snapshot. No healthy channel, or failing to take the snapshot, also counts as failure. The alert resolves after the next success; see [Monitoring & alerts](./monitoring.mdx).
- **When a channel is deleted**: **Delete and purge** deletes the snapshots on the channel too; **Remove only** leaves every remote object, snapshots included, where it is.
If no healthy channel lives off this host's disk (only the built-in local channel, or every other channel is unhealthy), image replicas and snapshots exist only on this host and are lost with it; the dashboard then shows **Your data only lives on this host**. Add an object storage or cloud drive channel and snapshots are written to it as well — no priority changes needed.
A snapshot written to an encrypted channel is ciphertext, and the crypt password and salt that decrypt it are derived from the master key together with the channel's ID and creation time recorded in the metadata database. Once the metadata database is gone, fetch the snapshot from any unencrypted channel that holds one. If only encrypted channels hold snapshots, you need the config file downloaded beforehand with **Export rclone config** and kept offline; see [Channel encryption](../channels/encryption.mdx).
## Managing snapshots in the admin console [#managing-snapshots-in-the-admin-console]
**Settings → Backups & export → Metadata snapshots** lists the union of the snapshots on all healthy channels, newest first; **Stored on** lists the channels holding each snapshot, in priority order.
- **Back up now**: take a snapshot immediately and write it to every healthy channel, pruning each channel beyond the retention count. If some channels were not written, the request fails with an error naming them and why, like `备份失败:快照 imgyoung-20261016-083000.123.db 有 1/3 个渠道未写成: 渠道 "r2-main": 上传快照: …`; the channels that were written keep the snapshot. With no healthy channel it returns `没有健康渠道可存放快照`.
- **Download**: reads the snapshot from the healthy channels holding it in priority order, moving on to the next one if a read fails. The downloaded file is a complete SQLite database.
- **Restore**: reads the same way; see the next section.
Snapshots on unhealthy channels or channels being deleted are not listed and are not used for downloads or restores. If listing any healthy channel fails, the whole list fails (500). With no healthy channel every action returns 503: `没有健康渠道,无法列举快照` for the list, `没有健康渠道,无法读取快照` for downloads and restores, and `没有健康渠道可存放快照` for **Back up now**. If no healthy channel has the requested snapshot, downloads and restores return 404 `快照不存在`; if some channels fail to read it and none succeeds, they return 500.
## Restoring from a snapshot [#restoring-from-a-snapshot]
A live SQLite file cannot be replaced safely, so a restore happens in two steps: stage the snapshot, then swap it in on the next start, before the database is opened.
**Stage and shut down**: click **Restore** on a snapshot row and confirm with **Restore and restart**. The service downloads the snapshot to `/imgyoung.db.restore` (via a temporary file renamed once complete), writes a `backup.restore` audit entry, responds, and shuts down gracefully: it stops accepting new requests, waits up to 6 seconds for in-flight requests, then stops the background loops and replication jobs, waiting up to 2 more seconds.
**Restart via the process manager**: the process exits normally and needs a process manager to start it again — `--restart unless-stopped` for Docker, `restart: unless-stopped` for Compose, `Restart=always` for systemd (the examples in `deploy/` are set up this way). A binary started by hand in a terminal simply exits, and the staged file is swapped in the next time you start it.
**Swap in**: at startup, before the database is opened, a restore marker `imgyoung.db.restored` is written to the data directory, the current `imgyoung.db` and its `-wal` and `-shm` files are renamed to `imgyoung.db.pre-restore-`, and the staged file is renamed to `imgyoung.db`. Database migrations then run as usual, so a snapshot from an older version is brought up to date.
**Reset binding times**: after the migrations and before the master key is loaded, the marker causes every channel's binding time (see [Reconciliation GC](#reconciliation-gc)) to be reset to the current time; the marker is then deleted and the log reads `换入快照后已把渠道绑定时刻重置为当前时刻,快照之后写入渠道的对象不会被对账回收`, with the number of channels in the `channels` field. If this step fails, the marker stays and the next start does it again.
**Sign in again**: the admin console reloads once the service is back. Sessions and passwords are as they were at snapshot time, so use the password you had then.
The kept `imgyoung.db.pre-restore-*` files are never deleted automatically; remove them once you are satisfied with the restore.
A restore takes the metadata back to snapshot time, so anything that happened afterwards no longer matches the objects in your channels:
- **images uploaded after the snapshot** are unknown to the metadata. Their objects predate the reset binding times, so reconciliation GC only counts them (`foreign`) and never deletes them; they stay on the channels but can no longer be reached through ImgYoung;
- **images deleted after the snapshot** are back in the metadata, but their objects are gone; they fail to load and show up as **Replication failed** alerts, so delete them in the admin console;
- **everything else** — channels, users and tokens created, runtime settings and hotlink rules changed — goes back to snapshot time.
## Disaster recovery after losing the host [#disaster-recovery-after-losing-the-host]
Prerequisites: a backup of the `master.key` of the instance the snapshot came from, and a snapshot file from one of these:
- a snapshot you downloaded;
- any **unencrypted** channel: use the provider's console or rclone to download the newest `imgyoung-*.db` under `_imgyoung/backup/` in the channel's path;
- an encrypted channel: read it through the crypt remote of the rclone config file you exported beforehand; see [Channel encryption](../channels/encryption.mdx).
**Install the same or a newer version on the new machine**; see [Installation](../install/index.mdx). It does not matter if it has already started once: the fresh instance's database is renamed and kept when the snapshot is swapped in. The `master.key` it generated on first start, however, must be replaced with the original in the next step. The metadata database records a fingerprint of the master key; once the snapshot is swapped in, a missing master key or one that does not match the snapshot makes the process refuse to start rather than generate a new key.
**Stop the service and put the master key and the snapshot in place**: put the original `master.key` in the data directory and the snapshot file as `imgyoung.db.restore`.
The snapshot must go in as `imgyoung.db.restore`; do not overwrite `imgyoung.db` with it. Only a swap through the staged file writes the restore marker that resets the channels' binding times. If you overwrite the database directly, objects written to the channels after the snapshot look like this database's own orphans to reconciliation GC and are deleted once past the grace period.
```bash title="Terminal"
mkdir -p data
cp imgyoung-master.key data/master.key && chmod 600 data/master.key
cp imgyoung-20261015-083000.123.db data/imgyoung.db.restore
```
The image runs as uid 65532 and has no shell, so use a throwaway container to write into the volume and fix ownership:
```bash title="Terminal"
# If the container is running, docker stop imgyoung first; if there is none yet, create it with your usual options (without starting it)
docker create --name imgyoung --restart unless-stopped -p 8721:8721 \
-v imgyoung-data:/data ghcr.io/x-haose/imgyoung:latest
docker run --rm --volumes-from imgyoung -v "$PWD":/in:ro alpine:3.24 sh -c '
cp /in/imgyoung-master.key /data/master.key &&
cp /in/imgyoung-20261015-083000.123.db /data/imgyoung.db.restore &&
chmod 600 /data/master.key &&
chown 65532:65532 /data /data/master.key /data/imgyoung.db.restore'
docker start imgyoung
```
```bash title="Terminal"
docker compose stop # if it has never been started, use docker compose create instead
docker run --rm --volumes-from "$(docker compose ps -a -q imgyoung)" -v "$PWD":/in:ro alpine:3.24 sh -c '
cp /in/imgyoung-master.key /data/master.key &&
cp /in/imgyoung-20261015-083000.123.db /data/imgyoung.db.restore &&
chmod 600 /data/master.key &&
chown 65532:65532 /data /data/master.key /data/imgyoung.db.restore'
docker compose up -d
```
After `install.sh --service` the service has already started once, so systemd has created `/var/lib/imgyoung` and handed it to the service user. Give the new files the same owner as the existing database:
```bash title="Terminal"
sudo systemctl stop imgyoung
sudo cp imgyoung-master.key /var/lib/imgyoung/master.key
sudo cp imgyoung-20261015-083000.123.db /var/lib/imgyoung/imgyoung.db.restore
sudo chown --reference=/var/lib/imgyoung/imgyoung.db \
/var/lib/imgyoung/master.key /var/lib/imgyoung/imgyoung.db.restore
sudo chmod 600 /var/lib/imgyoung/master.key
sudo systemctl start imgyoung
```
If you provide the master key through `IMGYOUNG_MASTER_KEY`, set the variable instead of placing the file.
**Start and check**: for the binary, start `imgyoung` now. The log lines `已用暂存快照换入元数据库,原库留存` and then `换入快照后已把渠道绑定时刻重置为当前时刻,快照之后写入渠道的对象不会被对账回收` confirm the swap. Sign in with the credentials from snapshot time and check on **Storage channels** that the channels are healthy again.
If startup fails saying the master key does not match the database, or that no master key can be found, the key you put in place is not the one from the snapshot's instance, or there is none. The snapshot has already been swapped in at that point; put the right master key in place and start again — there is no need to stage the snapshot again.
Images that existed only on the local channel are gone with the old host. Images with replicas on other channels load as usual, and the missing local replicas are detected by reconciliation GC and copied back from the other channels.
If a snapshot is on an unencrypted channel, you can also skip fetching the file by hand: start a fresh instance, add the channel holding the snapshots in the admin console with its original parameters, and restore from **Metadata snapshots**; the list covers every healthy channel, so no priority changes are needed. The fresh instance must use the original master key from its very first start (put `master.key` in place or set `IMGYOUNG_MASTER_KEY` beforehand); otherwise, when it restarts after the restore, the key does not match the snapshot and the process refuses to start. If it has already started with a generated key, stop it, put the original `master.key` in place, delete the fresh `imgyoung.db` (and its `-wal` and `-shm`) from the data directory, start it again, and only then add the channel. A newly added channel is bound at the moment it is added, and every object already in the bucket predates that, so reconciliation GC only counts those objects and never deletes them; nothing is lost while you wait.
Between adding the channel and restoring, do not click **Back up now** and do not restart the fresh instance: the fresh instance's empty database would be written to every healthy channel, the old one included, and by the retention count push out the oldest of the snapshots there. The restart caused by **Restore** is fine: that start swaps the snapshot in first, so what gets backed up afterwards is the restored database.
## Metadata export [#metadata-export]
**Settings → Backups & export → Data export → Export JSON** downloads `imgyoung-export.json` (each export writes a `data.export` audit entry). Contents:
| Field | Contents |
|---|---|
| `users` | Users: ID, username, role, quota, default lifetime, disabled flag, usage and image count; no password hashes |
| `channels` | Channels: ID, name, driver, priority, health, encrypted flag, direct-link flag, creation time, replica counts; no configuration parameters or credentials |
| `tokens` | Tokens: name, owner, plaintext prefix, scopes, revocation and issue times; no hashes |
| `images` | Every image reference: short ID, stored image, owner and token, original file name, object key alias, lifetime, access statistics, cleanup flags |
| `blobs` | Every stored image: SHA-256, perceptual hash, object key in the channels, MIME type, size, dimensions, reference count, deletion state |
| `blob_channels` | Which channel holds each replica, its state and last error |
The export is readable and self-contained: even without running ImgYoung you can use it to match the objects in your channels back to images. It is not a restore format; restores use snapshots. Runtime settings, hotlink rules, audit logs and alerts are not included.
## Reconciliation GC [#reconciliation-gc]
Every 24 hours (the first run 24 hours after process start), the replication subsystem reconciles each healthy channel in both directions. System objects under the `_imgyoung/` prefix (including snapshots) are left alone.
Orphan detection is bounded by the channel's **binding time**: the moment the channel record started pointing at its current storage location. ImgYoung only writes objects to that location after it.
| Situation | Binding time |
|---|---|
| A channel is created | Its creation time |
| The channel is edited in the admin console and the backend or any configuration parameter changes | Reset to the time of the edit; re-entering the same password counts as a change (rclone encodes passwords with a random IV, so the encoded value differs every time) |
| Only the name, priority or direct-link setting changes; a token refreshed by the provider is written back | Unchanged |
| A snapshot is restored (swapped in through `imgyoung.db.restore`) | Every channel is reset to the time of the start that swapped it in |
- **Orphans** are deleted, so pay-per-use channels do not bill you for ownerless objects. An object is an orphan only if all of the following hold:
- its key has ImgYoung's object key format `_`;
- the metadata database has no replica record of it on that channel, in any state;
- it was last modified no earlier than the channel's binding time;
- it was last modified more than 48 hours ago; the grace period covers the normal window between writing to a channel and recording it in the database.
- **Deep check leftovers**: the deep health check writes a temporary `.imgyoung-probe-*` object and deletes it right away; one is left behind only when that deletion fails. They are deleted once past the 48-hour grace period regardless of when they were written (leftovers from a connection test predate the channel record), and are not counted in the object count or usage.
- **Foreign objects** have no record in the database but either a key in another format or a modification time before the binding time — files written to the same path by other programs, the objects already in an old bucket attached by a fresh instance during disaster recovery, or objects written after a snapshot that was later restored. They are counted, never deleted.
- **Phantoms**: replicas the metadata database records as completed but that are missing from the channel. They are reset to pending and queued to be copied from another channel.
Each channel's reconciliation logs a `渠道对账完成` line with the fields `objects` (object count), `bytes`, `foreign` (foreign objects), `orphans_deleted` (deep check leftovers included) and `phantoms_requeued`.
If two ImgYoung instances use the same storage path, objects the other instance writes after this one bound the path are orphans as far as this database is concerned, and are deleted once past the grace period. Give each instance its own bucket or path.
Separately, replica reconciliation runs every 10 minutes (without listing channel objects): failed replicas on channels that are healthy again are reset to pending, and unfinished replicas are queued again.
# Upgrading (/en/docs/operations/upgrade)
Upgrade commands for each installation method, the database migrations that run automatically at startup, and what to watch out for when rolling back.
## Before upgrading [#before-upgrading]
1. Read the [release notes](https://github.com/x-haose/imgyoung/releases) of the target version.
2. Keep a copy of the metadata database to roll back to: in the admin console, go to **Settings → Backups & export → Metadata snapshots**, click **Back up now**, then **Download** that snapshot and store it off the server. Alternatively, stop the service and copy `/imgyoung.db` (only consistent once stopped).
3. Make sure `master.key` is backed up; see [Configuration](../configuration.mdx#master-key).
Check the running version with `imgyoung -version`, or under **Server version** in **Settings → Runtime settings → Bootstrap configuration**.
## Upgrade commands [#upgrade-commands]
Rerunning the install script upgrades to the latest stable release; add `--version X.Y.Z` for a specific one.
```bash title="Terminal"
# Running as a systemd service (installed with --service): replaces the binary, rewrites the unit and restarts the service
curl -fsSL https://raw.githubusercontent.com/x-haose/imgyoung/main/install.sh | sudo sh -s -- --service
# Managing the process yourself: only replaces the binary; restart imgyoung afterwards
curl -fsSL https://raw.githubusercontent.com/x-haose/imgyoung/main/install.sh | sh
```
`--service` overwrites `/etc/systemd/system/imgyoung.service` with `deploy/imgyoung.service` from the release archive; keep your own environment variables in a drop-in created with `systemctl edit`, which is left alone.
Pull the new image, remove the old container, and create a new one with the **same volume** and your usual options. Removing a container does not remove its named volume. If you pass the master key with `-e IMGYOUNG_MASTER_KEY=…`, do not leave it out when recreating the container: the database records a fingerprint of the master key, and the process refuses to start without it.
```bash title="Terminal"
docker pull ghcr.io/x-haose/imgyoung:latest
docker stop imgyoung && docker rm imgyoung
docker run -d --name imgyoung --restart unless-stopped \
-p 8721:8721 -v imgyoung-data:/data \
ghcr.io/x-haose/imgyoung:latest
```
Image tags: `latest` follows the newest stable release; a major.minor tag such as `1.0` follows the newest patch of that series; a full version such as `1.0.3` never moves. Pre-releases only get the full version tag.
```bash title="Terminal"
docker compose pull
docker compose up -d
```
If `compose.yaml` pins a major.minor or full version, change the `image:` tag first to move to another series.
Download the new archive for your platform from the [releases page](https://github.com/x-haose/imgyoung/releases), verify it against `checksums.txt` from the same release, extract it, replace the old `imgyoung` binary and restart the process. The data directory stays as it is.
```bash title="Terminal"
sha256sum -c checksums.txt --ignore-missing
tar -xzf imgyoung__linux_amd64.tar.gz
```
```bash title="Terminal"
git fetch --tags
git checkout vX.Y.Z
task build
```
Replace the running binary with the newly built `./imgyoung` and restart.
When the old process is stopped, ImgYoung first stops accepting new requests and waits up to 6 seconds for in-flight requests (cutting them off after that), then stops the background loops and in-flight replication jobs, waiting up to 2 more seconds, and finally closes the database and exits. Interrupted replication jobs are queued again by reconciliation on the next start. Docker's default 10-second stop timeout and the `stop_grace_period: 15s` (Compose) and `TimeoutStopSec=15s` (systemd) in the `deploy/` examples are all enough.
## Database migrations [#database-migrations]
Migrations are embedded in the binary and run automatically on every start, right after the database is opened:
- the migration files are first checked against their checksum list, then every migration not yet applied runs in version order; when skipping several versions, all intermediate migrations are applied in turn. Applied versions are recorded in the `schema_revisions` table.
- the replication subsystem's job queue tables are migrated at startup as well.
- runtime settings added by a migration are filled in with their factory defaults; existing values are untouched.
- if the database records a migration version this binary does not know (it was migrated by a newer version), startup is refused; see [Rolling back](#rolling-back) below.
- if a migration fails, the process exits with an error naming the failing migration file. Roll back using your pre-upgrade snapshot (see below).
There is no separate migration command, and none is needed.
## Rolling back [#rolling-back]
Downgrades are not supported: migrations only go forward, and some of them drop columns or rebuild tables. An older binary refuses to start on a database migrated by a newer version; the error names the newest migration version in the database and the newest one the binary knows.
Rolling back therefore takes exactly one thing: **the older binary plus your pre-upgrade snapshot**. Without a pre-upgrade snapshot, you have to stay on the new version.
Stop the service.
Switch back to the older binary or image tag.
Put your pre-upgrade snapshot (or the database copied while the service was stopped) in place as `/imgyoung.db.restore`; do not overwrite `imgyoung.db` with it. At startup it is swapped in, the database migrated by the newer version is kept as `imgyoung.db.pre-restore-`, and the channels' binding times are reset. For Docker the file must be owned by `65532:65532`; use the throwaway-container approach shown in [Backup & restore](./backup.mdx).
Start the service.
Metadata written since the upgrade is lost, with the same effects as restoring a snapshot: images uploaded in the meantime have no record in the metadata, so their objects on the channels are only counted, never deleted, but can no longer be reached through ImgYoung. See [Backup & restore](./backup.mdx).
# Forgotten password (/en/docs/operations/password-reset)
Recover the built-in administrator by clearing its password hash and restarting to get the factory password back; reset other users' passwords from the admin console.
## Built-in administrator forgot the password [#built-in-administrator-forgot-the-password]
Passwords are stored as argon2id hashes and cannot be written by hand. The built-in administrator's factory password, however, is seeded at startup: **if the built-in administrator has no password**, startup sets it to the factory password `imgyoung123` and marks it as requiring a change. Clearing its `password_hash` and restarting therefore brings you back to the first sign-in.
There is no dedicated reset command: one SQL statement does the job. This path requires a shell on the server or access to the database, which means control of the machine, so it is not a remote attack surface.
The built-in administrator's ID in the `users` table is always `default`. Its username can be changed in the admin console, so the commands below select it by ID rather than by name.
From the restart until you change the password, anyone can sign in with the public factory password and change it before you do. Sign in and change it right after the restart.
Requires `sqlite3` on the machine. Stop the `imgyoung` process, then run this against the database in the data directory (`data/` under the directory you start it from, or the directory set by `IMGYOUNG_DATA_DIR`):
```bash title="Terminal"
sqlite3 data/imgyoung.db "UPDATE users SET password_hash = '' WHERE id = 'default';"
```
Then start `imgyoung` again.
The image has neither a shell nor `sqlite3`, so run a throwaway Alpine container on the same volume, and hand the database files back to the image's runtime user afterwards:
```bash title="Terminal"
docker stop imgyoung
docker run --rm -i --volumes-from imgyoung alpine:3.24 sh -c \
'apk add -q sqlite && sqlite3 /data/imgyoung.db && chown 65532:65532 /data/imgyoung.db*' <<'SQL'
UPDATE users SET password_hash = '' WHERE id = 'default';
SQL
docker start imgyoung
```
Replace `imgyoung` with your container name.
From the directory containing `compose.yaml`:
```bash title="Terminal"
docker compose stop imgyoung
docker run --rm -i --volumes-from "$(docker compose ps -a -q imgyoung)" alpine:3.24 sh -c \
'apk add -q sqlite && sqlite3 /data/imgyoung.db && chown 65532:65532 /data/imgyoung.db*' <<'SQL'
UPDATE users SET password_hash = '' WHERE id = 'default';
SQL
docker compose start imgyoung
```
The data directory is `/var/lib/imgyoung`; requires `sqlite3` on the machine:
```bash title="Terminal"
sudo systemctl stop imgyoung
sudo sqlite3 /var/lib/imgyoung/imgyoung.db "UPDATE users SET password_hash = '' WHERE id = 'default';"
sudo systemctl start imgyoung
```
The log line `已初始化默认管理员密码,请登录后立即在后台修改` means the password was seeded. Sign in with the built-in administrator's username (`admin` unless you renamed it) and `imgyoung123`; you land directly on **Set the admin password**, and after changing it you sign in again with the new password.
The password change is enforced by the server: until the factory password is changed, that session can only sign out, read its own account (`GET /api/admin/me`) and change the password; every other admin API returns 403 `须先修改出厂默认密码` ("the factory default password must be changed first"), scripts calling the API directly included.
This only works for the built-in administrator. Clearing another user's `password_hash` just makes that account unable to sign in; no password is restored.
## Resetting another user's password [#resetting-another-users-password]
Administrators can reset any user's password in the admin console, including another administrator's: on **Users**, open the action menu at the end of the user's row (**More actions**), choose **Reset password**, and enter a new password of at least 8 characters.
- The reset takes effect immediately and ends all of that user's sessions; they sign in again with the new password.
- The user is not required to change a reset password after signing in.
- Each reset writes an audit entry (action `user.update`, detail `password.reset`).
Users who still know their old password can change it themselves with **Change password** in the account menu in the sidebar. For the API, see the [API reference](../api/admin-users.mdx).
# Monitoring & alerts (/en/docs/operations/monitoring)
/healthz and /metrics, scraping with Prometheus, alert conditions and the webhook payload, and logs.
## /healthz [#healthz]
`GET /healthz` returns 200 with the plain-text body `ok` and needs no authentication. It only means the process is running and its HTTP server responds; it does **not** check the database, channels or the job queue. Use it for liveness checks and external uptime probes; for channel health, use metrics and alerts.
Requests to `/healthz` and `/metrics` themselves are not counted in the HTTP metrics.
## /metrics [#metrics]
`GET /metrics` serves metrics in the Prometheus text format on the same port as everything else.
**Access control**: only clients whose address is inside `IMGYOUNG_METRICS_ALLOW` (loopback and private ranges when the variable is unset) are allowed; everyone else gets 403. Setting the variable to an empty string (or only whitespace) closes `/metrics` to everyone. The client address is resolved by the rules in [Reverse proxy & HTTPS](./reverse-proxy.mdx). Metrics expose route templates, channel names and usage and should not be public:
- the public entry point (the reverse proxy) must never forward `/metrics`; the Caddy and Nginx examples in `deploy/` answer it with 404;
- let Prometheus reach ImgYoung's listen address directly over the internal network;
- get `IMGYOUNG_TRUSTED_PROXIES` right. If it is wrong, every proxied request appears to come from the proxy, and if the proxy's address is in the allowed networks, a proxy that forwards `/metrics` exposes the metrics to everyone.
### Metrics [#metrics-1]
All application metrics are prefixed with `imgyoung_`:
| Metric | Type | Labels | Meaning |
|---|---|---|---|
| `imgyoung_http_requests_total` | counter | `method`, `route`, `status` | HTTP requests. `route` is the route template (such as `/i/{id}`), not the raw path |
| `imgyoung_http_request_duration_seconds` | histogram | `method`, `route` | HTTP request latency in seconds |
| `imgyoung_upload_duration_seconds` | histogram | `outcome` | Upload pipeline duration in seconds. `ok` succeeded; `rejected` invalid input or over quota; `failed` infrastructure failure |
| `imgyoung_channel_puts_total` | counter | `channel`, `result` | Object writes to channels, both synchronous writes during upload and asynchronous replication; `result` is `ok` / `error` |
| `imgyoung_channel_storage_bytes` | gauge | `channel` | Logical channel usage in bytes: the sum of image sizes of completed replicas; refreshed every **Usage report interval** |
| `imgyoung_channels_total` | gauge | | Loaded channels, including unhealthy ones, excluding channels being deleted or that failed to load |
| `imgyoung_channels_healthy_total` | gauge | | Currently healthy channels |
| `imgyoung_variant_lookups_total` | counter | `result` | Variant cache lookups; `result` is `hit` / `miss` |
| `imgyoung_queue_jobs_waiting` | gauge | | Jobs waiting in the job queue (available, scheduled and retryable); queried at scrape time, `NaN` if the query fails |
| `imgyoung_queue_jobs_running` | gauge | | Jobs currently running; same as above |
The Prometheus Go client's own `go_*` (runtime) and `process_*` (process) metrics are exported as well.
### Prometheus scrape configuration [#prometheus-scrape-configuration]
```yaml title="prometheus.yml"
scrape_configs:
- job_name: imgyoung
static_configs:
- targets: ["10.0.0.5:8721"] # ImgYoung's internal address and port
```
If Prometheus is outside the default private ranges, add its address to `IMGYOUNG_METRICS_ALLOW` (together with any default ranges you still need — setting the variable replaces the default list).
A few useful alerting expressions:
```text title="PromQL"
# A channel is unhealthy
imgyoung_channels_healthy_total < imgyoung_channels_total
# Writes to a channel keep failing
sum by (channel) (rate(imgyoung_channel_puts_total{result="error"}[10m])) > 0
# Uploads failing because of infrastructure
sum(rate(imgyoung_upload_duration_seconds_count{outcome="failed"}[10m])) > 0
# Job queue backlog
imgyoung_queue_jobs_waiting > 1000
```
## Alerts [#alerts]
Alerts come from an evaluation loop that runs once at startup and then every **Health check interval** (`health_check_seconds`, 60 seconds by default). Each round checks which conditions currently hold and reconciles them with the open alerts: new conditions open an alert, conditions that no longer hold resolve theirs automatically. There is at most one open alert per kind and target; while a condition persists, its details are refreshed every round.
| Kind | UI label | Target | Condition |
|---|---|---|---|
| `channel_unhealthy` | Channel unhealthy | Channel name | The latest health check of an in-service channel failed, or the channel failed to load; details give the reason |
| `replication_failed` | Replication failed | Channel name | The channel has replicas whose replication failed; details give the count. Failed replicas are retried once the channel is healthy again |
| `channel_quota_exceeded` | Channel over quota | Channel name | A **Channel usage alert threshold** is set and the channel's usage exceeds it |
| `drain_failed` | Channel drain failed | Channel name | Opens on the first failure to delete an image replica or a system object this service wrote (metadata snapshots included) while a channel is being deleted with **Delete and purge**; details give the reason (the job's own timeout or a shutdown interruption does not count). Draining keeps retrying with backoff; once the channel is empty it is removed and the alert resolves. If the credentials are revoked or the bucket is gone, use **Remove now** on **Storage channels**; the remote objects are left as they are |
| `backup_failed` | Backup failed | Empty | The most recent metadata backup failed: no healthy channel, the snapshot could not be taken, or some channels were not written. Details give the reason; when some channels failed it reads like `快照 imgyoung-20261016-083000.123.db 有 1/3 个渠道未写成: 渠道 "r2-main": 上传快照: …`, and the channels that were written keep the snapshot |
`backup_failed` reflects the most recent backup in the current process, so after a restart it does not reappear until a backup fails again.
**Where to see them**: the **Alerts** card on the **Dashboard** lists open alerts; **History** leads to **Settings → Alert history**, which pages through every alert including resolved ones. Opening and resolving are also logged (`告警开启`, `告警恢复`).
### Webhook [#webhook]
With an `http` / `https` URL in **Settings → Runtime settings → Alerts & audit → Alert webhook URL**, every alert is POSTed as JSON (`Content-Type: application/json`) once when it **opens** and once when it **resolves**:
```json title="Opened"
{
"kind": "channel_unhealthy",
"target": "r2-main",
"message": "连接超时",
"status": "open",
"opened_at": "2026-10-15 08:30:00"
}
```
```json title="Resolved"
{
"kind": "channel_unhealthy",
"target": "r2-main",
"message": "连接超时",
"status": "resolved",
"opened_at": "2026-10-15 08:30:00",
"resolved_at": "2026-10-15 09:05:00"
}
```
| Field | Meaning |
|---|---|
| `kind` | Alert kind, see the table above |
| `target` | Alert target (a channel name); empty string for `backup_failed` |
| `message` | Description from the latest evaluation (in Chinese) |
| `status` | `open` or `resolved` |
| `opened_at` | When the alert opened, UTC, formatted `YYYY-MM-DD HH:MM:SS` |
| `resolved_at` | When it resolved, same format; present only when `status` is `resolved` |
Delivery rules:
- any 2xx response counts as delivered; connection errors, timeouts (10 seconds) and non-2xx responses are retried on the next evaluation round until they succeed;
- for any one alert, the opening is always delivered before the resolution;
- requests carry no signature or authentication header; if the receiver needs to verify the sender, put a secret in the URL (for example as a query parameter) and use `https`;
- a transition that happens while no webhook is configured is marked as delivered and is not sent later, even if a webhook is configured afterwards;
- the payload format is fixed; to post to a chat tool, put a small relay in between that converts the format.
## Logs [#logs]
- Logs go to standard error in Go's `log/slog` text format: each line starts with `time=`, `level=` and `msg=`, followed by `key=value` fields; messages are in Chinese. The level is fixed at INFO and above; there is no setting to change it.
- To read them: `journalctl -u imgyoung` for systemd, `docker logs imgyoung` for Docker, `docker compose logs imgyoung` for Compose.
- Credentials stay out of the logs: channel configuration parameters never appear in logs or error messages, the Cloudflare API token exists in plaintext only in memory, and audit entries for configuration changes only record `(已配置)` (set) or `(空)` (empty). Upload tokens and sessions are stored as hashes only. The one password ever logged is the factory password seeded on first start (or after a [password reset](./password-reset.mdx)), which is public anyway.
Log lines worth knowing when troubleshooting:
| Message | Meaning |
|---|---|
| `ImgYoung 已启动` | Startup finished; includes version, commit, actual listen address and data directory |
| `已生成主密钥文件,请立即备份……` | The data directory holds a brand-new database and no master key, so a new `master.key` was generated. Outside a first deployment this usually means the data directory path is wrong or the volume is not mounted |
| `启动失败` with `err` containing `主密钥与数据库不符` or `找不到主密钥` | The database records a master key fingerprint and the current key does not match it, or `IMGYOUNG_MASTER_KEY` is unset and `master.key` does not exist. Put the original master key back and start again |
| `CDN purge 已启用(Cloudflare)` / `CDN purge 未启用……` | Whether CDN cache purging was configured at startup |
| `渠道装载失败,本渠道跳过,健康检查时重试` | A channel's configuration could not be decrypted or its driver could not be built; the channel shows as unhealthy with a reason starting with `装载失败:`, and loading is retried on every health check |
| `告警开启` / `告警恢复` | An alert opened or resolved |
| `元数据库快照已备份` | A snapshot was written to a channel; one line per channel, with fields `channel`, `key` and `bytes` |
| `启动备份未完成` / `定时备份未完成` | This backup round failed; `err` matches the details of the `backup_failed` alert |
| `渠道对账完成` | Result of the daily reconciliation GC, with fields `objects`, `bytes`, `foreign` (objects with no record whose key is in another format or that predate the channel's binding time; counted, never deleted), `orphans_deleted` and `phantoms_requeued`; see [Backup & restore](./backup.mdx) |
| `渠道已清理完毕并删除` | A channel deleted with **Delete and purge** has been emptied and its record removed; fields `objects` (image replicas deleted) and `system_objects` (system objects deleted, metadata snapshots and deep check leftovers included) |
| `收到停机请求,优雅停机后由进程管理器拉起` | A restore from snapshot was triggered in the admin console |
| `已用暂存快照换入元数据库,原库留存` | The snapshot was swapped in; includes where the previous database was kept |
| `换入快照后已把渠道绑定时刻重置为当前时刻,快照之后写入渠道的对象不会被对账回收` | Follow-up after swapping in a snapshot; the `channels` field gives the number of channels reset; see [Backup & restore](./backup.mdx) |
# Using it as a Go library (/en/docs/library)
ImgYoung's storage layer ships as three public packages you can go get — a driver interface, an rclone implementation, and multi-store failover primitives.
ImgYoung's multi-store reads and writes, channel encryption, and failover are exposed as public Go packages, and the
application itself reaches storage through these same packages. If your own Go project needs to write one object to
several stores, fall back automatically when reading, or check whether a store is usable, you can reuse them directly.
## The three packages [#the-three-packages]
| Package | What it provides | Depends on |
|---|---|---|
| `store/driver` | The `Driver` interface (`Put` / `Open` / `Stat` / `Delete` / `List`), `ObjectInfo`, and the sentinel `ErrNotFound` | Standard library only |
| `store/rclone` | A `Driver` backed by any rclone backend, with an optional crypt layer, OAuth token write-back, password parameter encoding, and export of an rclone configuration file equivalent to the driver | `store/driver` and rclone |
| `store/failover` | Stateless failover primitives over a list of `Driver`s: write until N copies succeed, read from the first readable one, light and deep health checks | `store/driver` only |
```text
store/failover ──→ store/driver ←── store/rclone ──→ github.com/rclone/rclone
```
- `store/driver` imports neither rclone nor any concrete backend. You can implement `Driver` yourself and never touch rclone.
- `store/rclone` is an optional implementation: rclone is compiled in only if you import it. It registers every
rclone backend, so `Backend` accepts any backend name rclone supports.
- `store/failover` works purely against the `driver.Driver` interface and treats rclone drivers and your own drivers the same way.
None of the three packages depend on the application's `internal/` code; a lint rule (depguard) enforces that boundary.
## Installation [#installation]
```bash title="Terminal"
# Interface and failover primitives only (bring your own driver)
go get github.com/x-haose/imgyoung/store/failover@latest
# Plus the rclone implementation
go get github.com/x-haose/imgyoung/store/rclone@latest
```
The module's `go.mod` declares Go 1.26.9; your toolchain must be at least that version.
## One module, only the packages you use get compiled [#one-module-only-the-packages-you-use-get-compiled]
`store/` lives in the same module as the application, `github.com/x-haose/imgyoung`; it is not split into a separate
module, because a separate module would need `replace` directives to tie the two together during development in the repository. Go compiles only imported packages: if you import just `store/driver` and `store/failover`, neither rclone nor
any application code ends up in your binary.
The size difference is substantial. A minimal program that prints one line, built on darwin/arm64 (not stripped):
| Imported package | Binary size |
|---|---|
| `store/failover` (pulls in `store/driver`) | about 2.7 MB |
| `store/rclone` | about 100 MB |
The second one is large because `store/rclone` imports rclone's `backend/all`, which registers every backend at once.
Once you import any `store` package, this module joins your dependency graph, and the module versions listed in its
`go.mod` take part in Go's minimal version selection (MVS). If your project also depends on one of those modules (the
AWS SDK, `golang.org/x/...`, and so on), its version may be raised to at least what this module requires. This only
affects version selection; what gets compiled, and how large the binary is, still depends only on the packages you import.
## Compatibility promise [#compatibility-promise]
Exported identifiers in all three packages stay backward compatible within major version v1: types, functions,
methods, fields, and sentinel errors will not be removed or change signature.
- Error message text is written in Chinese; do not depend on its content. Classify errors with `errors.Is` against the
exported sentinels (`driver.ErrNotFound`, `failover.ErrUnavailable`, and so on).
- Backend parameter names and behavior in `store/rclone` come from rclone itself and follow the rclone version pinned in
this module's `go.mod`.
- Packages under `internal/` cannot be imported from outside the module (a Go rule) and are not covered.
## What the library does not include [#what-the-library-does-not-include]
`store` only performs one operation against an ordered list of drivers. It keeps no state and runs nothing in the
background. The following pieces are implemented in the application, layered on top of the same primitives; if your
project needs them, you build them yourself:
| Capability | Description | Where the application implements it |
|---|---|---|
| Replica state persistence | Which drivers hold a copy of which object. `failover.Write` only returns the outcome of each attempt; recording it is up to you | `blob_channels` table |
| Asynchronous replication | Return once N copies are written and fill in the remaining drivers in the background. See [Failover · Building your own replication](./failover.mdx#replication) | `internal/core/replication` |
| Reconciliation | Periodically `List` each store, compare against your records in both directions, delete orphans, and re-copy missing replicas | `internal/core/replication` |
| Scheduling | Health check intervals, retries with backoff, a task queue | `cmd/imgyoung`, the River task queue |
| Driver instance management | Rebuild a driver and `Close` the old one when its configuration changes; keep health state and switching policy | `internal/core/channel` |
| Cloud drive authorization | The library only writes back refreshed tokens; obtain the initial token with `rclone authorize` | `internal/core/channel` |
For how the application puts these pieces together, see [Architecture](../architecture.mdx).
## Next steps [#next-steps]
The Driver contract, rclone.Open options, password encoding, OAuth token write-back, the crypt layer, and writing your own driver.
Semantics of Write / Open / Probe / DeepProbe, a complete primary/backup example, and building replication on top of the primitives.
# Driver interface and rclone implementation (/en/docs/library/driver)
The Driver contract, rclone.Open options, password parameter encoding, OAuth token write-back, the crypt layer, and writing your own driver.
## The Driver interface [#the-driver-interface]
`store/driver` defines exactly one thing: store bytes in some backend, read them back, inspect, delete, and list them.
```go
type Driver interface {
Put(ctx context.Context, key string, r io.Reader, size int64) error
Open(ctx context.Context, key string) (io.ReadCloser, error)
Stat(ctx context.Context, key string) (*ObjectInfo, error)
Delete(ctx context.Context, key string) error
List(ctx context.Context, dir string) ([]ObjectInfo, error)
}
```
| Method | Semantics | When the object is missing |
|---|---|---|
| `Put` | Streams `size` bytes from `r` into `key`. `size` must be known up front; the caller owns and closes `r` | — |
| `Open` | Returns a reader for the object; the caller must `Close` it | Returns an error wrapping `ErrNotFound` |
| `Stat` | Returns the object's metadata | Returns an error wrapping `ErrNotFound` |
| `Delete` | Deletes the object | Returns an error wrapping `ErrNotFound`; the caller decides whether that counts as success |
| `List` | Recursively lists every object under `dir`; an empty `dir` means the whole store. Returned keys are full paths relative to the store root | A missing directory yields an empty result, not an error |
Rules every implementation follows:
- **Safe for concurrent use**: one driver instance may be used from many goroutines at once.
- **Honors `ctx`**: every method respects cancellation and deadlines on `ctx`.
- **Detect missing objects with `errors.Is`**: implementations wrap `ErrNotFound` with context such as the key, so never compare with `==`.
- **Keys use `/` as the separator**, like `photos/2026/a.png`, relative to the driver's root path.
`ObjectInfo` is what `Stat` and `List` return:
## The rclone implementation [#the-rclone-implementation]
`store/rclone` implements `Driver` on top of rclone backends. It builds rclone's `fs.Fs` directly in-process: parameters
are passed to the backend as a map, with no rclone config file, no connection string, and no rclone subprocess.
### Opening a driver [#opening-a-driver]
```go
d, err := rclone.Open(ctx, rclone.Options{
Backend: "s3",
Params: map[string]string{
"provider": "Cloudflare",
"access_key_id": "",
"secret_access_key": "",
"endpoint": "https://.r2.cloudflarestorage.com",
},
Path: "my-bucket/images",
})
if err != nil {
return err
}
defer func() { _ = d.Close(ctx) }()
```
Fields of `rclone.Options`:
Errors returned by `Open` never carry the parameter map, so credentials in it cannot leak into logs through error messages.
### Content-Type on write [#content-type-on-write]
`Put` streams the data; the driver itself never buffers the whole object. It first reads up to 512 bytes from the start of the
data, determines the Content-Type with `net/http.DetectContentType` (the WHATWG content sniffing algorithm), hands it to
the backend along with the object, and then writes the remaining bytes. Object keys need no file extension: objects in
S3 and other object stores get their real type (such as `image/png`) instead of `application/octet-stream`, so opening
the store's public link directly in a browser displays the image instead of downloading it.
- Encrypted drivers do not pass the type on to the store: rclone's crypt layer deliberately hides the plaintext type, and the stored object is ciphertext.
- Backends that do not record a Content-Type (such as `local`) ignore it.
### s3 skips the bucket check by default [#s3-skips-the-bucket-check-by-default]
When `Backend` is `s3` and `Params` has no `no_check_bucket`, the driver adds `no_check_bucket=true`. An explicit value is never overridden.
The reason: S3-compatible stores such as R2 and MinIO are often accessed with credentials scoped to a bucket, without
permission to create buckets. By default rclone tries to create the bucket before uploading; when the bucket exists but
the credentials may not create it, the request fails with 403 and the write fails, while a read-only health check (see
[Multi-store failover](./failover.mdx)) still passes. The store looks healthy but rejects writes. The cost of skipping
the check is that **the bucket must already exist**.
### EnsureBucket [#ensurebucket]
When your program needs to create the bucket, use `(*rclone.Driver).EnsureBucket`. It makes the root directory, which
for s3 creates the bucket named in `Path`; an existing bucket is not an error.
```go
b, err := rclone.Open(ctx, rclone.Options{
Backend: "s3",
Params: map[string]string{
"provider": "Cloudflare",
"access_key_id": "",
"secret_access_key": "",
"endpoint": "https://.r2.cloudflarestorage.com",
"no_check_bucket": "false", // required, otherwise bucket creation is skipped and EnsureBucket does nothing
},
Path: "my-bucket",
})
if err != nil {
return err
}
defer func() { _ = b.Close(ctx) }()
if err := b.EnsureBucket(ctx); err != nil {
return err // usually the credentials lack permission to create buckets
}
```
`EnsureBucket` is not part of the `Driver` interface because it only makes sense for object storage. The application
opens a temporary driver with `no_check_bucket=false` just for this call, and keeps using a driver with the default
settings for everyday reads and writes.
### Encoding password parameters [#encoding-password-parameters]
rclone expects some parameters in the encoded form produced by `rclone obscure`, for example `pass` for webdav, sftp,
and ftp. Which parameters these are is declared by each backend's option metadata. The driver hands `Params` to the
backend unchanged, so these values must be **encoded beforehand**: a plaintext value left unencoded is decoded by the
backend into garbage, and authentication fails even though the credential is correct. Not every secret is in this
category; s3's `secret_access_key`, for instance, is passed as-is.
The package provides two functions for the encoding. Both pick the password parameters from the backend's metadata,
modify the map in place, skip empty values, and return an error for an unknown backend name:
| | `ObscureSecrets` | `ObscurePlainSecrets` |
|---|---|---|
| Which values it encodes | Every password parameter | Password parameters that are not already encoded |
| Use it when | The values are known to be plaintext, such as form input | The source is unknown: plaintext, or an encoded value copied from rclone.conf |
| When it guesses wrong | An already-encoded value gets encoded twice; authentication fails | A plaintext value that happens to look encoded passes through unchanged; authentication fails |
`ObscurePlainSecrets` decides "already encoded" the same way rclone does: a value rclone can decode counts as encoded,
matching `rclone config create` run without `--obscure` / `--no-obscure`. If it misjudges a plaintext value, encode it
with `rclone obscure` first and pass the result. The application uses `ObscureSecrets` for its console template forms
(the values are known to be plaintext) and `ObscurePlainSecrets` for custom parameters (both forms are accepted).
```go
params := map[string]string{
"url": "https://dav.example.com/remote.php/dav/files/me",
"vendor": "nextcloud",
"user": "me",
"pass": "plaintext password",
}
if err := rclone.ObscureSecrets("webdav", params); err != nil {
return err
}
d, err := rclone.Open(ctx, rclone.Options{Backend: "webdav", Params: params, Path: "images"})
```
A related helper, `SensitiveParams(backend)`, returns the names of the parameters a backend declares as passwords or
sensitive (`IsPassword` or `Sensitive` in its option metadata), sorted and without duplicates, by the same rule as
`rclone config redacted`: besides passwords, keys and tokens, identity information such as access key IDs and account
names is included. Use it to decide which values to mask when echoing a configuration and which to scrub from error
messages; the application's edit read-back and error scrubbing rely on it.
### OAuth token write-back and OnChange [#oauth-token-write-back-and-onchange]
Cloud drive backends such as Google Drive, OneDrive, and Dropbox use OAuth. The library has no authorization flow: run
`rclone authorize "drive"` on your machine (substituting the backend name), and put the resulting JSON token in
`Params["token"]`. If you authorized with your own app, put its `client_id` and `client_secret` in `Params` as well:
a token can only be renewed by the app that issued it.
`Open` only constructs the backend; it does not run the configuration steps `rclone config` performs after
authorization. Some backends need parameters written by those steps — OneDrive, for example, needs `drive_id` and
`drive_type`. Configure the remote once with `rclone config` on your machine, then copy those values from rclone.conf into `Params`.
When the token is about to expire, rclone refreshes it itself and writes the new value back as a parameter. On a
write-back, the driver first updates its in-memory parameters and then calls `OnChange(key, value)`.
- With a nil `OnChange`, the new value lives only in memory, and after a restart the process starts again from the old
token. For providers that rotate the refresh token on every refresh, the old token is already void by then, so
authorization breaks after the restart. With cloud drive backends, always supply `OnChange` and persist the value.
- The callback may run on any goroutine, including before `Open` returns (write-backs during construction). It runs
synchronously on rclone's call path, so return quickly and make it safe for concurrent use.
- The callback has no return value; if persisting fails, logging it is up to you.
- Do not run a separate token refresh loop. Two refreshers invalidate each other's refresh tokens. Calling a read-only
operation periodically (for example [`failover.Probe`](./failover.mdx)) is enough to keep rclone refreshing on time.
```go
d, err := rclone.Open(ctx, rclone.Options{
Backend: "drive",
Params: map[string]string{"token": tokenJSON},
Path: "imgyoung",
OnChange: func(key, value string) {
if err := saveParam(key, value); err != nil { // saveParam: your persistence function
log.Printf("persist parameter %s: %v", key, err)
}
},
})
```
### The crypt layer [#the-crypt-layer]
When `Crypt` is non-nil, the driver stacks rclone's crypt layer on top of the backend: object content is encrypted as
it streams, and file names are encrypted with rclone's `standard` mode. Reads and writes through the driver encrypt and
decrypt transparently, and `List` returns decrypted keys; the storage provider only ever sees ciphertext and encrypted names.
Under the hood, `Open` first builds the inner backend, registers it in rclone's process-wide fs cache under a random
name (like `imgyoung-0123abcd`), and then builds the crypt layer with that name as its `remote`.
Password and Salt are the only way to decrypt. Without them, the objects in the store cannot be recovered.
crypt is a public rclone format, so standard rclone can decrypt the data without this library: generate a
configuration file with `ConfigFile`, described in the next section. The application's channel encryption is built on
this same layer, with passwords derived from the master key; see [Channel encryption](../channels/encryption.mdx).
### Exporting an rclone configuration file [#config-file]
```go
func ConfigFile(opts Options, remote, cryptRemote string) (string, error)
```
`ConfigFile` produces the text of an rclone configuration file (rclone.conf) equivalent to the driver `Open(opts)`
builds, so standard rclone can read and write the same data with it:
- The backend section is named `remote`, and its parameters are exactly what the driver hands to the backend,
including the defaults this package adds (such as s3's `no_check_bucket = true`). Empty values are omitted, because
both the driver and rclone treat an empty value as unset. Password parameters are already in rclone's encoded form
by the `Options` convention and are written as they are.
- When `opts.Crypt` is non-nil, a second crypt section named `cryptRemote` is written: its `remote` points to
`:`, `password` and `password2` are the encoded `Password` and `Salt`, and
`filename_encryption = standard`, matching the driver's crypt layer. Reads and writes through it are plaintext.
- Without encryption, the backend section carries no path; use it as `:`. `OnChange` plays no part.
- Equivalence is guaranteed by construction: `ConfigFile` and `Open` share the same default filling and crypt
parameter generation, so they never compute things separately.
```go
text, err := rclone.ConfigFile(rclone.Options{
Backend: "s3",
Params: map[string]string{
"provider": "Cloudflare",
"access_key_id": "",
"secret_access_key": "",
"endpoint": "https://.r2.cloudflarestorage.com",
},
Path: "my-bucket/images",
Crypt: &rclone.CryptOptions{Password: "", Salt: ""},
}, "mystore", "mystore-crypt")
```
The resulting text looks like this (encoding uses a random IV, so `password` and `password2` differ on every call,
yet always decode to the same values):
```ini title="rclone.conf"
[mystore]
type = s3
access_key_id =
endpoint = https://.r2.cloudflarestorage.com
no_check_bucket = true
provider = Cloudflare
secret_access_key =
[mystore-crypt]
type = crypt
filename_encryption = standard
password =
password2 =
remote = mystore:my-bucket/images
```
```bash title="Terminal"
rclone --config rclone.conf ls mystore-crypt:
```
The text is serialized with the same INI library rclone uses for its configuration files, then read back the way
rclone reads it and checked. If a parameter cannot be written into the file as it is, the function returns an error
wrapping `ErrUnrepresentable` instead of producing a file that is not equivalent to the driver. The error text names
only the section and the parameter, never the value (the text itself is in Chinese):
| Situation | Error text |
|---|---|
| A parameter value contains a line break (`\n` or `\r`) | `参数值无法写进 rclone 配置文件: 远端 "" 的参数 "" 含换行` |
| The generated text cannot be parsed the way rclone reads it | `参数值无法写进 rclone 配置文件: 生成的文本按 rclone 的读法无法解析` |
| A parameter reads back as something other than its original value (leading or trailing whitespace, a `%(name)s` style reference, and so on) | `参数值无法写进 rclone 配置文件: 远端 "" 的参数 ""` |
Other errors: an unknown backend name, a section name that breaks rclone's naming rules (only letters, digits, `_`,
`-`, `.`, `+`, `@` and spaces), or a failure to encode a password.
The configuration file contains backend credentials and authorization tokens; rclone's encoding is reversible, not
encryption. For encrypted channels it also contains the decryption password. Keep it offline, to the same standard as the master key.
### Close [#close]
Always call `Close(ctx)` when you are done with a driver. If the backend provides a shutdown hook (rclone's
`Shutdowner`), it stops the backend's background tasks and connection pools. An encrypted driver also removes the inner
backend from rclone's fs cache, and the cache closes the inner backend as it is removed. Do not use a driver after `Close`.
`Close` is a method on `*rclone.Driver`, not part of the `Driver` interface. If you pass drivers around as
`driver.Driver`, keep the concrete pointer as well so you can close it.
### Complete example [#complete-example]
This is `store/rclone/example_test.go` from the repository. It runs with `go test ./store/rclone`, and each example
prints exactly what its `Output` comment lists (the comments in the source are in Chinese): `ExampleSensitiveParams`
queries the sensitive parameters of the webdav backend, and `Example` walks through write, stat, read back, and delete
on rclone's `memory` backend.
```go title="store/rclone/example_test.go"
package rclone_test
import (
"context"
"errors"
"fmt"
"io"
"strings"
"github.com/x-haose/imgyoung/store/driver"
"github.com/x-haose/imgyoung/store/rclone"
)
// ExampleSensitiveParams 查询 webdav 后端的敏感参数:密码(IsPassword)与账号名、令牌(Sensitive)都在其中。
func ExampleSensitiveParams() {
names, err := rclone.SensitiveParams("webdav")
if err != nil {
fmt.Println("sensitive params:", err)
return
}
fmt.Println(strings.Join(names, " "))
// Output:
// bearer_token pass user
}
// Example 用 rclone 的内存后端走一遍驱动的基本操作:写入、查询、读回、删除。
func Example() {
ctx := context.Background()
d, err := rclone.Open(ctx, rclone.Options{Backend: "memory", Path: "bucket"})
if err != nil {
fmt.Println("open:", err)
return
}
defer func() { _ = d.Close(ctx) }()
if err := d.Put(ctx, "hello.txt", strings.NewReader("hello"), 5); err != nil {
fmt.Println("put:", err)
return
}
info, err := d.Stat(ctx, "hello.txt")
if err != nil {
fmt.Println("stat:", err)
return
}
fmt.Println(info.Key, info.Size)
rc, err := d.Open(ctx, "hello.txt")
if err != nil {
fmt.Println("open object:", err)
return
}
data, err := io.ReadAll(rc)
_ = rc.Close()
if err != nil {
fmt.Println("read:", err)
return
}
fmt.Println(string(data))
if err := d.Delete(ctx, "hello.txt"); err != nil {
fmt.Println("delete:", err)
return
}
_, err = d.Stat(ctx, "hello.txt")
fmt.Println(errors.Is(err, driver.ErrNotFound))
// Output:
// hello.txt 5
// hello
// true
}
```
The `memory` and `local` backends are well suited to integration tests: the first keeps data in process memory only,
the second pairs with `t.TempDir()`, and neither needs a cloud account.
## Writing your own driver [#writing-your-own-driver]
Without rclone, implement `driver.Driver` yourself and it works with `store/failover` as is. Key points:
1. **Return `ErrNotFound` for missing objects**: when `Open`, `Stat`, or `Delete` cannot find the object, return an
error that `errors.Is(err, driver.ErrNotFound)` matches. `failover.Open` relies on it to tell "object does not
exist" from "store is broken", and `failover.Probe` treats `ErrNotFound` from `Stat` as "store reachable".
2. **`Put` reads exactly `size` bytes** and returns an error if it gets fewer; do not close `r`. After a failure the
caller may retry with the same key, and the new `Put` should completely replace whatever was left behind.
3. **`List` is recursive**: an empty `dir` means all objects; keys are full paths relative to the root; a missing
directory returns an empty result and nil.
4. **Fill `ModTime` with the real modification time**: callers use it to judge how old an object is. The application's
reconciliation, for example, only reclaims unknown objects whose modification time is older than its grace period.
5. **Be safe for concurrent use and honor `ctx`.** Check that the implementation is complete with a compile-time
assertion: `var _ driver.Driver = (*MemDriver)(nil)`.
Here is an implementation that keeps objects in process memory, ready to use in tests:
```go
package memstore
import (
"bytes"
"context"
"fmt"
"io"
"strings"
"sync"
"time"
"github.com/x-haose/imgyoung/store/driver"
)
// MemDriver is a Driver that keeps objects in process memory.
type MemDriver struct {
mu sync.RWMutex
objects map[string]memObject
}
type memObject struct {
data []byte
modTime time.Time
}
var _ driver.Driver = (*MemDriver)(nil)
func New() *MemDriver {
return &MemDriver{objects: map[string]memObject{}}
}
func (m *MemDriver) Put(ctx context.Context, key string, r io.Reader, size int64) error {
data, err := io.ReadAll(io.LimitReader(r, size))
if err != nil {
return fmt.Errorf("read %q: %w", key, err)
}
if int64(len(data)) != size {
return fmt.Errorf("write %q: want %d bytes, got %d: %w", key, size, len(data), io.ErrUnexpectedEOF)
}
if err := ctx.Err(); err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
m.objects[key] = memObject{data: data, modTime: time.Now()}
return nil
}
func (m *MemDriver) Open(ctx context.Context, key string) (io.ReadCloser, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
m.mu.RLock()
defer m.mu.RUnlock()
o, ok := m.objects[key]
if !ok {
return nil, fmt.Errorf("object %q: %w", key, driver.ErrNotFound)
}
return io.NopCloser(bytes.NewReader(o.data)), nil
}
func (m *MemDriver) Stat(ctx context.Context, key string) (*driver.ObjectInfo, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
m.mu.RLock()
defer m.mu.RUnlock()
o, ok := m.objects[key]
if !ok {
return nil, fmt.Errorf("object %q: %w", key, driver.ErrNotFound)
}
return &driver.ObjectInfo{Key: key, Size: int64(len(o.data)), ModTime: o.modTime}, nil
}
func (m *MemDriver) Delete(ctx context.Context, key string) error {
if err := ctx.Err(); err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.objects[key]; !ok {
return fmt.Errorf("object %q: %w", key, driver.ErrNotFound)
}
delete(m.objects, key)
return nil
}
func (m *MemDriver) List(ctx context.Context, dir string) ([]driver.ObjectInfo, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
prefix := ""
if dir != "" {
prefix = strings.TrimSuffix(dir, "/") + "/"
}
m.mu.RLock()
defer m.mu.RUnlock()
var out []driver.ObjectInfo
for k, o := range m.objects {
if strings.HasPrefix(k, prefix) {
out = append(out, driver.ObjectInfo{Key: k, Size: int64(len(o.data)), ModTime: o.modTime})
}
}
return out, nil
}
```
# Multi-store failover (/en/docs/library/failover)
The write, read, and health check primitives in store/failover, a complete primary/backup example, and building replication on top of them.
`store/failover` provides four stateless functions over a list of drivers. **The order of the driver slice is the
priority**: which drivers take part, which are healthy, and which ones hold a copy of an object are all decided by the
caller, who passes them in already sorted. The package itself keeps no state.
```go
func Write(ctx context.Context, ds []driver.Driver, key string, r io.ReaderAt, size int64,
opts WriteOptions) ([]Attempt, error)
func Open(ctx context.Context, ds []driver.Driver, key string) (io.ReadCloser, []Attempt, error)
func Probe(ctx context.Context, d driver.Driver) error
func DeepProbe(ctx context.Context, d driver.Driver) error
```
`Write` and `Open` both return `[]Attempt`, recording every driver actually tried, in the order tried:
## Write: write in order until N copies succeed [#write-write-in-order-until-n-copies-succeed]
```go
attempts, err := failover.Write(ctx, ds, key, bytes.NewReader(data), int64(len(data)),
failover.WriteOptions{Need: 2, AttemptTimeout: time.Minute})
```
= 1. Writing stops as soon as it is reached; remaining drivers are not written' },
AttemptTimeout: { type: 'time.Duration', description: 'Timeout for one write to one driver; 0 means only ctx applies', default: '0' },
}}
/>
- **Drivers are written one at a time in slice order, never concurrently.** When a driver fails, the next one is tried;
once `Need` copies succeed the call returns and later drivers are not touched.
- **Each attempt reads the data from the beginning.** The source is an `io.ReaderAt` plus `size`, and every attempt
reads `[0, size)` independently, so a driver that fails halfway does not affect the next one. `*bytes.Reader` and
`*os.File` both satisfy `io.ReaderAt`.
- **Cancelling `ctx` stops immediately**: `ctx` is checked before each attempt; once it is cancelled no further drivers
are tried and `ctx.Err()` is returned, not `ErrInsufficient`. A failure caused by cancellation says nothing about the drivers.
- **Attempts with `Err == nil` are the drivers that now hold a copy.** Even on success, `attempts` may include failed attempts.
- **Failed writes are not cleaned up**: a failed driver may be left with a partial object. Delete it yourself if
needed, or let reconciliation deal with it.
- A `Need` larger than the number of drivers is not rejected up front; all drivers are tried, then `ErrInsufficient` is returned.
| Error | Meaning |
|---|---|
| `ErrInvalidNeed` | `opts.Need` is less than 1; checked first |
| `ErrNoDrivers` | The driver slice is empty |
| `ErrInsufficient` | Every driver was tried and there are still too few copies; the same error also wraps the cause of each failure |
| The `ctx` error | `ctx` was cancelled or hit its deadline partway through |
## Open: read from the first readable driver [#open-read-from-the-first-readable-driver]
```go
rc, attempts, err := failover.Open(ctx, ds, key)
if err != nil {
return err
}
defer func() { _ = rc.Close() }()
```
- Drivers are opened in slice order and **the first successful open is returned immediately**; later drivers are not
touched. The caller closes the reader.
- On success, the last element of `attempts` is the driver serving the reader; the earlier ones are failed attempts,
useful for logs and metrics.
- **Fallback only happens while opening.** If the reader fails partway through reading, it does not switch to another driver.
- There is no per-attempt timeout, only `ctx`; `ctx` is checked before each attempt, and once it is cancelled
`ctx.Err()` is returned. With rclone drivers, `ctx` usually also governs the returned reader (for HTTP-based backends,
cancelling `ctx` aborts the response body), so do not cancel it before you finish reading.
When every driver fails, there are two kinds of error:
| Situation | Error returned | Meaning |
|---|---|---|
| Every driver reports the object does not exist | Wraps `driver.ErrNotFound` | The object really is missing |
| At least one driver failed for a reason other than "not found" | Wraps `ErrUnavailable`; each driver's failure appears only in the error text and is not wrapped | Some driver is broken; the object may still exist |
An empty driver slice returns `ErrNoDrivers`.
The two kinds are mutually exclusive: `errors.Is(err, driver.ErrNotFound)` is true only when every driver confirmed
the object does not exist, and always false when some driver is broken. An `ErrUnavailable` error does not wrap the
drivers' original errors; to examine why a particular driver failed, look at the returned attempts:
```go
rc, attempts, err := failover.Open(ctx, ds, key)
switch {
case errors.Is(err, driver.ErrNotFound):
// Every driver confirmed the object does not exist: e.g. HTTP 404
case errors.Is(err, failover.ErrUnavailable):
// Some driver is broken and the object may still exist: treat as temporarily unavailable, e.g. HTTP 503
for _, a := range attempts {
log.Printf("driver %d: %v", a.Index, a.Err)
}
case err != nil:
// ErrNoDrivers, or ctx was cancelled
default:
defer func() { _ = rc.Close() }()
// read from rc
}
```
## Probe and DeepProbe: health checks [#probe-and-deepprobe-health-checks]
| | `Probe` (light check) | `DeepProbe` (deep check) |
|---|---|---|
| What it does | One `Stat` on the reserved key `ProbeKey` | Writes a small object under a random key, reads it back and compares byte for byte, then deletes it |
| Healthy when | `Stat` succeeds or returns `driver.ErrNotFound` | Write, read, and delete all succeed |
| Cost | One read-only request; no writes or deletes | A write and a delete every time |
| What it catches | Unreachable store, invalid credentials | Additionally catches "readable but not writable": read-only credentials, a full bucket, an exhausted quota |
| Good for | Periodic checks | Event-driven checks: creation, configuration changes, manual rechecks, confirming recovery |
`Probe`'s timeout is entirely up to `ctx`, so always call it with a deadline. `ProbeKey` is `.imgyoung-health-probe`; by
convention it is never written, so do not store data under that key.
Details of `DeepProbe`:
- The temporary object key is `.imgyoung-probe-` followed by 16 random hex characters, and the content is a fixed 19
bytes; random keys keep concurrent deep checks from interfering with each other.
- The write and the read-back are bound by `ctx`. The delete uses its own 10-second timeout detached from `ctx`
cancellation, so it still tries to clean up after a failed read-back or an expired `ctx`.
- A failure in the write, the read-back, or the content comparison returns the corresponding error. If the read-back
already failed, the delete result no longer affects the return value; if the read-back succeeded but the delete
failed (with anything other than `driver.ErrNotFound`), the delete error is returned.
- A failed delete leaves a temporary object behind. `failover.IsDeepProbeKey(key)` reports whether a key is a deep check temporary object, so your cleanup logic can claim and reclaim it.
A successful read-only check does not mean the store accepts writes. A safe combination, and the one the application
uses, is "downgrade only": periodic light checks can only mark a healthy store unhealthy; an unhealthy store whose light
check passes must also pass a deep check (write, read, and delete all succeed) before it is marked healthy again. That
way a store with read-only credentials, which passes the light check but rejects writes, is not mistaken for recovered
on the next light check.
## Complete example [#complete-example]
This is `store/failover/example_test.go` from the repository. Two local directories act as the primary and backup
stores. The primary's root path sits under a regular file, so every write to it fails, simulating a broken primary;
the write falls through to the backup, and the read switches to the backup as well. It runs with
`go test ./store/failover` and prints exactly what the `Output` comment at the end lists (the comments in the source are in Chinese):
```go title="store/failover/example_test.go"
package failover_test
import (
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"github.com/x-haose/imgyoung/store/driver"
"github.com/x-haose/imgyoung/store/failover"
"github.com/x-haose/imgyoung/store/rclone"
)
// Example 两个本地目录充当主、备存储,主存储故障时写入顺延到备存储,读取同样切到备存储。
func Example() {
ctx := context.Background()
dir, err := os.MkdirTemp("", "failover-example")
if err != nil {
fmt.Println("mkdir:", err)
return
}
defer func() { _ = os.RemoveAll(dir) }()
// 主存储的根落在一个普通文件之下,任何写入都会失败,以此模拟主存储故障。
blocker := filepath.Join(dir, "blocker")
if err := os.WriteFile(blocker, nil, 0o600); err != nil {
fmt.Println("write blocker:", err)
return
}
primary, err := rclone.Open(ctx, rclone.Options{Backend: "local", Path: filepath.Join(blocker, "data")})
if err != nil {
fmt.Println("open primary:", err)
return
}
defer func() { _ = primary.Close(ctx) }()
backup, err := rclone.Open(ctx, rclone.Options{Backend: "local", Path: filepath.Join(dir, "backup")})
if err != nil {
fmt.Println("open backup:", err)
return
}
defer func() { _ = backup.Close(ctx) }()
ds := []driver.Driver{primary, backup} // 顺序即优先级
data := []byte("hello")
written, err := failover.Write(ctx, ds, "a.txt", bytes.NewReader(data), int64(len(data)),
failover.WriteOptions{Need: 1})
if err != nil {
fmt.Println("write:", err)
return
}
for _, a := range written {
fmt.Println("write to", a.Index, "ok:", a.Err == nil)
}
rc, read, err := failover.Open(ctx, ds, "a.txt")
if err != nil {
fmt.Println("read:", err)
return
}
got, err := io.ReadAll(rc)
_ = rc.Close()
if err != nil {
fmt.Println("read all:", err)
return
}
fmt.Println(string(got), "from", read[len(read)-1].Index)
// Output:
// write to 0 ok: false
// write to 1 ok: true
// hello from 1
}
```
## Building your own replication [#replication]
A common pattern is "return once the primary write succeeds and fill in the other stores in the background": call
`Write` with `Need: 1`, remember the drivers that were not written, and copy to them later.
```go
// writePrimary returns once one copy is written, reporting which drivers hold it and which still need it.
func writePrimary(ctx context.Context, ds []driver.Driver, key string, data []byte) (holders, pending []int, err error) {
attempts, err := failover.Write(ctx, ds, key, bytes.NewReader(data), int64(len(data)),
failover.WriteOptions{Need: 1, AttemptTimeout: time.Minute})
if err != nil {
return nil, nil, err
}
done := map[int]bool{}
for _, a := range attempts {
if a.Err == nil {
done[a.Index] = true
holders = append(holders, a.Index)
}
}
for i := range ds {
if !done[i] {
pending = append(pending, i)
}
}
return holders, pending, nil
}
```
Replicating one copy means reading it from a driver that holds it and writing it to the target driver, and the read
step can use `Open`'s fallback directly:
```go
// Replicate copies key from holders (drivers that hold a copy, in priority order) to target.
// size is the object size in bytes, normally taken from your own metadata; without it, Stat a source driver first.
func Replicate(ctx context.Context, holders []driver.Driver, target driver.Driver, key string, size int64) error {
rc, attempts, err := failover.Open(ctx, holders, key)
if err != nil {
return fmt.Errorf("no readable source copy: %w", err)
}
for _, a := range attempts[:len(attempts)-1] {
log.Printf("source driver %d unreadable, moved on: %v", a.Index, a.Err)
}
err = target.Put(ctx, key, rc, size)
if cerr := rc.Close(); err == nil {
err = cerr
}
return err
}
```
These two functions only perform the work. To turn them into reliable replication you also need:
- **Persisted replica state**: store `holders` and `pending` in your own database so unfinished replication can resume after a restart.
- **Retries with backoff**: retry a failed `Replicate` later, mark it failed and alert after repeated failures, and try again once the store recovers.
- **Reconciliation**: periodically `List` every store and compare with your records in both directions. Objects in the
store but not in your records are orphans (produced when the write succeeded but recording it failed); delete them
after a grace period. Copies your records claim but the store lacks need to be replicated again. Only reclaim
objects you can tell you wrote yourself (by your own key format, for example), and leave files other programs put
in the same store alone; claim deep check leftovers with `IsDeepProbeKey` and delete them once past the grace period.
- **Concurrency limits**: replication shares the stores' bandwidth and request quotas with regular traffic, so cap how many copies run at once.
The application records replica state in the `blob_channels` table and runs replication on the River task queue; see
[Architecture](../architecture.mdx).
## How the application uses these primitives [#how-the-application-uses-these-primitives]
The application's channels are a set of rclone drivers, and upload, image access, and health checks all call this package directly:
| Scenario | Call | Arguments and handling |
|---|---|---|
| Upload | `Write` | Drivers are the healthy channels in priority order; `Need` is the runtime setting `min_success` (values below 1 count as 1); `AttemptTimeout` is 10 minutes. Channels that succeeded are recorded as holding a ready replica; the rest are recorded as pending and replication jobs are queued. Too few copies returns 503 |
| Image access | `Open` | Drivers are the channels that are healthy, not being cleaned up, and hold a ready replica of the image, in priority order; each failed attempt is logged; if all fail, 503 is returned |
| Periodic health check | `Probe` / `DeepProbe` | Every `health_check_seconds` seconds, channels that previously failed to load are retried, then all channels get a light check in parallel, with a 10-second timeout per channel; an unhealthy channel whose light check passes is confirmed with a deep check (20-second timeout) before it recovers |
| Event-driven deep check | `DeepProbe` | After a channel is created or edited, on manual recheck, and on connection test, with a 20-second timeout |
Replication jobs try the source channels one by one and write to the target channel, the same idea as `Replicate`
above. The daily reconciliation skips `ProbeKey` and the system directory `_imgyoung/`, and only reclaims orphans whose
keys have the application's object key format (criteria in [Architecture](../architecture.mdx)); leftovers from
deep checks are claimed with `IsDeepProbeKey` and deleted once past the 48-hour grace period, regardless of when they
were written. For runtime settings, see [Configuration](../configuration.mdx).
# API reference (/en/docs/api)
Authentication, error format, and shared conventions of the ImgYoung HTTP API; per-group endpoint details are generated from the OpenAPI document.
## Where the API document comes from [#where-the-api-document-comes-from]
`api/openapi.yaml` in the repository (OpenAPI 3.1) is the single source of truth for ImgYoung's HTTP API. The group
pages in this section are generated from it; the end-to-end tests check the status code and body of every `/api/`,
`/healthz`, and `/i/` response against it; and the admin console's frontend types are generated from it too.
The summaries and descriptions in that document are written in Chinese, so the generated group pages are in Chinese as well.
The document is compiled into the binary, and a running instance serves a copy matching its own version at
`GET /api/openapi.yaml`. You can import it directly into Swagger UI, Scalar, or a code generator:
```bash title="Terminal"
curl -o openapi.yaml https://img.example.com/api/openapi.yaml
```
The S3 protocol surface (`/s3/…`) follows the AWS S3 specification, returns XML error bodies, and is not part of this
reference; see [Upload clients](../guides/clients.mdx) for how to use it.
## Authentication [#authentication]
There are exactly two authentication surfaces, and their credentials are not interchangeable:
| | Data plane | Admin console |
|---|---|---|
| Paths | `POST /api/upload`, `POST /api/upload/url` | `/api/admin/…` |
| Credential | An upload token in the `Authorization: Bearer ` header | The session cookie `imgyoung_session`; state-changing requests also send an `X-CSRF-Token` header |
| How to obtain | Issue one on the admin console's "Upload tokens" page; the plaintext is shown only once | `POST /api/admin/auth/login` |
| Not authenticated | 401: the token is invalid, expired, or revoked | 401: not logged in, or the session expired |
| Not permitted | 403: the token lacks upload permission | 403: CSRF token missing or wrong; still on the factory default password; administrator required |
Upload tokens look like `imgy_…`. They only authorize the data plane; no admin console endpoint accepts them. The image
access endpoints (`/i/…`, `/o/…`), `/healthz`, and `/api/openapi.yaml` need no authentication.
### Sessions and the CSRF token [#sessions-and-the-csrf-token]
**Log in**: `POST /api/admin/auth/login` with the body `{"name": "", "password": ""}`. On success
the response sets the session cookie, and the body is `{"user": {…}, "csrf_token": "…"}`.
**Call endpoints**: send the cookie with every request. `GET`, `HEAD`, and `OPTIONS` need only the cookie; every other
method must also echo the `csrf_token` from login in the `X-CSRF-Token` header, or it gets a 403.
**Restore the session state**: if you lose the `csrf_token` (after a page reload, say), `GET /api/admin/me` returns the
current user and the same `csrf_token`.
```bash title="Terminal"
# Log in, saving the cookie to jar.txt; note csrf_token in the response body
curl -c jar.txt -H 'Content-Type: application/json' \
-d '{"name":"admin","password":""}' \
https://img.example.com/api/admin/auth/login
# Read-only endpoints need only the cookie
curl -b jar.txt https://img.example.com/api/admin/me
# State-changing endpoints also need the CSRF token
curl -b jar.txt -X POST -H 'X-CSRF-Token: ' \
https://img.example.com/api/admin/auth/logout
```
Session details:
- The cookie is `HttpOnly` and `SameSite=Lax`; it is also `Secure` when the request arrives over HTTPS (TLS terminated by
the server itself, or reported via `X-Forwarded-Proto` by a trusted reverse proxy).
- A session lasts 30 days from login and is not extended by use; after that you must log in again.
- Changing a user's password invalidates all of that user's sessions.
- Too many login attempts return 429.
- While an account still has the factory default password, its session can use only three endpoints: log out
(`POST /api/admin/auth/logout`), view itself (`GET /api/admin/me`, where `must_change_password` is true), and change
the password (`PUT /api/admin/me/password`). Every other admin endpoint, including admin console uploads and issuing
tokens, returns 403 `须先修改出厂默认密码` (change the factory default password first) until the password is
changed and you log in again.
Channels, users, hotlink protection, audit log, alerts, backups, export, and moderation endpoints, as well as reading
the full runtime settings and changing them, are for administrators only; a regular user gets 403.
## Error format [#error-format]
Apart from the exception below, every error response is a JSON envelope:
```json
{ "error": "存储渠道暂不可用,请稍后重试" }
```
`error` is a user-facing description (in Chinese) with no internal details or credentials. Programs should branch on
the HTTP status code, not parse this text.
**The exception is the image access endpoints** (`/i/{id}`, `/o/{user_id}/{object_key}`, and the admin console
preview `/api/admin/images/{id}/thumb`): apart from 400 for invalid processing parameters, which is still a JSON
envelope, their error responses are plain text:
| Status | Meaning |
|---|---|
| 403 | Rejected by hotlink protection (`/i/` and `/o/` only) |
| 404 | The image does not exist, has expired, or was deleted |
| 500 | Looking up the image metadata failed (database error); retry later |
| 503 | No healthy channel can currently read the image |
The admin console preview enters this pipeline only after session authentication, so authentication errors such as
not being logged in are still JSON envelopes. In `redirect` access mode, the 302 redirect from an image access
endpoint carries `Cache-Control: no-store`: the redirect target changes with channel health and configuration, so it
stays out of CDN and browser caches.
Common status codes:
| Status | Meaning |
|---|---|
| 400 | Invalid request body or parameters |
| 401 | Not authenticated: invalid token, or not logged in / session expired |
| 403 | Not permitted: CSRF check failed, still on the factory default password, administrator required, or the token lacks upload permission; for uploads it also means the storage quota is full |
| 404 | The target does not exist or is not visible to the current user |
| 409 | Conflicts with existing state, such as a duplicate name, or a channel that holds the only copy of some images |
| 413 | Larger than the per-image size limit (runtime setting `max_upload_mb`) |
| 415 | Not a supported image format (judged by file content) |
| 422 | Resolution exceeds the pixel budget (runtime setting `max_pixels_mp`) |
| 429 | Upload concurrency is full, the user is uploading too often, or there are too many login attempts; wait as indicated by `Retry-After` when present |
| 500 | Internal server error |
| 503 | On upload, no healthy channel can be written or too few synchronous replicas succeeded; on image access, no channel can currently read the image |
The group pages list the status codes each endpoint can actually return.
## Shared conventions [#shared-conventions]
- **Timestamps**: unless stated otherwise, UTC text in the form `YYYY-MM-DD HH:MM:SS` (for example
`2026-10-15 08:30:00`), with no time zone suffix. The exceptions are `built_at` in build info and `taken_at` on
backup snapshots, which are RFC 3339 (`taken_at` with millisecond precision).
- **Lists**: list endpoints return `{"items": […], "total": N}`, where `total` counts all items after filtering.
- **Pagination**: the image, upload token, duplicate, cleanup, audit log, and alert lists accept `page` (from 1,
default 1) and `page_size` (default 20, maximum 100; larger values are treated as 100).
- **Time ranges**: the image list and the audit log accept `since` (inclusive) and `until` (exclusive), in the same format.
- **IDs**: images use a 10-character short ID string; user IDs are strings (the built-in administrator is `default`);
tokens, channels, duplicate candidates, and similar records use integers; backup snapshots are identified by file name.
- **Idempotent uploads**: uploads may send an `Idempotency-Key` header. Within the retention period (runtime setting
`idempotency_key_ttl_hours`, default 24 hours), retries with the same key by the same user return the same image.
Keys are scoped per user.
- **Request bodies**: JSON endpoints use `application/json`; file uploads use `multipart/form-data` with the file in
the `file` field, and the optional `exif_policy` and `ttl` are read only from form fields (never from URL query
parameters), at most 1 KiB each.
- **Base URL**: the API shares the site's origin, with the path prefixes shown above and no version segment.
## Groups [#groups]
Uploads and URL imports authenticated with an upload token
Public image endpoints, subject to hotlink protection
Log in, log out, current user, and password change
Dashboard and runtime settings
Session-authenticated uploads from the admin console, through the same pipeline as the data plane
Image list, details, edits, and deletion
Issuing, rotating, and revoking named tokens, and their S3 credentials
Channel templates, creation, editing, health checks, deletion, and cloud drive authorization
Accounts, quotas, and password resets (administrators only)
The Referer allowlist (administrators only)
Decisions on near-duplicates and pending-cleanup cold images (administrators only)
Audit log, alerts, backup snapshots, and metadata export (administrators only)
Health check and the API document
# Data plane (/en/docs/api/data-plane)
Uploads and URL fetch, authenticated with upload tokens
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# Image access (/en/docs/api/image-access)
Public image delivery, subject to hotlink protection
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# System (/en/docs/api/system)
Health check and API document
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# Admin · Session (/en/docs/api/admin-session)
Sign in, sign out, current user and password change
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# Admin · Overview and settings (/en/docs/api/admin-overview)
Dashboard and runtime configuration
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# Admin · Upload (/en/docs/api/admin-upload)
Session-authenticated uploads through the same pipeline as the data plane
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# Admin · Images (/en/docs/api/admin-images)
Image list, details, edits and deletion
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# Admin · Upload tokens (/en/docs/api/admin-tokens)
Issuing, rotating and revoking named upload tokens, and their S3 credentials
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# Admin · Storage channels (/en/docs/api/admin-channels)
Channel templates, creation, editing, probing, deletion and cloud drive authorization
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# Admin · Users (/en/docs/api/admin-users)
User accounts, quotas and password resets (admins only)
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# Admin · Hotlink protection (/en/docs/api/admin-referer)
Referer allowlist (admins only)
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# Admin · Review (/en/docs/api/admin-review)
Review of similar duplicates and cold images awaiting cleanup (admins only)
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# Admin · Operations (/en/docs/api/admin-operations)
Audit log, alerts, backup snapshots and metadata export (admins only)
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# Architecture (/en/docs/architecture)
Single-instance design, layers and dependency direction, the data model, upload and access data flows, the outbox fence, periodic tasks, and why rclone is integrated in-process.
## Single-instance design [#single-instance-design]
ImgYoung is deliberately designed as a single instance: one process, one data directory.
- **The metadata database is one SQLite file** (`imgyoung.db` in the data directory, in WAL mode). Writes queue on a
single connection and reads use a pool; the driver is the pure-Go `modernc.org/sqlite`.
- **The task queue lives in the same database**: asynchronous work runs on River, whose job tables sit in the same
SQLite file as the business tables, so no separate queue service is needed.
- **All other state is local**: the upload concurrency gate, per-user upload rate limits, login rate limiting, and access
activity aggregation live in process memory, and the variant cache lives on local disk. There is no Redis.
- **One static binary**: everything is built with `CGO_ENABLED=0`, and the admin console's frontend assets and the
OpenAPI document are compiled into the binary.
As a result, ImgYoung does not support several processes sharing one data directory, nor horizontal scaling. The path
to high availability is replacing the database with PostgreSQL (River supports PostgreSQL itself, so the task queue
moves along with it) and moving the in-process shared state out at that point; the current code only implements SQLite.
## Layers and dependency direction [#layers]
```text
http ──→ core ──→ infra
│ └────→ store
└─────────────→ infra
```
| Layer | Directory | Responsibility |
|---|---|---|
| store | `store/` | The public storage engine: the `driver` interface, the `rclone` implementation, and the `failover` primitives. See [Using it as a Go library](./library/index.mdx) |
| infra | `internal/infra/` | Infrastructure and outbound adapters: `config` (bootstrap and runtime settings), `db` (schema, queries, migrations, sqlc-generated code), `secret` (master key and encryption), `id` (short IDs), `imaging` (image processing), `cdn`, `urlfetch` (fetching for URL imports), `observability` (metrics), `version` |
| core | `internal/core/` | Business core: `channel` (channels and health checks), `replication` (replication, reconciliation, draining, CDN purge), `upload`, `variants` (on-demand processing), `dedup`, `lifecycle`, `reliability` (backup and restore), `auth`, `audit`, `referer`, `usage`, `alert` |
| http | `internal/http/` | Inbound HTTP: `publicapi` (uploads and image access), `s3api` (the S3 protocol surface), `adminapi` (admin console), `middleware`, `httpx`, `router` |
| Composition root | `cmd/imgyoung/` | `build.go` constructs the object graph by hand in the order infrastructure → services → HTTP, `serve.go` orchestrates startup and shutdown with oklog/run, and `loops.go` holds the periodic loops |
`store` and `infra` are two parallel leaves: `store` depends on no `internal/` package, and `infra` depends on neither
core, store, nor http; `core` depends on both. The dependency direction is enforced by golangci-lint's depguard rules,
and nothing merges while CI is red:
| Rule | Must not import | Why |
|---|---|---|
| `store/**` | Any package under `internal/` | store is a public, reusable storage engine and cannot depend on application code |
| `internal/infra/**` | `internal/core`, `store`, `internal/http` | infra is the infrastructure leaf |
| `internal/core/**` | `internal/http` | The business core knows nothing about transport and can be tested without HTTP |
| `internal/http/**` | `store` | Channel selection and failover belong to core; the transport layer only uses the results core gives it |
Two more conventions:
- **Interfaces are defined by their consumers**: each package declares small interfaces with only the methods it uses
(such as `upload.ChannelSelector` and `publicapi.BlobChannels`), satisfied implicitly by concrete types.
`store/driver.Driver` is the only first-class abstraction.
- **Identity is passed explicitly**: middleware resolves the authenticated principal into the request context, and the
handler takes it out and passes it to core as an argument; core never reads identity from the context, and per-user
isolation is enforced in core.
HTTP handlers and River workers are thin adapters that translate an external trigger into a service call. Periodic
tasks are `Run(ctx, wait)` loops owned by each service and orchestrated by the composition root; there is no separate scheduler layer.
## Data model [#data-model]
At the heart of the data model is the **separation of physical images from upload references**:
```text
images (references) ──→ blobs (physical images) ──→ blob_channels (replicas per channel) ──→ channels
N : 1 1 : N N : 1
```
- **`blobs`** are the bytes actually stored. Every upload creates a new blob; after background deduplication merges
byte-identical blobs, one blob can be referenced by several images. A blob records the `sha256` of the sanitized
bytes, the perceptual hash `phash`, the object key inside channels `storage_key` (of the form
`{short_id}_{first 8 hex chars of sha256}`), MIME type, size, dimensions, the reference count `ref_count`, and the deletion markers.
- **`images`** are the references created by uploads; the public 10-character short ID is the primary key. Owner,
source token, S3 object key alias, expiry time and access activity, the cold-image label, and cleanup exemption all
live on the reference, not on the blob.
- **`blob_channels`** records a blob's replica on a channel and doubles as the state of its replication task:
`pending → uploading → done / failed`, plus the attempt count and the last error.
Keeping the two layers apart puts each concern in its place: merging duplicates just repoints references at the
surviving blob; TTL, cold images, and quotas are computed per reference; replicas, replication, and physical deletion work per blob.
**Reference counting**: every change to `ref_count` is an atomic `UPDATE … SET ref_count = ref_count ± 1`. Deleting a
reference and decrementing the count happen in the same transaction. A blob whose count reaches zero is marked for
deletion with a physical deletion time based on the runtime setting `delete_grace_minutes`. When that time comes, the
cleanup task first removes its variant cache, then deletes the business rows in one transaction while re-checking the
deletion condition (if the blob was referenced again in the meantime, the deletion is abandoned), and finally queues an
object deletion job for every channel holding a replica.
The remaining tables:
| Table | Contents |
|---|---|
| `channels` | Channels: rclone backend name, parameter JSON encrypted with the master key (AES-GCM), priority, health flag and latest check result, whether encrypted, whether direct links are allowed, the being-cleaned-up flag; the creation time `created_at` (which, together with the channel ID, feeds key derivation for encrypted channels and never changes) and the binding time `bound_at` (see [reconciliation GC](#reconcile-gc)) |
| `variants` | Index of the variant cache: one row per blob and processing parameter set; the bytes live on local disk and are evicted LRU by last access |
| `settings` | Runtime settings in a single-row wide table; each column's DEFAULT is the factory value. See [Configuration](./configuration.mdx) |
| `users`, `tokens`, `sessions` | Users and quotas, upload tokens (hashes only), admin sessions (only a hash of the session id plus its bound CSRF token) |
| `idempotency_keys` | Upload idempotency keys, scoped per user |
| `dedup_candidates` | Near-duplicate candidate pairs, the work queue for manual decisions |
| `referer_whitelist`, `audit_log`, `alerts` | The hotlink protection allowlist, audit log of admin actions, and alert records |
| `master_key_fingerprint` | A single row holding a fingerprint derived from the master key (it cannot be reversed into the key); the master key is checked against it at every start, and a missing or mismatched key refuses to start. See [Master key](./configuration.mdx#master-key) |
The schema is defined by declarative SQL under `internal/infra/db/schema/`, one file per table, with a comment on every
table and every column. Versioned migrations are generated from it by Atlas, embedded in the binary, and applied in
order at startup; applied versions are recorded in the `schema_revisions` table, so upgrading across several versions
also applies every migration in between.
Migrations cannot be downgraded: if the database records a migration version this binary does not know (meaning a newer
binary has already migrated it), the process refuses to start, and the error names the latest migration version in the
database and the latest one this binary knows. The only way back is "old binary + a snapshot taken before the
upgrade"; see [Upgrading](./operations/upgrade.mdx).
## Upload data flow [#upload-data-flow]
Data plane uploads, admin console uploads, URL imports, and S3 PutObject differ only in where the bytes come from;
once the bytes are in hand they all go through the same pipeline. The upload path does not deduplicate: every upload
creates a new blob and deduplication happens in the background, which keeps the upload path a straight line with no branches.
**Entry and guards**: authenticate (upload token, session, or S3 signature) and pass two gates: the per-user upload
rate limit (`upload_rate_per_min`) and the global upload concurrency gate (`max_concurrent_uploads`, which returns 429
immediately when full). The image bytes are then read fully into memory; no upload of any size is written to a
temporary file. The per-image limit `max_upload_mb` applies to the image itself: a file exactly at the limit is
accepted, and anything larger returns 413. A multipart request body is additionally capped at "per-image limit + 64 KiB",
and only three parts are read — `file`, `exif_policy`, and `ttl`. `exif_policy` and `ttl` are taken only from form
fields (never from URL query parameters), at most 1 KiB each, or the request gets a 400.
**Idempotency**: if `Idempotency-Key` is present and the same user has already used that key, the existing image is returned.
**Format and pixel budget**: the real format is decided by the magic bytes at the start of the file, and only JPEG,
PNG, GIF, and WebP are accepted. Only the image header is read to get the dimensions, and they are cross-checked
against the sniffed format; if width × height exceeds `max_pixels_mp` the upload is rejected without a full decode.
Decoded memory depends on resolution, not on file size, so this gate has to come before decoding.
**Quota pre-check**: quota is charged in full per reference; exceeding the user's quota returns 403 and nothing is written to channels.
**Sanitizing**: with the EXIF policy `strip`, metadata is removed (JPEG, PNG, WebP; GIF has no standard metadata segment and passes through unchanged).
For JPEG, a minimal Exif segment holding only the orientation is added back, so photos that rely on the orientation
tag to display upright do not end up rotated; GPS, camera model, timestamps, and every other field are still removed,
and nothing is added back when the orientation is the default.
**Hashing**: one full decode computes the perceptual hash (pHash), then SHA-256 is computed over the bytes that will be
stored. Sanitizing happens before hashing, so `sha256` always matches the bytes actually stored in channels.
**Writing to channels**: the object key is `{short_id}_{first 8 hex chars of sha256}`; embedding the short ID is the
last resort for rebuilding from storage if the metadata database is lost entirely. `failover.Write` writes to the
healthy channels in priority order until `min_success` copies succeed, with a 10-minute timeout per write, moving on to
the next channel on failure.
**Recording**: one transaction writes the blob, the reference, a replica row for every active channel (`done` for the
ones written, `pending` for the rest), and the idempotency key.
**Queuing replication**: replication jobs are queued for the `pending` channels and the upload returns. If queuing
fails, it is only logged; reconciliation picks it up.
The whole upload happens in memory without temporary files. Peak memory is roughly "concurrency × max(per-image size
limit, decoded memory at the pixel budget)", so lowering these settings adapts ImgYoung to small machines. If the
channel write succeeds but recording fails, an orphan object is left behind; reconciliation GC reclaims it, and the
channel write is never rolled back.
## Access data flow [#access-data-flow]
`GET` / `HEAD /i/{id}` (and the S3 object key alias `/o/{user_id}/{key}`; the admin console preview
`/api/admin/images/{id}/thumb` uses the same pipeline but is never counted as an access):
1. Hotlink protection middleware allows the request or returns 403 based on the Referer allowlist (admin console previews skip this step).
2. The reference is looked up; if it cannot be found, 404 is returned. A reference past its expiry time is treated as
missing right away, without waiting for the cleanup task to reclaim it, and an access never renews it.
3. Processing parameters are validated: a `w` outside the width presets or an explicit `format` outside the allowed
formats returns 400 (GIF originals are never processed, so both parameters are ignored for them).
4. Access is counted: only GET requests on the public endpoints add to the in-memory activity, flushed to the database
in batches every `activity_flush_seconds`; HEAD requests, requests with invalid parameters, and admin console previews are not counted.
5. On-demand processing kicks in when the image needs resizing or converting: a `w` parameter, an explicit `format`, or
no format with a browser that accepts WebP, WebP allowed by the settings, and an original that is not WebP already.
GIFs are always served as originals. A variant cache hit is returned directly; on a miss the image is processed
within the `process_workers` concurrency limit and cached, with concurrent requests for the same parameters merged
into one processing run. When processing is busy or fails, the original is served instead, with `Cache-Control: no-store`.
A HEAD request fetches or generates the variant the same way; it just returns no body.
6. Otherwise the original is served. Candidate channels are those that are healthy, not being cleaned up, and hold a
`done` replica of the image, in priority order. In `redirect` access mode, if a candidate allows direct links, is not
encrypted, and has a public URL configured, the response is a 302 to that channel, sent with
`Cache-Control: no-store` (the target changes with channel health and configuration, so it must not be cached). A
HEAD request gets the response headers only and no channel object is read; otherwise `failover.Open` proxies the
read, switching to the next channel as soon as one cannot be read.
Caching depends on the endpoint:
| Endpoint | Cache-Control | Why |
|---|---|---|
| `/i/{id}` | `public, max-age=31536000, immutable` | Content behind a short ID never changes; the URL is the version |
| `/o/{user_id}/{key}` | `public, no-cache` | Re-uploading the same key repoints the alias at a new image; caches may store the response but must revalidate with the ETag before each use, getting 304 when unchanged |
On both endpoints originals and variants carry a strong ETag (the content hash for originals, the hash plus processing
parameters for variants), and a request whose `If-None-Match` matches it gets a 304. The 304 carries the `ETag`,
`Cache-Control`, and `Vary` the 200 response would have, so caches can keep using their stored copy. Responses whose
format was negotiated via `Accept` carry `Vary: Accept`; a fallback original is `no-store` and never answered with a 304.
If a database lookup fails, the response is a plain-text 500 that can be retried.
## The outbox fence: business tables are the single source of truth [#outbox]
Asynchronous replication runs on River, but River jobs are only **idempotent triggers**. The single source of truth for
replica state is the business table `blob_channels`:
- A replication job means "make sure blob X has a replica on channel Y". The worker reads the business table first: if
the replica is already `done` it returns; before copying it sets `uploading`; on failure it goes back to `pending` and
River retries; if the last attempt still fails it becomes `failed`; on success it becomes `done`.
- Jobs are deduplicated by (blob, channel), counting only unfinished jobs, so the same replica never appears in the queue twice.
- **Reconciliation** runs at startup and every 10 minutes after that: it queues drain jobs for channels being cleaned
up, seeds missing replica rows for every channel, resets `failed` replicas on healthy channels to `pending`, and then
pages through every `pending` / `uploading` replica with a primary-key cursor and queues it again; replicas turning
`done` during the scan cannot shift later pages and cause rows to be skipped.
So even if River's job tables are lost or corrupted, the queue can be rebuilt completely from the business table; a job
that was running when the process was killed is rescheduled by River once it has been stuck in the running state for
16 minutes. When a channel is created, `pending` replica rows are seeded for every existing blob, and the same
machinery brings them in line, with no separate backfill logic.
The other asynchronous jobs follow the same principle: deleting objects from a channel (`delete_object`), draining a
channel being cleaned up (`drain_channel`: it first deletes the replica objects row by row, so its progress is simply the
remaining replica rows; then deletes the system objects under `_imgyoung/` on that channel, metadata snapshots included,
along with deep check leftovers, leaving objects written by other programs alone; and finally deletes the channel record), and purging CDN caches
(`cdn_purge`). All of them are idempotent and retried by River with backoff: orphans left by an object deletion that
finally fails are reclaimed by reconciliation GC; the first time a drain job cannot delete an object, it writes the
reason into the channel's check result and raises a `drain_failed` alert, then keeps retrying with backoff — once its
attempts run out, reconciliation queues it again, so it never stops (the job's own timeout or a shutdown cancellation
does not count as a failure); the way out is **Remove now** on the channel in the admin console (remote objects are kept). When a CDN purge finally
fails, the edge cache expires once the CDN's own cache lifetime runs out.
## Periodic tasks [#periodic-tasks]
Periodic tasks are loops owned by each service and started and stopped by the composition root. For intervals that come
from runtime settings, a change in the admin console reschedules the current wait immediately instead of waiting for the old interval to finish.
| Task | Interval | What it does |
|---|---|---|
| Health check | `health_check_seconds` (default 60 seconds) | Retry loading channels that previously failed to load, then light checks on all channels in parallel; an unhealthy channel whose light check passes recovers only after a deep check confirms it. See [Multi-store failover](./library/failover.mdx) |
| Alert evaluation | Same as the health check | Unhealthy channels, failed replication, channel usage over the threshold, failed drains, failed backups; can be pushed to a webhook |
| Lifecycle cleanup | `cleanup_sweep_seconds` (default 60 seconds) | Reclaim expired references → physically delete blobs past their grace period → label cold images → reclaim expired idempotency keys → reclaim audit entries past retention |
| Activity flush | `activity_flush_seconds` (default 60 seconds) | Write public access times and counts to the database in batches; variants' last-access times are flushed on the same interval |
| Deduplication | `dedup_scan_seconds` (default 300 seconds) | Merge exact duplicates (same SHA-256) automatically, then scan for pHash near-duplicates and queue them for manual decisions |
| Usage report | `usage_report_seconds` (default 300 seconds) | Compute storage usage per channel and update metrics |
| Metadata database backup | `backup_interval_hours` (default 24 hours) | Take one consistent snapshot with `VACUUM INTO` and write it to `_imgyoung/backup/` on every channel that is healthy and not being cleaned up, each channel keeping its own `backup_keep` copies; if any channel is not written, the round counts as failed (`backup_failed` alert) and the channels that were written keep the snapshot. At startup, a backup runs right away if any healthy channel lacks a snapshot within the backup interval. See [Backup and restore](./operations/backup.mdx) |
| Auth maintenance | 1 minute / 1 hour (fixed) | Write back token last-used times in batches / reclaim expired sessions |
| Replication reconciliation | 10 minutes (fixed) | See the previous section |
| Reconciliation GC | 24 hours (fixed) | List every healthy channel and compare with the replica records in the database in both directions: reclaim orphan objects, and reset phantom replicas to `pending` to be replicated again. Criteria below |
### Reconciliation GC criteria [#reconcile-gc]
What reconciliation GC handles (the system directory `_imgyoung/` and the light check key are excluded from the comparison):
- **Orphans**: an object is reclaimed only if all of these hold — its key has this service's object key format
`{short_id}_{first 8 hex chars of sha256}`; the database has no replica record of it on that channel in any state;
its modification time is not earlier than the channel's binding time; and it is past the 48-hour grace period
(which covers uploads already written to the channel but not yet recorded). Every other object without a record —
files written by other programs, objects in an old bucket attached by a new instance during disaster recovery
before the metadata is restored, or objects written to the channel before a snapshot restore that the restored
database has no record of — is only counted, never reclaimed, and appears as `foreign` in the reconciliation log.
- **Deep check leftovers**: `.imgyoung-probe-` temporary objects left when a deep check fails to delete them are
deleted once past the 48-hour grace period, regardless of when they were written, and are not counted in the
reconciliation's object and byte totals.
- **Phantoms**: replicas recorded as `done` but missing from the channel are reset to `pending` and queued for replication again.
The **binding time** (`channels.bound_at`) marks when the channel record started to correspond to its current storage
location; this service only writes objects there after that moment:
- A new channel's binding time equals its creation time. When a new instance attaches an old bucket, every object
already in it is older and is only counted, never reclaimed.
- Editing a channel in the admin console resets it to the time of the edit if the backend name or any configuration
parameter changes; typing the same password again counts as a change (rclone encodes passwords with a random IV).
Changing only the name, priority, or direct-link switch does not reset it, and neither does a token refresh write-back.
- After a restore from a snapshot, every channel is reset to the time of the first start after the restore: before
swapping the snapshot in, a marker file `imgyoung.db.restored` is written to the data directory; after migrations and
before the master key is loaded, the marker triggers the reset and is deleted on success, with the log line
`换入快照后已把渠道绑定时刻重置为当前时刻,快照之后写入渠道的对象不会被对账回收`. Objects uploaded after the snapshot,
which the restored database has no record of, are therefore never deleted as orphans. The marker is written when the
staged file `/imgyoung.db.restore` is swapped in, and a restore from the admin console stages exactly that
file; a manual restore must also put the snapshot there and then start, not overwrite `imgyoung.db` directly,
otherwise no marker is written and the binding times are not reset.
`created_at` does not change along with the binding time: together with the channel ID it feeds key derivation for encrypted channels.
Reconciliation only trusts this database's replica records: objects another instance writes to the same path have the
right key format but no record here, so once past the grace period they are deleted as orphans.
### Shutdown [#shutdown]
Shutdown (on SIGINT / SIGTERM, or a restart requested after restoring a backup snapshot in the admin console) happens
in order: new requests stop being accepted first, and in-flight requests get at most 6 seconds before the remaining
connections are closed; then the background loops and in-flight replication jobs are cancelled and the replication
subsystem gets at most 2 seconds, while each loop writes out data still held in memory before it exits; finally the
channel drivers and the database are closed. The two stages total 8 seconds, within Docker's default 10-second kill
timeout, and the sample configurations under `deploy/` leave extra headroom (`stop_grace_period: 15s` in compose,
`TimeoutStopSec=15s` for systemd). Cancelled replication jobs keep their state in the business table and are queued
again by reconciliation on the next start.
## Technology choices [#tech-stack]
Every choice serves the same premise: a single instance, a single static binary, and no external services needed to
deploy apart from the storage channels. Exact dependency versions are whatever the repository's `go.mod` says.
### Language and build [#language-and-build]
- **Go**: compiles to a single static binary with a small Docker image; I/O orchestration such as multi-channel writes,
asynchronous replication, health checks, and the task queue is straightforward with goroutines; the ecosystem for
object storage, HTTP, and metrics is mature, and simple syntax and fast builds keep the bar for contributors low. The
deciding factor is that rclone itself is written in Go, and only Go can call its backends directly in-process; see
[Why rclone is integrated in-process](#rclone).
- **`CGO_ENABLED=0` throughout**: without a C toolchain or system libraries, the release archives can be cross-compiled
for Linux, macOS, and Windows on two architectures each, and shipped in a distroless image holding nothing but the
binary. Every dependency below is pure Go or works without cgo. All builds use `-tags nodynamic`: by default the WebP
library tries to load the system libwebp at runtime, and only with that path switched off is the Linux binary free of
the dynamic linker.
### Dependencies [#dependencies]
| Area | Choice | Why |
|---|---|---|
| HTTP routing | chi | Handlers are plain standard-library `http.Handler`s with no private concepts; uploads (streaming request reads) and image proxying (streaming response writes) both work directly with `io.Reader` / `io.Writer`, so a thin router fits best |
| SQLite driver | `modernc.org/sqlite` | A pure-Go implementation; no cgo |
| Database access | sqlc | Queries are written in SQL and compiled into type-safe Go code. No ORM: an image host's queries are all simple, exact lookups, so an ORM's abstraction buys nothing |
| Migrations | Atlas | The declarative schema is the single source of truth; during development Atlas diffs it into versioned migrations, and at runtime its pure-Go executor applies them on startup, so deployments need no migration tool |
| Task queue | River | A persistent task queue with retries, backoff, and concurrency control built in; on its SQLite driver the job tables share a database with the business tables, so there is no extra service. Jobs are only idempotent triggers and state lives in the business tables; see [the outbox fence](#outbox) |
| Periodic tasks | A `Run(ctx, wait)` loop owned by each service | Periodic tasks only need "run every so often", with the interval taken from runtime settings and rescheduled the moment it changes; no cron library |
| Wiring | Assembled by hand in `cmd/imgyoung/build.go` | The set of components is fixed and their dependencies are static, so calling constructors in order is enough: searchable, navigable, with no reflection or runtime container. No DI framework |
| Lifecycle | oklog/run | HTTP, River, and each background loop are actors; when any of them exits, the whole group shuts down in order. Harder to get wrong than hand-rolled `WaitGroup`s and channels |
| Architecture guard | depguard (built into golangci-lint) | The direction of dependencies between layers is a lint rule enforced by CI, not a matter of discipline; see [Layers and dependency direction](#layers) |
| Perceptual hash | goimagehash | Pure Go; takes the decoded `image.Image`, so it plugs straight into the upload's decode step |
| Image codecs | Standard library, `golang.org/x/image`, gen2brain/webp | None needs cgo; gen2brain/webp encodes WebP by running libwebp in a pure-Go WebAssembly runtime |
| Cryptography | Standard-library AES-256-GCM and HKDF; argon2id from `golang.org/x/crypto` | Encrypting channel configuration and credentials, deriving channel keys and S3 secrets, and hashing login passwords respectively. Encryption of channel data itself is left to rclone crypt |
| Rate limiting | `golang.org/x/time/rate` | Upload rate limits and login throttling are both in-process token buckets |
| Logging and metrics | `log/slog`, Prometheus client_golang | The de facto standards in their areas |
| Short IDs | jaevor/go-nanoid | Public short IDs come from a mature implementation; internal primary keys are SQLite auto-increment rowids and never exposed |
The project is released under the MIT license: the same as rclone, with the lowest barrier for contributors, and a
self-hosted tool has no realistic concern about cloud vendors reselling it as a hosted service.
### What is deliberately left out [#what-is-deliberately-left-out]
- **Redis**: it has no place in a single instance. What needs atomicity, such as idempotency keys and reference counts,
relies on SQLite constraints and atomic updates; rate limits are in-process token buckets; the variant cache lives on
local disk. In a single instance the in-process versions are strictly better than Redis: no network round trips, no
serialization, no extra service to run. It only becomes worth reconsidering when moving to multiple instances.
- **Configuration files and configuration libraries**: environment variables only get the process started; all other
runtime settings live in the database's `settings` table and are edited in the admin console, so "where does this
value come from" always has exactly one answer. See [Configuration](./configuration.mdx).
- **ORMs, DI frameworks, cron libraries**: see the table above for why.
### SQLite and versioned migrations [#sqlite-and-versioned-migrations]
SQLite is a single file with no external dependencies, and a consistent snapshot is just one more file, which suits
self-hosting best and fits the single-instance design. Versioned migrations have been there from day one: a
long-running self-hosted instance goes through many version upgrades, and without versioned migrations the schema would
quickly get out of hand. Migrations only go forward; see [Data model](#data-model).
### Two upgrade seams [#two-upgrade-seams]
Only two choices are expected to be replaced; everything else is fixed. Go's thin libraries all follow the shape of the
standard library, so replacing one would be mechanical work, and reserving flexibility for it would buy nothing.
- **Image processing engine**: the single entry point for on-demand processing is `Processor.Process(src, params)` in
`internal/infra/imaging`. The current implementation is pure Go: the standard library decodes JPEG / PNG / GIF,
gen2brain/webp encodes WebP, output is limited to WebP, JPEG, and PNG, and GIFs are stored and served as-is without
processing. AVIF and HEIF are not included: they see little use, and their encoders (libaom and the like) are heavy.
More formats or faster processing (an implementation based on libvips, for example) would mean replacing this processor.
- **Database**: currently SQLite. The schema is defined in declarative SQL, migrations are generated by Atlas, and query
code is generated by sqlc; moving to PostgreSQL means regenerating the query code and migrations for that dialect and
switching River to its PostgreSQL driver.
Each seam currently has a single implementation, and no interface type has been defined for either in advance.
## Why rclone is integrated in-process [#rclone]
The storage layer follows one rule: rclone only stores bytes in some backend, retrieves, inspects, lists, and deletes
them. Primary/backup switching, replication, reference counting, deduplication, TTL, and health decisions are all
ImgYoung's own code, separated from rclone by the `store/driver.Driver` interface.
The integration imports rclone's `fs` and backend packages directly and builds an `fs.Fs` in-process from a parameter
map, instead of calling the rclone command line, librclone's RPC interface, or an rclone config file:
- **Streaming, no temporary files**: uploads write in-memory bytes straight through `fs.Fs.Put`, and reads stream the
object straight into the HTTP response.
- **Configuration stays in ImgYoung's own database**: channel parameters are stored in the `channels` table encrypted
with the master key and decrypted into a map for the backend at runtime. The parameters contain credentials, so they
are never logged and never carried in error chains.
- **One token refresher**: rclone refreshes OAuth tokens itself and hands them to the application through the
parameter write-back hook, which encrypts and stores them immediately; the application runs no refresh loop of its own.
- **One handle for everything**: reads, writes, checks, listing, and deletion all go through the same `fs.Fs`, with
one set of error mappings (missing objects always become `driver.ErrNotFound`).
- **Channel encryption with no extra code**: encrypted channels stack rclone's crypt layer on top of the backend, and
the upload and access paths stay unchanged; crypt is a public format, so with the password the data can be decrypted
by standard rclone on its own. See [Channel encryption](./channels/encryption.mdx).
- **Configuration exportable as-is**: the admin console can export an rclone configuration file for every channel, with
exactly the parameters the driver hands to the backend, plus a crypt remote for encrypted channels. It is generated by
`ConfigFile` in `store/rclone`, which shares its parameter handling with driver construction, so standard rclone can
read and write the channel's data without ImgYoung. See [Driver interface and rclone implementation](./library/driver.mdx#config-file).
- **Tests need no cloud account**: rclone's `local` and `memory` backends let the whole storage path run as integration tests on one machine.
Registering every rclone backend (`backend/all`) means any storage rclone supports can be used as a channel directly,
and rclone upgrades bring new backends with them; the cost is a binary on the order of 100 MB. This relies on rclone's
internal APIs, which make no stability promise, so `go.mod` pins the rclone version and upgrades are done by hand with the full test suite.
The `Driver` interface does not assume rclone exists, which is also the way out if rclone's internal APIs change:
swap in another `Driver` implementation and the business code stays the same. Splitting the interface and the rclone
implementation into separate packages also lets Go projects outside the application reuse this storage engine on its
own; see [Using it as a Go library](./library/index.mdx).