# 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:

<Tabs items={['Binary', 'Docker', 'Compose', 'systemd']}>
<Tab value="Binary">

```bash title="Terminal"
IMGYOUNG_LISTEN=127.0.0.1:8721 IMGYOUNG_DATA_DIR=/srv/imgyoung ./imgyoung
```

</Tab>
<Tab value="Docker">

```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`.

</Tab>
<Tab value="Compose">

```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.

</Tab>
<Tab value="systemd">

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
```

</Tab>
</Tabs>

## 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 `<data dir>/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:

<Tabs items={['Binary', 'Docker', 'Compose', 'systemd']}>
<Tab value="Binary">

```bash title="Terminal"
cp data/master.key /safe/place/imgyoung-master.key
```

</Tab>
<Tab value="Docker">

```bash title="Terminal"
docker cp imgyoung:/data/master.key ./imgyoung-master.key
```

</Tab>
<Tab value="Compose">

```bash title="Terminal"
docker compose cp imgyoung:/data/master.key ./imgyoung-master.key
```

</Tab>
<Tab value="systemd">

```bash title="Terminal"
sudo cp /var/lib/imgyoung/master.key ./imgyoung-master.key
```

</Tab>
</Tabs>

### 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.

<Callout type="error" title="Back up master.key first">
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).
</Callout>
