DittoFS uses a flexible configuration approach with named, reusable stores. This allows different shares to use completely different backends, or multiple shares can efficiently share the same store instances.
DittoFS has no OpenTelemetry / distributed-tracing subsystem. Observability is
provided by an opt-in Prometheus /metrics endpoint on a dedicated listener. See
Metrics (Prometheus) below for the full configuration.
DittoFS uses a control plane database to store persistent configuration for users, groups, shares, and permissions. This enables dynamic management via CLI commands and REST API without restarting the server.
database:
# Database type: sqlite (single-node) or postgres (HA-capable)
Bind interface. Loopback-only by default (secure-by-default for single-host). Set to 0.0.0.0 for multi-host / Kubernetes (then front it with TLS termination — see TLS and bind address)
port
8080
HTTP/HTTPS port for API endpoints
read_timeout
10s
Maximum duration to read request
write_timeout
10s
Maximum duration to write response
idle_timeout
60s
Maximum idle time for keep-alive
drain_stall_timeout
5m
Inactivity bound for POST /system/drain-uploads. The drain has no total time cap — a multi-GiB flush runs as long as it keeps making progress — and is aborted (504) only if no upload completes within this window (the remote stalled). Mirrors rclone’s --timeout
require_initial_password_change
true
Force the bootstrap admin user to change its password on first login. Set to false to opt out (automated/test deployments). Also skipped when DITTOFS_ADMIN_INITIAL_PASSWORD is set
pprof
false
Expose Go /debug/pprof/* profiling endpoints
pprof_mutex_rate
100 (when pprof: true; else 0)
Mutex contention sampling, 1 per N events. Applied only when pprof: true; unset/0 then falls back to 100. Without it /debug/pprof/mutex is header-only. Disable profiling via pprof: false, not by zeroing this
pprof_block_rate_ns
1000000 (when pprof: true; else 0)
Block profiling rate in ns, 1 sample per N ns blocked. Applied only when pprof: true; unset/0 then falls back to 1000000. Without it /debug/pprof/block is header-only. Disable profiling via pprof: false, not by zeroing this
JWT Configuration Options:
Option
Default
Description
secret
(required)
HMAC signing key (min 32 chars)
access_token_duration
15m
Access token lifetime
refresh_token_duration
168h
Refresh token lifetime (7 days)
Security Note: The JWT secret should be kept confidential. Use the DITTOFS_CONTROLPLANE_SECRET environment variable in production to avoid storing secrets in config files.
The control plane API carries admin logins, the dfsctl remote password login, operator credentials, and JWTs. By default the server binds to 127.0.0.1 (loopback only) so a fresh dfs start is not reachable off-host. For any deployment that must accept connections from another machine — multi-host, Kubernetes — set host: 0.0.0.0 and protect the listener with TLS.
DittoFS offers native, file-based TLS as a secure-by-default floor. It is intentionally thin: DittoFS loads certificate files (and an optional client CA for mTLS) — it is not a certificate authority, does not generate self-signed certificates, and does not do ACME, issuance, renewal, or rotation. Certificate lifecycle is left to your platform (cert-manager, a mounted Kubernetes Secret, Vault, your PKI). When the platform rewrites the files on disk, DittoFS hot-reloads them with no restart.
Path to the PEM server certificate (or chain). Both cert_file and key_file must be set to enable HTTPS; setting one without the other is a fatal config error
key_file
(unset)
Path to the PEM private key for cert_file
client_ca
(unset)
Path to a PEM CA bundle. When set, the server requires and verifies a client certificate signed by one of these CAs (mutual TLS). Requires cert_file/key_file
min_version
1.2
Minimum negotiated TLS version: "1.2" or "1.3"
When cert_file/key_file are unset, the server serves plain HTTP exactly as before (back-compatible). When set, it serves HTTPS; the files are read and parsed at startup, so a missing or malformed certificate fails fast with a clear error.
Recommended deployment model: terminate TLS for the edge at an ingress / service mesh / reverse proxy (NGINX), and use DittoFS native TLS (or mTLS via client_ca) as the secure floor for non-Kubernetes hosts and direct dfsctl access. See docs/SECURITY.md and docs/DEPLOYMENT.md. For Kubernetes, the operator renders host: 0.0.0.0 automatically so the API Service can reach the pod; see docs/DEPLOYMENT.md.
Per-share block storage is configured via dfsctl store / dfsctl share commands (not the server config file). Each share owns an isolated local storage directory plus a reference to a remote store (S3 or filesystem). The block store lives in pkg/block/engine/ and composes a local tier, a remote tier, the unified CAS-keyed in-memory Cache, a syncer (async local-to-remote transfer), and a garbage collector.
The local filesystem store (fs) is a thin adapter over the journal — an
append-only, log-structured write-back cache (pkg/block/journal/). Writes
append to the journal and local-ack; a background carve pass packs dirty ranges
into packed remote blocks (blocks/<id>). Pre-v0.16 {payloadID}/block-{idx}
layouts must be converted with dittofs ≤ v0.21 (dfs migrate-to-cas) before the
server will start.
These keys live inside the per-share local block store’s config JSON
(passed via dfsctl store block local add --config '{...}' or the REST API).
They only take effect when the local store type is fs. Legacy keys from the
pre-journal design — use_append_log, rollup_workers, stabilization_ms,
orphan_log_min_age_seconds — are no longer parsed; if present they are
silently ignored.
Key
Type
Default
Description
max_log_bytes
int
deduced (25% of RAM, floor 1 GiB)
Per-share local-cache size hint. Still accepted and resolved (per-store value takes precedence over the global blockstore.local.max_log_bytes and the system-deduced default), but it no longer drives write backpressure — the journal caps on-disk usage and evicts on its own budget. Values above 2^53 (~9 PiB) lose precision through JSON parsing.
Env-var mapping follows the dot-path convention:
DITTOFS_BLOCKSTORE_LOCAL_FS_MAX_LOG_BYTES.
There is no metadata.RollupStore backend requirement any more — the journal
owns its local-cache state; a metadata backend only needs the block-record and
synced-hash contracts (see implementing stores).
Under the journal, write backpressure comes from the local on-disk cap, not
an append-log budget: when the cache is full and every segment is pinned by
not-yet-carved (dirty) bytes, a write waits up to the eviction budget and then
returns ErrLocalStoreFull (surfaced as disk-full to the protocol). A healthy
remote lets the carve pass drain dirty bytes so eviction frees space and the
writer proceeds. The max_log_bytes value below is retained as a size hint and
still resolves with the precedence shown, but it no longer gates writes.
The effective budget resolves with the following precedence (highest
first):
Per-store block-store config["max_log_bytes"] — set per share via
dfsctl store block local edit <share> --config '{"max_log_bytes": 2147483648}'.
Global server-config blockstore.local.max_log_bytes — applies to every
share that does not set the per-store key.
System-deduced default (25% of RAM, floor 1 GiB).
The global knob lives in the top-level server-config blockstore.local block
and binds to the env var DITTOFS_BLOCKSTORE_LOCAL_MAX_LOG_BYTES:
blockstore:
local:
max_log_bytes: 2147483648# 2 GiB global local-cache size hint (no longer gates writes).
Durability is a per-store property: whether bytes a store has accepted
survive a daemon crash / restart. Each local and remote store resolves an
effective durable flag at construction — a type default that an operator
may override.
Store type
Kind
Default durable
fs
local
true — bytes are on disk; un-mirrored chunks are never evicted, survive restart, and re-mirror asynchronously
memory
local
false — volatile, lost on restart
s3
remote
true — durable object storage
memory
remote
false — test/dev fixture, lost on restart
Override the default per store by adding a durable bool to the store’s
config JSON, e.g. for a local fs store on a volatile tmpfs mount:
A non-bool durable value is ignored with a startup warning (the type default
stands). The effective values are surfaced as Local Durable / Remote Durable in dfsctl store block stats.
CLOSE/COMMIT semantics. SMB CLOSE and NFS COMMIT (and the NFSv3 stable-WRITE
path) call the engine flush. A hard flush error (I/O fault, remote rejection,
metadata error) is always surfaced to the client regardless of the settings
below. Beyond that, whether CLOSE/COMMIT waits for durability is governed by a
per-share policy flag, require_durable_commit (default false):
require_durable_commit
CLOSE/COMMIT behavior
false (default)
Acknowledge once the flush succeeds — regardless of durability. The local→remote mirror stays fully asynchronous and observable via the unsynced-bytes metric / Pending Remote (bytes). Ordinary NFS/POSIX writes never EIO.
true (opt-in)
Acknowledge only when the data is on a durable store: committed := localDurable || (Finalized && remoteDurable). Trades latency for synchronous durability on non-fs-local stores.
Set it per share via the local block store config:
A non-bool value is ignored with a startup warning (default false stands).
When require_durable_commit = true, the strict rule resolves as follows:
Production (local fs):localDurable=true, so CLOSE/COMMIT ack
immediately — there is no wait on the remote, and the mirror stays fully
asynchronous. fs-local is always durable, so the flag is effectively a
no-op there (the fast path is identical to the default).
Volatile local (memory) + durable remote (s3): the data is only safe
once it reaches the durable remote, so CLOSE/COMMIT succeeds only when the
flush is Finalized. While the remote is unhealthy or a mirror pass is
in-flight, CLOSE/COMMIT returns a transient I/O error (NFS3ERR_IO /
NFS4ERR_IO / SMB STATUS_UNEXPECTED_IO_ERROR) and the client re-drives —
the bytes remain in local CAS and the syncer keeps mirroring. (NFS unstable
WRITE is unaffected — it still returns UNSTABLE and defers durability to a
later COMMIT.)
Volatile local with no remote (or a non-durable remote): the data is never
durable, so CLOSE/COMMIT reports the same transient I/O error rather than
silently acknowledging a write that a crash would lose.
In the default configuration none of the above transient errors occur —
CLOSE/COMMIT acks on a successful flush and the syncer mirrors in the
background. Use require_durable_commit = true only when you need synchronous
durability guarantees on a volatile-local + durable-remote share and can accept
the added latency.
The recommended way to select this is the per-share durability enum
(local | writeback | remote); require_durable_commit: true is equivalent
to durability: remote. See Durability & QoS tiers for the full
spectrum and per-tier throughput numbers.
dfsctl store block stats also shows Pending Remote (bytes) — the headline
data-at-risk gauge (local CAS bytes not yet mirrored to the remote) — which is
the way to observe the async mirror backlog under the default policy.
DittoFS splits file data into content-defined (FastCDC) chunks. A chunk is the
unit of dedup, of the local cache, and — critically — of a read fetch: a
small random read pulls the whole chunk covering its offset. The default chunk
floor is ~1 MiB, so a 4 KiB random read into a large file amplifies to ~1 MiB
(~256×). That is fine for sequential and archival workloads but poor for random
I/O (VM images, databases).
chunk_size (bytes) lowers the FastCDC minimum for a share, shrinking the read
unit. Effective average chunk size ≈ chunk_size; a hard ceiling is derived
(8× chunk_size) unless you set chunk_max explicitly.
Terminal window
# Random-access share: ~128 KiB chunks (≈8× less read amplification)
~16× more FileChunk rows; ~0 extra dedup loss on VM/DB
131072 (128 KiB)
~159 KiB
~32×
~8× more manifest rows
262144 (256 KiB)
~287 KiB
~64×
~4× more manifest rows
Notes:
S3 object count is unchanged. Chunks are packed into ~16 MiB block objects
regardless of chunk_size, so smaller chunks do not create more/smaller
S3 objects — only more FileChunk manifest rows. Writes/uploads keep their
full throughput.
Write-time only. Reads never re-chunk — the manifest records each chunk’s
boundaries — so changing chunk_size affects only newly written data, and old
data stays readable. Dedup is not restored across a change (different
boundaries → different hashes), but on VM/DB images dedup is already ~0.
Invalid or below-floor (< 4 KiB) values are ignored with a startup warning and
the default stands. Applies to fs local stores; memory local stores ignore
it (in-RAM reads have no amplification).
The CAS write path uses an async syncer and a fail-closed mark-sweep
garbage collector. The syncer’s sizing (claim timeout, etc.) is not an
operator-facing config section — it is auto-deduced from system resources at
startup and constructed in code; there is no syncer: config block (a stale
syncer: section in a config file is tolerated but ignored, logged as an
unknown key). Upload concurrency is adaptive by default — see below.
When you mirror a share to a remote (S3 or filesystem remote), DittoFS uploads
CAS chunks concurrently. Uploads are network-latency bound, not CPU bound:
a single PUT to a remote region sustains only a few MiB/s, so throughput scales
with the number of concurrent uploads until the uplink saturates. The right
number depends on the link, not the host — a CPU-derived default throttled fast
links and over-subscribed slow ones.
By default DittoFS discovers the right concurrency itself. It starts
conservative and ramps the number of in-flight uploads up while delivered
throughput (goodput) keeps rising, settling at the point where opening more
connections stops helping. It backs off only on upload errors or a real
throughput collapse — never on the latency rise that healthy concurrency itself
causes. No configuration is required to saturate the uplink.
To pin a fixed concurrency instead (disabling auto-tuning), set
--parallel-uploads N on the remote:
Terminal window
dfsctlstoreblockremoteadd--namer1--types3\
--bucket…--region…--endpoint…\
--parallel-uploads32# fixed window of 32; 0 (default) = adaptive
dfsctl store block remote edit --name r1 --parallel-uploads 0 returns a remote
to adaptive mode. Observe the live window via the Prometheus gauge
dittofs_datapath_upload_window (target concurrency) alongside
dittofs_datapath_uploads_inflight (actual in-flight uploads); see
Metrics.
The mark-sweep GC is the one tunable surface, configured via the top-level
gc: server-config section:
gc:
grace_period: 1h# Objects whose LastModified is newer than
# (snapshot - grace_period) are NEVER
# deleted. Default 1h. Values in (0, 5m)
# are REJECTED at config load; values in
# [5m, 10m) are accepted but emit a
# warning. The cushion protects in-flight
# uploads whose metadata-txn lands after
# the snapshot.
dry_run_sample_size: 1000# Maximum candidate keys reported in
# --dry-run mode. Default 1000.
compaction_live_ratio: 0# Reclaim dead bytes from partially-dead
# blocks. After each sweep, a block whose
# live bytes / object size is below this
# ratio is repacked (live chunks only) and
# the old block deleted. Must be in [0, 1];
# 0 (default) disables compaction. A value
# like 0.5 compacts a block once it is more
# than half dead.
auto_enabled: true# Run background GC automatically so you
# don't have to invoke the CLI. Default
# true. Set false to require manual
# `dfsctl store block gc`.
auto_interval: 15m# Period between background GC runs.
# Default 15m. Values in (0, 1m) are
# REJECTED. Ignored when auto_enabled is
# false.
Tuning guidance:
Background GC is on by default (auto_enabled: true, every
auto_interval) and reclaims orphaned blocks on both the local
and remote tiers. Disable it (auto_enabled: false) only if you want
to drive GC entirely on demand or via external scheduling.
You can still run GC on demand at any time:
dfsctl store block gc <share> (add --dry-run to preview, capped by
gc.dry_run_sample_size; add --reconcile to also reap rows leaked by
older versions).
gc.grace_period MUST be longer than your worst-case
metadata-commit latency after a successful PUT. The default 1h is
comfortable for any commit path that completes in seconds.
When a share has a remote block store configured (S3 or filesystem
remote), the on-disk local tier is a temporary write-through cache, not
durable storage — every chunk is mirrored to the remote and may be evicted
locally once synced. To stop a fast writer with a slow/lagging uploader from
filling the host volume, the local cache is bounded and writes apply
graceful, observable backpressure when it fills:
Bounded cache. If a remote is configured and you set no explicit
per-share size (dfsctl share … --local-store-size), the cache is capped at
blockstore.local.default_remote_cache_size (default 10 GiB). An
explicit --local-store-size always wins. Local-only shares are
unaffected — they keep their existing system-deduced local size and never
apply remote-cache backpressure. The cap is enforced lazily, on the
write/carve path (it evicts synced segments to make room for new writes); it
is not a background reaper, so on an idle or read-only workload the
resident local tier is not shrunk toward the cap. To reclaim local disk — or
to force cold, remote-served reads for read-path benchmarking — evict on
demand with dfsctl store block evict (drops the read buffer and drains
resident synced blocks; never drops not-yet-uploaded data). Remote read-miss
volume (the read-amplification signal) is observable via the
dittofs_datapath_block_range_read_bytes_total metric.
Backpressure stall. When the cache is full and every cached chunk is
still unsynced, a write stalls waiting for the syncer to drain to the
remote and free space, rather than failing. The stall is bounded by
blockstore.local.backpressure_max_wait (default 60s).
Hard failure only when the remote cannot drain. If the remote is
unhealthy (genuinely unreachable, not merely slow) or the backpressure
window is exceeded, the write fails with disk-full
(NFS3ERR_NOSPC / NFS4ERR_NOSPC / SMB STATUS_DISK_FULL) instead of
silently filling the disk.
Diagnosing a stall. Backpressure engage/release events are logged
(rate-limited) at INFO, so a stalled writer is never a mystery:
INFO local cache backpressure engaged: waiting for syncer to drain
Prometheus metrics for cache pressure / unsynced bytes are tracked
separately (server-wide instrumentation, issue #1188); today the signal is
the structured logs above.
The recycle bin is configured per share via dfsctl share create /
dfsctl share edit (or the REST share create/update body), not the
server config file. When enabled, deleting a file or directory moves it
into a visible #recycle directory at the share root instead of
destroying it; it can be restored over the mount or with dfsctl trash.
Setting (dfsctl flag)
REST field
Type
Default
Meaning
--enable-trash
trash_enabled
bool
false
Turn the per-share recycle bin on or off. Disabling it auto-empties the bin (permanently deletes its contents).
--trash-retention-days
trash_retention_days
int
0
Auto-purge bin entries older than N days. 0 = keep forever.
--trash-restrict-empty-to-admin
trash_restrict_to_admin
bool
false
Restrict emptying the bin to admins. Users may still restore.
--trash-max-size
trash_max_bytes
int64 (bytes)
0
Cap total bytes held in the bin; over-cap evicts oldest-first. 0 = unbounded.
--trash-exclude
trash_exclude_patterns
glob (repeatable)
(none)
Deletions matching any glob bypass the bin and are removed immediately.
A background reaper enforces trash_retention_days and
trash_max_bytes on an hourly interval. Deletes of items already
inside#recycle are permanent, and in-place truncate/overwrite of a
file’s content is not recycled (only unlink and replace-overwrite are).
dfsctl share show <name> displays the active trash configuration.
Terminal window
# Enable the bin with a 30-day retention and a 10 GiB cap
A remote block store may compress block payloads before upload and
decompress on download. The plaintext BLAKE3 hash remains the CAS key,
so dedup and GC are unaffected. The decorator is per-remote: every
share that references the remote inherits its compression policy.
Add a compression block to the remote store’s config JSON when
creating it:
Algorithm: "zstd" or "lz4". Defaults to zstd when the compression block is present but algo is omitted.
Notes:
Absence of the compression block means no wrapping — zero behavior
change for existing remotes.
Per-block adaptive: if the compressed body is not strictly smaller
than the plaintext, the decorator stores the raw plaintext with no
header. Incompressible payloads (random data, already-compressed
media) cost only the encoder pass, no on-wire expansion.
GetRange on a framed block decompresses the full block before
slicing — there is no random access into a compressed body. Read
paths that consume whole CDC chunks are unaffected.
The policy is captured at remote-store creation; restart the share
after editing the config to switch algorithms. Mixed framed and raw
blocks coexist within one remote and the reader auto-detects via the
5-byte DFCMP magic prefix.
A remote block store may also encrypt block payloads before upload using
client-side envelope encryption. Compression (when enabled) runs
before encryption — encrypted bytes are incompressible by design.
See ENCRYPTION.md for the full threat model and design.
Add an encryption block to the remote store’s config JSON:
The passphrase that unlocks a local key file is read from the
DITTOFS_ENCRYPTION_PASSPHRASE environment variable — never the config
file or command line.
The s3 remote store talks the AWS S3 API, so any S3-compatible object
store works — set a custom endpoint (and credentials) and DittoFS connects
to it instead of AWS. The store reads exactly these config keys (see
pkg/block/remote/s3/store.go and the factory in
pkg/controlplane/runtime/shares/service.go):
Key
Required
Default
Notes
bucket
yes
—
Bucket name. Must already exist; DittoFS does not create it.
access_key_id
yes
—
S3 access key. For GCS use an HMAC key, not a service-account JSON.
secret_access_key
yes
—
S3 secret key.
region
no
us-east-1
Some providers ignore it but the SDK still requires a value; the default is sent when omitted.
endpoint
no (AWS) / yes (others)
AWS
Service URL. Scheme optional — https:// is prepended when absent.
force_path_style
no
auto
Auto-enabled whenever endpoint is set. Set explicitly to false to opt back into virtual-hosted-style for providers that require it (e.g. GCS).
prefix
no
—
Key prefix prepended to every block (e.g. dittofs/). End it with /.
allow_private_endpoint
no
false
Required to point endpoint at a loopback or private-network address (MinIO, LocalStack, self-hosted RGW). See the SSRF note below.
Path-style addressing (endpoint.example.com/bucket/key) is the safe
default for non-AWS providers because virtual-hosted style
(bucket.endpoint.example.com/key) needs wildcard DNS and TLS SANs that
most S3-compatible gateways do not provide. DittoFS therefore flips
force_path_style on automatically the moment you set a custom endpoint;
the only providers below that need it turned back off are those that
require virtual-hosted style (GCS).
Private endpoints (MinIO, LocalStack, self-hosted RGW). DittoFS rejects
an endpoint that resolves to a loopback or private-network address
(127.0.0.0/8, 10/8, 172.16/12, 192.168/16, link-local, ULA) as an
SSRF guard; the cloud metadata address (169.254.169.254) is always blocked.
Self-hosted gateways normally live on exactly those networks, so add
"allow_private_endpoint": true to their --config to permit them, as the
MinIO and Ceph recipes below do.
Credentials live in the store’s own config (the --config blob below, or the
equivalent --access-key / --secret-key flags) — they are not read from the
DITTOFS_* server-config environment. Each recipe is a
dfsctl store block remote add invocation; attach the resulting store to a
share with dfsctl share create … --remote <name>.
These are configured exactly like the verified ones but have not been run
against a live account in CI — they are documented from each provider’s S3
compatibility guide. The per-provider column flags the one gotcha that bites.
Provider
Endpoint
Region
force_path_style
Gotcha
Cubbit DS3 ⭐ (DittoFS sponsor)
https://s3.cubbit.eu
eu-west-1
auto-on
Geo-distributed, S3-compatible object storage from Cubbit. Create an S3 access key/secret in the DS3 console; the bucket lives in your assigned region.
Google Cloud Storage (XML/HMAC)
https://storage.googleapis.com
us-east-1
set false
Use an HMAC key (access_key_id/secret_access_key), not a service-account JSON. GCS ignores region (any non-empty value works), so send the us-east-1 default. GCS wants virtual-hosted style, so override the auto path-style default to false.
Backblaze B2
https://s3.us-west-004.backblazeb2.com
us-west-004
auto-on
Endpoint embeds the region (s3.<region>.backblazeb2.com); region must match it. Use an application key, not the master key.
Wasabi
https://s3.us-east-1.wasabisys.com
us-east-1
auto-on
Region is in the hostname; mismatched region causes auth failures.
DigitalOcean Spaces
https://nyc3.digitaloceanspaces.com
us-east-1
auto-on
Endpoint is the datacenter (<region>.digitaloceanspaces.com); send region: us-east-1 (Spaces ignores it but the SDK requires a value).
Alibaba Cloud OSS
https://oss-us-west-1.aliyuncs.com
us-west-1
auto-on
Region is encoded in the endpoint host; use the matching OSS region.
Tencent Cloud COS
https://cos.ap-guangzhou.myqcloud.com
ap-guangzhou
auto-on
Bucket name must include the AppID suffix (name-1250000000); endpoint carries the region.
All of the above accept the same optional knobs as AWS S3 —
prefix, compression, encryption, and durable (see the preceding
subsections) — because they share the single s3 store implementation.
The BadgerDB metadata engine keeps two in-memory caches that dominate read
performance under concurrent NFS/SMB load:
the block cache — decompressed LSM-tree data blocks, and
the index cache — the block-offset indices used to locate keys.
Badger’s own defaults are tiny (256 MiB block, index cache disabled), which
thrashes on a busy server over a large directory tree. The symptom in the logs
is Block cache might be too small ... hit-ratio: 0.26 ... sets-rejected; every
cold lookup then walks the LSM tree from disk, which also widens the window for
the dedup transaction-conflict race and the local-cache write-path backpressure
stall.
By default both sizes auto-scale with the memory available to the process,
so no tuning is required:
Cache
Fraction of available RAM
Floor
Ceiling
block
15 %
512 MiB
4 GiB
index
7.5 %
256 MiB
2 GiB
The fractions are deliberately conservative because the same process also holds
the local journal cache, the metadata working set, and read buffers. The available-memory
figure is the cgroup limit inside a container, or physical RAM otherwise (same
detection used for block-store sizing). Examples:
4 GiB host → ~614 MiB block / ~307 MiB index (the floors don’t bind).
To override the auto-sizing, set the sizes explicitly (in MiB) in the top-level
metadata.badger block. Setting one dimension still auto-sizes the other:
metadata:
badger:
block_cache_mb: 2048# 0 (default) = auto-size from available RAM
index_cache_mb: 1024# 0 (default) = auto-size from available RAM
Recommended sizing vs. object/metadata count. As a rule of thumb the block
cache should hold the hot directory/inode working set. Each cached file/inode is
on the order of a few hundred bytes of decompressed LSM data, so:
Hot metadata objects
Suggested block_cache_mb
Suggested index_cache_mb
up to ~1 M
auto (≥512)
auto (≥256)
~1–10 M
1024–2048
512–1024
~10–50 M
2048–4096
1024–2048
> 50 M
4096+ (raise host RAM)
2048+
If you still see hit-ratio below ~0.8 or sets-rejected in the Badger logs,
the cache is undersized for the working set — raise block_cache_mb first.
These global sizes apply to every BadgerDB metadata store on the node. A single
store can be overridden via its config-map keys when it is created (see below):
--config '{"path":"...","block_cache_mb":2048,"index_cache_mb":1024}'.
DittoFS supports two complementary quota layers, both enforced by NFS and SMB:
Per-share quota (dfsctl share create/edit --quota-bytes) — a single byte
ceiling for the whole share. Exceeding it returns NFS3ERR_NOSPC /
STATUS_DISK_FULL.
Per-identity quotas (dfsctl quota …) — per-user (uid) and per-group
(gid) limits, plus an optional default-user fallback applied to any user
without an explicit quota. Each quota bounds both bytes and inodes
(file count) and supports a soft threshold with a grace period before
the soft threshold is enforced as a hard limit. Usage is charged to the file
owner (standard quota semantics). Exceeding a hard limit (or an expired
soft+grace) returns NFS3ERR_DQUOT / NFS4ERR_DQUOT /
STATUS_QUOTA_EXCEEDED. df / FSSTAT and SMB FS_FULL_SIZE_INFORMATION
report the smallest applicable quota for the calling identity.
Terminal window
# Per-user quota: uid 1000 limited to 10 GiB / 100k files, soft at 8 GiB,
# 7-day grace (604800s) before the soft byte threshold becomes hard.
Per-identity quota usage is tracked incrementally by every metadata backend
(memory / badger / postgres), keyed by owner uid and gid, and is reconstructed
from the file rows on startup. A chown that changes a file’s owner moves its
bytes and inode count between identities. Limits live in the control-plane DB
and are also manageable via the REST API
(/api/v1/shares/{name}/quotas[/{scope}/{id}]).
The soft → grace → hard state machine records when an identity first crosses
its soft threshold and enforces the soft limit as hard once the grace window
elapses. For an explicit user/group quota the grace timer lives on the quota
row. For the default-user fallback the timer is inherently per-user (each
user trips soft at a different time, and the single shared template row cannot
hold per-user state), so it is recorded in a small side table keyed by
(share, uid), written the first time a default-user breaches soft and reaped
when usage drops back under soft. This makes default-user grace durable across
a server restart — a restart no longer hands every over-soft default user a
fresh grace window.
Note: enforcement is best-effort (matching the per-share soft quota):
under high write concurrency a few operations may briefly exceed a limit until
usage catches up. This is standard for userspace NFS/SMB servers.
Kubernetes operator: the operator manages infrastructure only — there is
no Share/Quota CRD. Quotas are managed via the REST API / dfsctl as above.
DittoFS supports a unified user management system for both NFS and SMB protocols. Users, groups, and their permissions are stored in the control plane database (see Database Configuration) and can be managed via:
REST API - For programmatic management and integrations
Config file - For bootstrap configuration (imported on first run)
Permission resolution follows a priority order: user explicit permissions > group permissions (highest wins) > share default.
Note: Users and groups defined in the config file are imported into the database on first run. After that, use CLI commands or the REST API to manage them.
# Optional: explicit share permissions (override group permissions)
share_permissions:
/private: "admin"
- username: "editor"
password_hash: "$2a$10$..."
enabled: true
uid: 1001
gid: 101
groups: ["editors"]
- username: "viewer"
password_hash: "$2a$10$..."
enabled: true
uid: 1002
gid: 102
groups: ["viewers"]
User Fields:
Field
Type
Description
username
string
Unique username for authentication
password_hash
string
bcrypt password hash (cost 10 recommended)
enabled
bool
Whether the user can authenticate
uid
uint32
Unix UID for NFS identity mapping
gid
uint32
Primary Unix GID
groups
[]string
Group names this user belongs to
share_permissions
map
Per-share permissions (optional, overrides group)
NFS Authentication: NFS clients authenticate via AUTH_UNIX. The client’s UID is matched against DittoFS user UIDs. If a match is found, the user’s permissions are applied.
SMB Authentication: SMB clients authenticate via NTLM. The username is matched against DittoFS users, and permissions are applied from the user’s configuration.
Users and groups live in the control-plane database, not the config file. Manage them with
dfsctl against a running server (run dfsctl login first). See CLI.md for the
complete, generated reference.
User Commands:
Terminal window
# Create a user (password prompted interactively)
dfsctlusercreate--usernamealice
dfsctlusercreate--usernamealice--host-uid# map to your current host UID
initial_grant: 1# Floor when client requests 0 credits
max_session_credits: 8192# Per-connection credit window cap
# Adaptive strategy thresholds (ignored for fixed/echo)
load_threshold_high: 1000# Start throttling above this load
load_threshold_low: 100# Boost credits below this load
aggressive_client_threshold: 256# Throttle clients with this many outstanding
SMB Credit Strategies:
Strategy
Description
Use Case
echo
Grants what client requests (bounded by MinGrant/MaxGrant, clamped by window)
Recommended, default — matches Samba and MS-SMB2 3.3.1.2
fixed
Always grants initial_grant credits
Simple, predictable behavior
adaptive
Scales grants by live load and client-outstanding factors
Throughput-focused; may grant more aggressively than clients expect
SMB Credit Configuration Options:
Option
Default
Description
strategy
echo
Credit grant strategy
min_grant
1
Minimum credits per response
max_grant
8192
Maximum credits per response
initial_grant
1
Floor when client requests 0 credits (Samba-compatible)
max_session_credits
8192
Per-connection credit window cap (Samba’s smb2 max credits)
load_threshold_high
1000
(adaptive only) Server load that triggers throttling
load_threshold_low
100
(adaptive only) Server load that triggers boost
aggressive_client_threshold
256
(adaptive only) Outstanding requests that trigger client throttling
Note: Every response’s credit grant is clamped to the connection’s
remaining window capacity before being written, regardless of strategy.
This prevents the client’s per-connection cur_credits counter from
overflowing — Samba’s client hard-caps it at uint16 max and rejects
overflowing responses with NT_STATUS_INVALID_NETWORK_RESPONSE
(see issue #378 and docs/SMB.md §Credit Flow Control).
SMB3 encryption provides confidentiality and integrity for all messages on a session using AEAD ciphers (AES-GCM or AES-CCM). Encryption is negotiated during NEGOTIATE (cipher selection for SMB 3.1.1), enforced per-session during SESSION_SETUP, and enforced per-share via the encrypt_data field in share configuration.
Per-Share Encryption: Individual shares can require encryption via the encrypt_data flag. When enabled, the server sets SMB2_SHAREFLAG_ENCRYPT_DATA in the TREE_CONNECT response, and clients must encrypt all traffic to that share.
Secure default: As of v1.0.0 the shipped default is preferred, so SMB 3.x
sessions are encrypted out of the box while remaining wire-compatible with SMB 2.x
clients. Set encryption_mode: required to mandate encryption for sensitive
deployments (this rejects SMB 2.x clients, which cannot encrypt). If SMB is bound to
a non-loopback address with encryption_mode: disabled, dfs logs a startup WARN
because file data then traverses the network in cleartext. See
docs/SECURITY.md for the hardened template.
Enforcement Rules:
SESSION_SETUP: When mode is preferred or required, AEAD encryption keys are derived for SMB 3.x sessions. In required mode the SMB2_SESSION_FLAG_ENCRYPT_DATA flag is set in the response and every subsequent message on the session must be encrypted. In preferred mode the keys are available for per-share enforcement, but the session flag is not set — message encryption is only forced on trees connected to shares with encrypt_data=true.
Per-share (encrypt_data=true): encryption is forced on that tree regardless of the global mode. In required mode the unencrypted session is rejected at TREE_CONNECT. In preferred mode TREE_CONNECT succeeds, but every subsequent unencrypted request on the tree is denied with STATUS_ACCESS_DENIED (enforced by checkEncryptionRequired) — so plaintext access to an encrypt_data share is never actually allowed. The global mode only governs sessions to non-encrypt_data shares (mixed model in preferred).
Guest sessions: Never encrypted (no session key for key derivation).
SMB 2.x clients: Never encrypted (encryption requires SMB 3.0+). In required mode, 2.x clients are rejected at NEGOTIATE.
Security Note: For production environments handling sensitive data, set encryption_mode: required and enable encrypt_data on shares that hold confidential information.
SMB3 signing provides message integrity using AES-CMAC (3.0+) or AES-GMAC (3.1.1), replacing the HMAC-SHA256 used in SMB 2.x. Signing keys are derived from the session key using SP800-108 KDF.
DittoFS can advertise itself on the LAN so it appears in macOS Finder →
Network, Linux file managers (via Avahi), and the Windows Explorer →
Network view — the same job the external avahi-daemon and wsdd/wsdd2
daemons do for Samba, but built in-process. Discovery is off by default and
managed through the live adapter settings (dfsctl / REST), so it applies
immediately without an adapter restart:
This opens additional listeners: mDNS on UDP 5353, and — for WS-Discovery —
UDP 3702 plus an HTTP metadata endpoint on TCP 5357. NFS advertises the
first export’s path in a path= TXT record so Finder mounts the right share.
Advertised name. All advertisers share one instance-wide name, so a server
shows up consistently across Finder and Explorer. It defaults to
DittoFS-<hostname> (e.g. DittoFS-VM2) — distinct per host so several DittoFS
servers on one LAN stay distinguishable — and is overridable:
Terminal window
dfsctlsettingssetdiscovery.name"Marketing Files"# custom instance name
dfsctlsettingssetdiscovery.name""# revert to DittoFS-<hostname>
Each adapter formats the name for its own protocol: mDNS uses it verbatim, while
WS-Discovery folds it to a NetBIOS-legal computer name (upper-cased, illegal
characters replaced with -, capped at 15 characters) since Explorer renders it
as a Windows computer name. A name change takes effect the next time an
advertiser (re)starts — toggle discovery off/on, or restart the adapter.
Note: discovery is multicast-based and LAN-local. It works on a host
network (bare metal, VM, or a hostNetwork pod) but does not traverse
standard Kubernetes / overlay networks — Explorer and Finder will only see the
server on the same L2 segment. Mounting by name/IP always works regardless.
WS-Discovery makes the machine appear in Explorer; Windows still connects to
SMB on port 445, so the SMB adapter must be reachable there.
Windows host firewall: when DittoFS runs on a Windows host, the built-in
“Network Discovery” firewall rules only cover Windows’ own services (they are
scoped to System / svchost), so inbound traffic to dfs.exe is dropped by
default. Explorer then discovers the server over multicast but silently fails
the follow-up metadata fetch (a TCP 5357 connection that never completes),
and the machine never renders. Add inbound allow rules for the dfs.exe
program on TCP 5357 and UDP 3702/5353:
When netbios_domain is set, the SMB server advertises the AD domain in the
NTLM challenge (MsvAvNbDomainName / MsvAvDnsDomainName) and stamps it on
authenticated sessions, so domain users authenticate against the correct domain.
Unset → the server advertises WORKGROUP / local (pre-AD-4 standalone
behavior).
Offline keytab import (one keytab, both protocols)
A keytab can hold multiple service principals, so a single file serves SMB and
NFS. Pre-create the computer/service account in AD and export a keytab
containing bothcifs/<host>@REALM (SMB) and nfs/<host>@REALM (NFS):
Terminal window
# On a domain-joined admin host (samba-tool / adcli / Windows ktpass):
Point kerberos.keytab_path at the combined keytab. The SMB handler selects
the cifs/ principal (deriving it from the NFS service_principal, or via an
explicit override); NFS RPCSEC_GSS uses the nfs/ principal.
NTLM pass-through for AD domain users (NETLOGON machine account)
The keytab above authenticates AD users over Kerberos (mounting by the SPN
FQDN, e.g. \\server.example.com\share). But when a client connects by a name
that has no Kerberos SPN — an IP address, or the LAN-discovery name a user
gets by double-clicking the server in Explorer → Network (§10) — Windows
falls back to NTLM, which the KDC never sees. To let AD domain users
authenticate on that path, DittoFS validates their NTLM response against a
Domain Controller via NETLOGON pass-through (MS-NRPC NetrLogonSamLogon).
This is opt-in and requires a dedicated machine (computer) account —
distinct from the cifs/ service account in the keytab, because NETLOGON needs
a workstation-trust secure channel that only a machine account can establish.
The secure channel rides a Kerberos-authenticated SMB session to the DC’s
\PIPE\netlogon (reusing the same krb5_conf / realm as above). On success the
DC returns the user’s SID and group SIDs, which resolve to a UID/GID through
the directory idmap (§13 / the LDAP identity provider, or idmap_rid) and are
matched against share grants — the same SID-based authorization as the
Kerberos/PAC path, so a domain user or group (e.g. Domain Admins) granted
on a share is authorized identically over NTLM.
kerberos:
# ... realm / netbios_domain / keytab_path as above ...
machine_account:
enabled: true# opt-in; false (default) => no NTLM pass-through
account_name: "DITTOFS$"# the machine account sAMAccountName (trailing '$')
dc_address: # optional; empty => discover the DC via DNS SRV
- "10.0.0.10"
# Provisioning — pick ONE:
# (A) OFFLINE: you pre-create the computer account and give DittoFS its
# password. Nothing is written to AD at runtime.
secret: "the-machine-account-password"
# (B) ONLINE JOIN: DittoFS creates the computer object itself over LDAPS on
# first domain logon, owns the password, and rotates it. Requires a
# privileged bind account that can create computer objects. Omit
# `secret` when using this. LDAPS (or ldap:// + start_tls) is mandatory —
# AD refuses a machine-password write over an unencrypted connection.
realm and netbios_domain are required for pass-through (the secure
channel and NTLM TargetInfo both need them). The secret / bind_password
are redacted in dfs config show. For a full walkthrough — pre-creating the
DITTOFS$ account and testing the Explorer double-click — see
docs/guide/windows-ad-setup.md.
Controls the background scheduler that drives per-share snapshot policies
(schedule + retention). Policies themselves are configured per share via
dfsctl share snapshot-policy or the REST API — see
SNAPSHOTS.md §12. These
knobs only tune the daemon-wide scheduler loop.
snapshot:
# How often the daemon scans for due policies. The per-share policy
# interval (not this knob) controls how often a share is snapshotted.
scheduler_poll_interval: 1m# default 1m
# Turn the scheduler off entirely. Policies are still stored and can be
# run manually with `dfsctl share snapshot-policy run`.
scheduler_disabled: false# default false
# Per-request budget for the synchronous restore endpoint.
Resolves directory principals (a user@REALM form or an AD SID) to a Unix
identity by querying LDAP/AD. It reads the RFC2307uidNumber/gidNumber
POSIX attributes (the idmap_ad model) or falls back to RID-based
derivation (idmap_rid), and resolves the user’s group memberships — including
nested AD groups via the LDAP_MATCHING_RULE_IN_CHAIN matching rule. The
provider is registered in the identity-resolution chain after Kerberos, so an AD
principal/SID with no local user mapping is resolved against the directory.
Security: the connection is encrypted by default. A plaintext ldap://
connection is refused unless it is upgraded with start_tls: true or the
operator explicitly opts in with allow_plaintext: true. Prefer ldaps://.
ldap:
enabled: true
url: ldaps://dc.example.com:636# ldaps:// (preferred) or ldap:// + start_tls
start_tls: false# upgrade an ldap:// connection to TLS
allow_plaintext: false# explicit opt-in for an unencrypted bind (off)
(required when enabled; empty triggers an anonymous bind and is rejected)
ldap.user_attr
DITTOFS_LDAP_USER_ATTR
sAMAccountName
ldap.realm
DITTOFS_LDAP_REALM
(empty)
ldap.idmap
DITTOFS_LDAP_IDMAP
rfc2307
ldap.nested_groups
DITTOFS_LDAP_NESTED_GROUPS
false
ldap.max_group_results
DITTOFS_LDAP_MAX_GROUP_RESULTS
200
ldap.timeout
DITTOFS_LDAP_TIMEOUT
10s
ldap.tls.ca_cert_file
DITTOFS_LDAP_TLS_CA_CERT_FILE
(system roots)
ldap.tls.client_cert_file
DITTOFS_LDAP_TLS_CLIENT_CERT_FILE
(empty)
ldap.tls.client_key_file
DITTOFS_LDAP_TLS_CLIENT_KEY_FILE
(empty)
ldap.tls.insecure_skip_verify
DITTOFS_LDAP_TLS_INSECURE_SKIP_VERIFY
false
ldap.tls.min_version
DITTOFS_LDAP_TLS_MIN_VERSION
1.2
Admin-configured identity links (dfsctl idmap add) take precedence over the
directory query: a link for an ldap external ID resolves to the mapped local
user before any LDAP search is issued.
Samba AD-DC self-signed certificates. A default Samba AD-DC serves an
auto-generated TLS certificate that commonly has a negative serial number.
Go’s crypto/x509 rejects such certificates at parse time — before TLS
verification runs — so ldap.tls.insecure_skip_verify does not bypass it.
The dfs binary is built with x509negativeserial=1 so ldaps:// against a
default Samba AD-DC works out of the box. For production directories, prefer a
properly-issued DC certificate (the Go toggle is slated for removal in a future
release).
Managing identity providers over the API (no restart). The ldap.* and
kerberos.* keys above seed the configuration on first boot. After that, both
providers can be read, updated, and tested over the control-plane API without
editing files:
Method & path
Purpose
GET /api/v1/identity-providers
List providers + enabled state (no secrets).
GET /api/v1/identity-providers/{type}/config
Read config (bind password redacted to ********).
PUT /api/v1/identity-providers/{type}/config
Create/replace config (validated).
POST /api/v1/identity-providers/{type}/test
Dry-run dial+bind (LDAP) / keytab check (Kerberos); never persists.
{type} is ldap or kerberos; all routes are admin-only. A persisted config
takes precedence over the file/env config on subsequent boots. LDAP
changes hot-reload the live resolver; Kerberos changes take effect on the
next server restart (the NFS/SMB adapters bind it at startup). The bind
password is write-only — submit ******** (or omit it) on PUT to keep the
stored secret. Equivalent CLI: dfsctl identity-provider {list,get,set,test}
(see CLI.md).
Current servers store remote data as packed blocks/<id> containers. Shares
that still hold standalone-CAS state (per-chunk cas/ objects and locators
from v0.16-v0.21 servers) are converted automatically at startup, per share,
before the share serves — no command, flag, or sentinel involved. The
conversion is idempotent and resumable; a killed run converges on the next
start. See the migration guide.
If a share’s remote is unreachable while standalone chunks remain, that
share fails to start (its data would be unreadable anyway); restore
connectivity and start again.
Pre-v0.16 .blk layouts: migrate with dittofs ≤ v0.21 first
The offline dfs migrate-to-cas tool was removed after v0.21. On startup,
dfs start still probes each share for the legacy .blk layout (a
.cas-migrated-v1 sentinel from an old migration short-circuits the probe)
and refuses to start un-migrated shares:
Exits with code 78 (EX_CONFIG per sysexits(3)).
Prints a directive to stderr naming the offending share and pointing at
dittofs ≤ v0.21 for the .blk migration.
Halts on the FIRST share that surfaces the legacy layout.
Run dfs migrate-to-cas on v0.21, verify the per-share .cas-migrated-v1
sentinel exists, then upgrade — the automatic conversion above finishes the
job.
DittoFS exposes a Prometheus /metrics endpoint on a dedicated listener,
separate from the control-plane API and the protocol adapters. It is opt-in
and disabled by default. When enabled it serves the standard Go/process
collectors plus the DittoFS instruments (request RED metrics per adapter,
connection counts, sync/remote/local-store/quota/GC gauges, and snapshot
timestamps).
# Top-level metrics block (NOT nested under `server:`).
metrics:
enabled: true# opt-in; default false
host: 127.0.0.1# bind interface; default loopback only
port: 9090# default 9090
path: /metrics# default /metrics
auth: none# "none" (default) or "token"
token_file: ""# path to a file holding the Bearer token (auth: token)
tls: # optional; reuses the control-plane TLS shape
cert_file: ""
key_file: ""
client_ca: ""# set for mTLS
min_version: "1.2"# minimum TLS version: "1.2" (default) or "1.3"
Option
Default
Description
enabled
false
Turn the metrics listener on.
host
127.0.0.1
Bind interface. Binds loopback by default; set 0.0.0.0 to expose it deliberately (pair with a firewall/NetworkPolicy).
port
9090
TCP port for the endpoint.
path
/metrics
HTTP path. Must start with /.
auth
none
none (rely on bind host + network policy) or token (require a Bearer token).
token_file
File whose trimmed contents are the expected Bearer token. Required when auth: token.
tls.cert_file / tls.key_file
Serve the endpoint over HTTPS.
tls.client_ca
Require + verify client certificates (mTLS).
tls.min_version
1.2
Minimum negotiated TLS version: 1.2 or 1.3.
Security: the endpoint binds 127.0.0.1 by default so it is not reachable
off-host without an explicit host change. In production prefer one of:
bind loopback and scrape via a sidecar; restrict with a firewall/NetworkPolicy;
or enable auth: token (and/or TLS). Never expose 0.0.0.0 unauthenticated on
an untrusted network.
DittoFS is only a scrape target. It never runs, bundles, manages, or depends
on a Prometheus/Thanos/Mimir instance. Point your cluster’s existing Prometheus
at the endpoint. For production, the standard path is the
kube-prometheus-stack
(prometheus-operator) with long-term storage via Thanos or Mimir. DittoFS ships
no dashboards or compose artifacts — the guidance below is enough to wire it
into any standard stack.
DittoFS Pro bundles a turnkey monitoring stack on top of this endpoint: a
docker compose --profile monitoring profile that stands up Prometheus +
Grafana with a pre-provisioned DittoFS dashboard, plus an in-dashboard Metrics
section. The endpoint and config documented here are the contract it builds on.
The DittoFS Kubernetes operator wires this up for you when you opt in on the
DittoServer spec. It renders the metrics container port, a dedicated metrics
Service carrying prometheus.io/scrape annotations (for annotation-based
discovery), and — only if the monitoring.coreos.com CRDs are installed and
you enable it — a ServiceMonitor for the prometheus-operator. If those CRDs
are absent the operator skips the ServiceMonitor (logged) and never fails the
reconcile.
apiVersion: dittofs.dittofs.com/v1alpha1
kind: DittoServer
metadata:
name: example
spec:
storage:
metadataSize: 10Gi
metrics:
enabled: true# renders the metrics Service + scrape annotations
port: 9090
path: /metrics
# bearerTokenSecret: # optional authed scrape
# name: dittofs-metrics-token
# key: token
serviceMonitor:
enabled: true# requires the prometheus-operator CRDs
interval: 30s
labels: # match your Prometheus serviceMonitorSelector
Build (or import) a Grafana dashboard around the ⭐ core series: an adapter RED
row (rate of dittofs_adapter_requests_total, error ratio, latency percentiles
from dittofs_adapter_request_duration_seconds), a durability row
(dittofs_remote_up, dittofs_sync_pending_bytes, and snapshot success rate
from dittofs_snapshot_operations_total), and a capacity row
(dittofs_localstore_disk_used_bytes, dittofs_quota_used_bytes). DittoFS does
not ship a dashboard JSON; these series are stable and named for direct use.
Note: DITTOFS_ADMIN_INITIAL_PASSWORD is only used during the very first server start when the admin user is created. It has no effect on subsequent starts. When set, the admin account’s MustChangePassword flag is not enabled.
On the first start (when no admin user exists yet) the initial admin password is chosen in this precedence:
DITTOFS_ADMIN_INITIAL_PASSWORD (env, plaintext) — sets a known password and also derives the NT hash, so the admin can authenticate over SMB as well as the REST/control-plane API.
admin.password_hash (config, bcrypt $2a$/$2b$/$2y$) — sets a known credential without writing a plaintext secret to disk. No NT hash is derivable from a bcrypt hash, so an admin bootstrapped this way works for the control-plane/REST API only, not SMB (use option 1 for SMB). A value that is not a valid bcrypt hash is rejected at startup.
Auto-generated — a random password is generated. It is printed once only when dfs start runs with stdout attached to an interactive terminal (i.e. dfs start --foreground in a real TTY). In background/daemon mode, and under Docker/systemd/CI where stdout is a pipe, the password is not written to the log and cannot be recovered — the log only records that an admin user was created. Recovery via dfsctl user password admin is not possible in this state (it requires an authenticated admin session). Options 1 and 2 are consulted only while bootstrapping a new admin, so once this first start has created the admin user, setting them and restarting will not change the password — you must remove the admin user (reset the control-plane database) and re-bootstrap with option 1 or 2 set. For any non-interactive deployment, set option 1 or 2 before the very first start.
Options 1 and 2 also skip the forced first-login password change (the operator already chose the password). All three apply only on first start; later starts never change an existing admin’s password.
admin:
username: admin
# bcrypt hash, e.g. from `htpasswd -bnBC 10 "" 'my-secure-password' | tr -d ':\n'`