Skip to content

DCTL global flags

Every flag on this page is accepted by every subcommand, before or after the subcommand name — dctl --json ls vault:photos and dctl ls --json vault:photos are the same invocation. They are defined in one place, crates/dctl-cli/src/cli/globals.rs, and grouped here under the same headings dctl --help uses.

Which flags actually change a given command is documented on that command's page under Options inherited from parent commands; a flag that is irrelevant to a command is accepted and ignored rather than rejected.

Resolution order. For any setting that has more than one source, the most specific statement of intent wins:

sh
command-line flag  >  DCTL_* environment variable  >  config.toml  >  built-in default

Only two settings reach config.toml today: the remote definitions themselves, and verify (per remote — see Durability). Every other default in the tables below comes from crates/dctl-cli/src/constants.rs, which is the single source of truth for them.

Environment variables are the DCTL_-prefixed spellings shown in each table. An exported-but-empty variable is treated as unset wherever DCTL can tell the difference, because a blank value is almost always a CI interpolation that failed rather than a deliberate choice.

-h/--help and -V/--version are clap built-ins rather than members of the global block, and are not repeated below.

Status. Every flag on this page either acts or is refused. There is no third state. A flag this build cannot honour fails the run before anything is read or written, names itself, and says which layer owes the capability — the way --key-file always has. They are listed together under Flags that are refused.

This replaces a not yet honoured category that eleven flags sat in, including --bwlimit and --max-transfer. Both are cost controls — the flags an operator sets so a runaway job cannot generate a bill — and both were accepted, listed here, and silently ignored: --bwlimit 1k moved 10 MiB at 32.9 MiB/s, and --max-transfer 1M moved the whole 10 MiB and exited 0. A flag in that state is worse than one that does not exist, because the operator believes they capped their egress. crates/dctl-cli/src/cli/reach.rs now holds the classification for every global flag and a test fails the build if a new one is added without either reaching an implementation or being explicitly refused.


Configuration#

FlagValueDefaultEnvironment
--configPATHplatform config dir + config.tomlDCTL_CONFIG
--remoteSPECnoneDCTL_REMOTE
--indexPATHplatform data dir + vault.redbDCTL_INDEX

--config PATH#

The configuration file this invocation uses. The default is config.toml inside ~/.dctl/config.toml. One directory holds everything DCTL writes, and the layout is identical on macOS, Linux and Windows; DCTL_HOME relocates the whole tree at once. on Windows, and ./.dctl where no home directory can be determined. A file you name and get wrong is a hard error; the default path being absent is a fresh installation and yields an empty configuration, which is what lets DCTL run entirely from flags and environment variables.

The file holds non-secret settings only — remote names, types, endpoints, buckets, regions, policy defaults (PLAN.md §14). It is created 0600, and DCTL warns on stderr (but does not refuse to run) when it is group- or world-readable, because what leaks is reconnaissance rather than credentials.

--remote SPEC#

The remote a command operates on when it is given no explicit target. Commands still accept REMOTE:PATH positionally and that always wins; this is the setting that lets a container or a cron job carry the destination in its environment instead of in every command line. A spec is name:path (vault:photos/2024), and a single ASCII letter before the colon is a drive letter on a platform that has drives, so C:\data is a local path on Windows and the remote C elsewhere.

--index PATH#

The local encrypted index database (redb), which is the record of what is actually stored: the durability contract's commit step (PLAN.md §6 step 6) writes here, and nothing counts as stored until it does. The default is vault.redb inside ~/.dctl/index/. Point it somewhere else to keep several vaults side by side on one machine, or to put the index on a disk you actually back up.


Authentication#

FlagValueDefaultEnvironment
--passwordPASSWORDnoneDCTL_PASSWORD
--password-commandCOMMANDnoneDCTL_PASSWORD_COMMAND
--password-filePATHnone
--recovery-phrasePHRASEnoneDCTL_RECOVERY_PHRASE
--recovery-phrase-filePATHnone
--key-filePATHnone
--no-ask-passwordoff

Sources are tried in a fixed order, most explicit first, so a scripted run is never surprised by a prompt it did not ask for:

sh
--password / DCTL_PASSWORD  --password-command  --password-file  interactive prompt

The recovery phrase is a separate factor, not another entry in that chain: it unwraps its own slot in the envelope, so supplying it does not consult the password sources at all.

Whatever the source, one trailing line ending is stripped and nothing else: leading and interior whitespace are part of the passphrase, because trimming them would make a correct password fail against the vault it created. An empty result is refused rather than tried. Every failure on this path exits 22 (vault_locked).

--password PASSWORD#

The vault password as a literal argument. Reach for it only in a throwaway shell: an argument is visible in ps to every other process on the machine and lands in shell history. DCTL_PASSWORD fills the same field and is the form to use for containers and CI; -v reports which of the two supplied the value, since they are otherwise indistinguishable at the point of use.

--password-command COMMAND#

A command whose stdout is the password — the flag to use with an existing secret manager (pass show vault, op read, a cloud secret fetch). It runs through the platform shell (sh -c, or cmd /C on Windows) so a pipeline works without quoting games, and it must exit 0. The helper's own stderr is deliberately not echoed: it is attacker-influenceable text on a credential path, and a helper that prints the secret on failure must not leak it into DCTL's logs.

--password-file PATH#

Reads the password from the first line of a file. Useful for a systemd unit or a Kubernetes secret mounted at a path, where a command would be indirection for its own sake. Protect the file yourself — DCTL reads whatever mode you left on it.

--recovery-phrase PHRASE#

The BIP-39 phrase dctl init prints once, used instead of the password. It is global rather than a flag on dctl vault recover, and that is the point of the recovery story: somebody who has lost their password needs their data, not a receipt saying the phrase is valid. dctl ls vault: --recovery-phrase "…" and dctl cat, dctl copy, dctl restore all run under it.

Same exposure warning as --password — an argument is visible in ps — with one difference that makes it worse: a phrase cannot be rotated by changing the password. Changing the password never invalidates the phrase, which is what keeps a paper backup current for the vault's whole life and is also why leaking it is permanent. Prefer DCTL_RECOVERY_PHRASE or the file form.

--recovery-phrase-file PATH#

The whole file is read and BIP-39's own whitespace rules are applied — not --password-file's first-line rule. Twenty-four words come off a sheet of paper, and somebody transcribing them will break the lines where the paper breaks them; reading only the first line would reject a correct phrase at the one moment it is being used.

--key-file PATH#

The second-factor keyfile from PLAN.md §8: something you have mixed into the KEK alongside something you know, so that a stolen password alone does not open the vault.

Not supported by the engine in this build, and the layer is named because it is not the CLI's: dctl_core::Vault::init and ::unlock take a password and no factor parameter, so there is no argument for a keyfile to become. The vault's key-encryption key is derived from the password alone, the file this flag names is never opened, and the factor cannot be applied to either creating a vault or opening one. PLAN.md §8 — the auth/key model of phase 0 (§11) — is where the missing half is specified: the password half shipped and the factor half did not, so this is an unfinished foundation rather than a future feature.

Rather than proceed with one factor when you asked for two, the flag is refused with exit 7 at both of the places a key is derived:

  • Creating a vault. dctl init --key-file creates nothing — no envelope, no index.
  • Opening one. Every unlock passes through a single refusal point, which runs before the password is read and before the remote is resolved. So dctl copy ./src vault: --key-file kf.bin exits 7 having transferred nothing and read nothing.

Both refusals name the flag, the capability, the crate that owes it, and what did not happen. A run given a keyfile never exits 0, so a success can never be a run that silently dropped one.

The message is assembled in one place rather than at each call site, which is a correctness property and not tidiness: the chokepoint every command passes through used to compose it from the command name alone, and dctl init --key-file kf therefore reported "dctl init is not implemented in this build" — a false statement about a command that works.

(A command whose vault path is not implemented at all — the listings today — fails with its own exit 7 before reaching the unlock. The reason differs; the outcome, that nothing proceeds on one factor, does not.)

--no-ask-password#

Never prompt; fail instead. This is the flag that turns an unattended job's worst outcome — hanging forever on an invisible prompt — into an immediate exit 22 with a hint naming the non-interactive sources. Set it on every cron job and container entrypoint; a run with no terminal fails the same way anyway, but not before it has tried.


Durability#

FlagValueDefaultEnvironment
--verifychecksum | sample | strictchecksum
--verify-samplesN8
--checksumoff
--size-onlyoff
--modify-windowSECONDS1
--immutableoff

--verify MODE#

The cost/assurance dial of the verified-write contract (PLAN.md §6 step 5). It sets how hard DCTL looks after a write, and it is the same dial dctl verify and dctl scrub use when they re-ask the question later.

ModeWhat it doesWhat it provesExtra egress
checksum (default)Compares the provider's stored checksum against the one computed locally.The provider still holds the ciphertext DCTL sent.none
sampleReads back and re-authenticates the object. In this build it reads all of it, so it costs what strict costs.The stored object decrypts and authenticates.full
strictReads and decrypts every object in full and confirms its whole-file BLAKE3.The plaintext is intact, end to end.full — a second copy of the data

Be clear about what strict costs. A full read-back downloads everything it just uploaded: a 50 GB video costs 50 GB up and 50 GB back down, and on a metered bucket that is a doubled bill for the run, not a rounding error. Aimed at a tree rather than a single object it is worse still — --verify strict over a 50 TB vault is a 50 TB download, so the integrity commands warn before starting whenever a byte-reading mode meets a prefix. sample is the middle setting: on a sealed upload it reads back the object's first and last chunks plus --verify-samples seeded interior ones, authenticating each chunk's tag, which catches wholesale corruption and seam damage without buying a second copy of the data. Be equally clear about what a sample cannot prove: no subset of chunks can evaluate the whole-plaintext BLAKE3 or the object's footer, so a corrupt chunk outside the sample is not detected — sample is a spot check against rot, and strict remains the only whole-object statement. Anyone who configured verify = "sample" before this build was silently getting strict (at strict's full egress); they now get what the word says, at the price the word implies. On the non-sealed directions (downloads and plain stores) sample still reads in full — the honest sampling exists on the sealed upload read-back, where the chunk geometry gives a sample meaning.

Why checksum is still a strong default. It is not "no verification" — step 4 of the write pipeline is mandatory and runs whatever --verify says: the provider returns the stored object's checksum, DCTL compares it with the value it computed locally, and a mismatch hard-aborts. The staged object is deleted, a checksum-mismatch error is logged, the source file is left untouched, and — the part that matters — the index commit never happens. Since that commit is the only thing that makes a file count as stored (PLAN.md §6 step 6), a failed verification commits nothing: there is no half-stored file, no "copied" report, and for move, no deleted source. Corrupt parts cannot land in the first place either, since each multipart part's checksum is verified by the provider on ingest and a bad part is rejected and retried.

What checksum does not prove is that the stored ciphertext still decrypts, or that the plaintext hash matches end to end. That is the gap sample and strict close, and it is why every integrity report names the mode that produced it — "1,204 objects verified" is three different claims depending on this flag, and readers assume the strongest one.

Verification strength is also a per-remote setting in config.toml (verify = "strict"), because the trade-off belongs to the destination: a full read-back is free against a local disk and expensive against a bucket. Both spellings use the same lower-case words, and --verify on the command line overrides the configured value.

--verify-samples N#

Interior chunks a --verify sample read-back draws per object, on top of the always-read first and last. Default 8; 0 is a usage error, because head and tail are mandatory and a depth of nothing is a contradiction. Each run logs the seed its picks derived from, so a reported failure can be replayed.

The flag was refused for a release, because accepting it while sample secretly read every chunk would have published a sampling depth nothing applied. The sampled read is real now, and so is the number.

--checksum#

Decide whether a file needs transferring by comparing content hashes instead of size and modification time. Slower on the local side (the source must be read to hash it) and correct in the cases the default misses — a file rewritten with identical length and a preserved mtime. If either side cannot supply a hash, the command fails rather than quietly falling back to a weaker comparison, since a weaker answer dressed up as the one you asked for is exactly the misreporting the durability contract exists to prevent.

Against a plain object store — a local:, sftp:, b2:, s3: or r2: remote holding ordinary objects — DCTL reads each object back and hashes it. A plain store holds the plaintext, so the hash of what it is holding is exactly the digest the comparison needs. That is a full pass over the destination, which on a metered provider is egress, and the run says so once on stderr rather than letting you find it on an invoice. --size-only, or the default size-and-modification-time comparison, reads nothing.

A vault destination answers for free, from the plaintext BLAKE3 its index recorded at write time. The one object that still cannot answer is one nobody has read: a row written by dctl index rebuild carries an empty digest, which is unknown rather than a hash, and the command fails naming the file and the remedy.

--size-only#

Compare by size alone, ignoring modification time. The fastest and weakest comparison: it is the right choice against a destination whose timestamps are untrustworthy, and the wrong one anywhere an in-place edit might preserve a file's length. Conflicts with --checksum — the two ask for contradictory comparisons, so passing both is a usage error rather than a silent precedence rule. With neither flag, the default is size plus modification time within --modify-window.

There is no longer an exception for vaults. A sealed vault used to record the moment each object was written rather than the source file's modification time, so the default comparison could not mean anything against one, and DCTL substituted a content comparison and warned about it. Both the cause and the substitution are gone: a vault index row and a sealed object's own metadata carry the source's time, so a vault answers the ordinary size-and-time question like any other destination.

--modify-window SECONDS#

How far apart two modification times may be and still count as the same instant. Defaults to 1.

A tolerance is not optional, because the two sides of a comparison record time differently and cannot be talked out of it. A local filesystem keeps nanoseconds (ext4), 100 ns (NTFS) or two whole seconds (FAT); DCTL's own records — the index row, a sealed object's metadata and every backend listing — keep whole unix seconds; SFTP carries mtime as unsigned 32-bit seconds and cannot return more; B2 stores milliseconds. With a zero tolerance every one of those differences reads as "modified", and a nightly sync re-uploads the dataset for a reason nobody can see.

Widen it for a destination that rounds: --modify-window 2 is what a FAT-formatted backup disk needs. Narrowing it below 1 is refused, with a message saying why — DCTL stores whole seconds, so a smaller window cannot express a real distinction and can only reject unchanged files. A flag that parsed and then silently ignored its argument would be worse.

The same value is used by copy, move, sync and check, from one place, so check cannot disagree with the sync that produced the tree it is checking.

--immutable#

Refuse to modify or delete anything that already exists; only additions are allowed. It converts an overwrite into a hard failure (dctl rcat onto an existing object, a restore that would replace files, a touch that would re-stamp one) rather than a prompt, which is what makes it usable in a write-once archival job. Combining it with a command flag that also forbids creation — dctl touch --no-create --immutable — leaves the command unable to do anything at all and is refused as a usage error.

In the transfer family (copy, copyto, move, moveto, sync) the decision is made at plan time, against the same diff --dry-run prints. Any entry whose action is update (an existing destination object being replaced) or delete (a sync extra being removed) makes the whole run fail with exit 7 (fatal_error) before a single byte moves, and the message names the paths that caused it:

console
$ dctl copy ./src ./archive --immutable
error: --immutable, but 2 existing destination object(s) would be replaced or removed: update a.txt, update photos/b.jpg
warning: --immutable allows only additions. Point the transfer at a destination
that does not already hold these objects, or drop --immutable. To see the full
list, re-run with --dry-run and without --immutable.
$ echo $?
7

Three consequences worth stating plainly:

  • A destination that does not exist yet is not an overwrite. Additions still transfer normally — that is the whole point of "only additions are allowed".
  • --dry-run --immutable fails the same way a real run would. That is why the check lives in the plan rather than in the write: a write-once archival job is verifiable before it is scheduled, instead of being discovered unsafe by the first file it ruins.
  • It governs the destination, not a move's source. Removing the source is what move means, so reading the flag that way would make move --immutable a contradiction rather than a safeguard; copy is the verb that leaves a source alone.

Long refusals are elided after ten paths (the count in the message is always exact); re-run with --dry-run and without --immutable to see the full plan.

--immutable with --no-traverse is a usage error (exit 1), for the same reason --no-create --immutable is: --no-traverse never lists the destination, so every source file is planned as a first-time copy and the overwrite this flag exists to forbid is invisible to the planner. Honouring the pair would silently downgrade a guarantee to a hope.

The exit code differs by command and each is a published contract: 7 from the transfer family and from restore, 1 from rcat (a single named object, checked before anything is listed).


Transfer#

FlagValueDefaultEnvironment
--transfersN1
--checkersN1
--bwlimitRATEunlimited
--retriesN3
--low-level-retriesNrefused
--timeoutSECONDS300
--contimeoutSECONDS60
--max-transferSIZEunlimited
--max-durationDURATIONunlimited

Every flag in this group used to parse and do nothing. Six now act (--bwlimit, --retries, --max-transfer, --max-duration, --timeout, --contimeout), two accept only the value that is true of this build (--transfers 1, --checkers 1), and one is refused with the reason — exit 7, before anything is read or written, the way --key-file is. There is no fourth outcome; see Flags that are refused.

--transfers N#

Files transferred at once. Only 1 is accepted. This build's executor walks the plan in plan order on a single task, so that the list --dry-run prints and the list the machine performs are provably the same one; --transfers 2 is refused rather than accepted and ignored.

The default used to be 4, which was the number a concurrent executor would have wanted and which nothing read. Making it concurrent is a change to the durability contract rather than to a number: the audit chain is appended in plan order, a fatal error stops the run instead of failing every remaining file identically, and both need an answer before a second file may be in flight.

--checkers N#

Metadata comparisons run at once. Only 1 is accepted, for a sharper reason than --transfers: there is no checker stage to make parallel. Comparison happens once, while the transfer plan is built, over two listings that are already in hand. Parallel checking would be a different pipeline, not a larger number.

--bwlimit RATE#

Bandwidth ceiling, written with the usual size suffixes (10M, 1.5MiB) or off for unlimited. The rate is bytes per second, not bits — 10M is roughly an 80 Mbit/s link fully used. A value that does not parse is a usage error (exit

  1. before the command starts, never a silently unlimited run.

Granularity: one file. Each file is charged for the bytes it actually moved, and the next window waits until that charge has been paid off at the configured rate. A window is a few megabytes, so this shapes the wire rather than merely spacing out files: --bwlimit 1M holds one 100 MiB object to about 100 s while it is being transferred, instead of putting it on the link as fast as the link will take it and waiting afterwards.

It did exactly that until the streaming engine landed, and the gap was the whole width of the flag. The debt was charged once per finished file, because the engine handed a whole object to the storage backend in one call and got a byte count back at the end — there was no per-buffer seam to charge. So a run of one object was not paced at all, and neither was the last file of any run: 8 MiB moved as a single file at --bwlimit 1M took 47 ms, while the same 8 MiB as eight files took 7051 ms. Bytes now move in bounded windows in both directions, the limiter is installed as the storage layer's meter, and the same 8 MiB as one object takes 8497 ms against an 8000 ms target.

Both uses of the flag are served:

  • Capping a bill or a metered link. The average rate over the run is the limit, so the bytes per month are the limit.
  • Keeping a video call usable while a backup runs. One enormous file is now paced for its duration rather than saturating the uplink until it finishes.

Two details worth knowing. The first window of a run is free — the charge is made after bytes move, so that it is a measurement rather than an intention — which costs a few megabytes of burst at the very start and nothing after. And a sealed destination is charged for ciphertext, which is what actually crosses the link and is a percent or so larger than the plaintext; that is why the measured 8497 ms sits just above the 8000 ms the plaintext alone would predict.

--retries N#

How many times a whole failed file is retried — the original attempt plus N repeats, so --retries 0 still transfers each file once. Everything inside is re-attempted: a repeat re-reads the source, re-encrypts and re-verifies rather than replaying a buffer.

Only failures a repeat can fix are repeated: a temporary error (a reset connection, a 503, a dropped ssh session) and a checksum mismatch, which means the destination stored something other than what was sent and where nothing was committed. A missing file, a locked vault, an AEAD authentication failure and a --max-transfer stop are not repeated — the first three will answer the same way next time, and the fourth is the run being stopped on purpose.

There is no sleep between attempts, matching rclone, whose --retries-sleep defaults to zero. Retries are counted and shown in the end-of-run summary, so a run that succeeded only after fighting for it does not look identical to one that did not.

--low-level-retries N#

Refused (exit 7). Request-level retries now exist on every backend, but on a schedule this flag cannot reach: attempts, backoff, jitter and the total waiting budget are chosen per provider and are not a single number. Accepting an N that set one of the four and silently ignored the other three would be a dial that half works, which is worse than one that says no.

What the schedule is, since the flag will not let you change it: six attempts for sftp, b2, s3 and r2, starting at half a second and doubling to eight, honouring a server's Retry-After up to a minute, and never waiting more than two minutes in total for one operation; three attempts over one second for local:, where the errors worth repeating clear immediately or not at all. 5xx, 408 and 429 are temporary; 401 and 403 never are. A failure that is retried says how many attempts were made; one that is not says nothing about retrying, which is the fix for a hint that used to claim exhausted retries over a single attempt.

Whole-file retries are separate and are what --retries controls.

--timeout SECONDS#

Give up on a transfer that has moved no data at all for this long. Default 300 (five minutes); 0 waits forever.

It is an inactivity deadline, not a deadline on the operation, and the difference is the whole flag. --timeout 300 does not mean "fail after five minutes" — a 4 GiB restore over a domestic uplink takes hours and never comes close to it, because every frame that moves resets the clock. It means "fail after five minutes in which nothing moved". This is rclone's meaning of the same flag — an IO idle timeout — and the reason its default is generous: a deadline that fires on a transfer which is succeeding destroys work, where one that fires late only costs you the difference.

It bounds one attempt. What it costs a run whose link is GONE is a count and not a clock: six attempts in a row that get no answer end it, after which the run stops asking and exits 28 (link_silent), without walking the files it had left. It is not exit 5: 5 means retries exhausted, and here they were not — the run stopped early, because a link answering nothing cannot be persuaded by asking again. --retries does not repeat a file into it, for the same reason.

In time those six attempts cost at most 6 × --timeout when a transfer goes quiet — thirty minutes at the default, 180 seconds at --timeout 30 — plus the retry schedule's own backoff (15.5 s deterministic, jittered). An attempt that cannot reach the host at all is bounded by --contimeout instead, and on sftp: an abandoned dial costs that twice, once waiting for it and once while the ssh it started is reaped. Measured against a black-holed route with --timeout 30 --contimeout 30, five repeats each: b2: ended 195.9 s after the cut and sftp: 370.6 s — same flags, same fault, and the difference is the dial. Keep --contimeout well under --timeout, as the defaults do, and the run's cost is the one --timeout states.

The count is the run's and not each request's. It counts attempts that got no answer at all — not a status, not a protocol reply, not an errno — and any answer puts the whole count back to zero. Six in a row ends it.

That product is a number now, and it was not before. The sentence here used to read "the bound on the whole run is the product — roughly six times this number plus the backoff", which was wrong as arithmetic: the schedule ran once per distinct request, and one copy makes several — a 160 MiB upload runs it for b2_upload_part, again for b2_cancel_large_file and again for b2_list_buckets — and --retries multiplied all of it. The corrected sentence that replaced it said the product was not a number this flag could know, which was true and was the defect: measured against live B2 with the route black-holed, the same command against the same fault returned the shell at 46.3 s, 136.6 s and 288.7 s, depending on which request the cut landed on. An earlier build measured 943.6 s, and 601 s on sftp:.

Counting the run's unanswered attempts instead of each request's removes the factor. One request's own schedule is untouched — the limit is not smaller than the longest schedule any layer runs, and a compile-time rule holds that — so the first request to meet a dead link behaves exactly as it did before. It is the second request that is refused.

What this costs. A link that answers nothing for a whole schedule ends the run, where --retries would previously have repeated the file into the same silence. --retries keeps its meaning for every failure that has an answer, which is every other failure there is. --timeout 0 is the escape for an operator who really will wait as long as it takes.

It still does not bound a run that is healthy but slow, and nothing here can: a transfer that is moving resets this on every frame, which is the entire point of an inactivity deadline. For a backup window use --max-duration. An inactivity deadline that behaved like a stopwatch would kill every large transfer it is there to protect.

Where it reaches, and how closely:

BackendWhat is watchedGrain
b2, s3, r2the connection taking the next slice of a request body, and each chunk of a response arriving64 KiB out, one network chunk in
sftpeach protocol operation completingone operation — up to a 4 MiB read or write
local:not applied; see below

The grain is coarser than rclone's, which arms the deadline on the socket itself and re-arms it per read/write syscall. DCTL cannot: reqwest owns its connector and the connection type it hands to hyper is private, so the closest seam available is hyper asking for the next body frame — which it only does once the socket has taken the previous one. The practical difference appears only on a link so slow that a single frame or chunk takes longer than the whole deadline; at the default that is under 14 KiB/s on sftp, and the remedy is a larger --timeout.

local: applies neither this nor --contimeout, and that is a statement rather than an omission. There is no connection to establish, and the case that would want a deadline — a wedged network mount — blocks in uninterruptible sleep inside the kernel, where abandoning the wait does not stop the read. A timeout there would be a report, not a remedy. The errors a local filesystem does produce are covered by its own retry schedule.

--contimeout SECONDS#

Give up on reaching a host after this long. Default 60; 0 waits forever.

Separate from --timeout because the two bound different failures. Nothing is at risk while a connection is being established, so abandoning one costs a round of backoff and nothing else — which is why this is an order of magnitude more impatient than the deadline on a transfer already carrying data.

On b2, s3 and r2 it bounds the TCP connect and the TLS handshake. On sftp it is applied twice over: it becomes ssh -o ConnectTimeout, so the whole chain a ProxyCommand builds is bounded from the inside, and it bounds the dial itself from the outside. The second one is not belt-and-braces — ConnectTimeout covers the TCP connect and stops watching after it, so a route black-holed once the connection is up, a host that never offers the sftp subsystem, and a tunnel that authenticates and then goes quiet are all past its reach. That gap is what left a black-holed sftp: run alive 601 s after a 30 s deadline had fired: the session was discarded correctly, a replacement was dialled, and the replacement hung.

Like --timeout, it bounds one attempt. An attempt that could not reach the host got no answer either, so it counts towards the same run-level limit --timeout describes: six in a row and the run stops asking.

--max-transfer SIZE#

Stop the run once this much has been transferred (100G, 500GB, off). The budget flag: it is how you cap what a single run can cost on a metered provider, or stay under a daily cap. A run that stops for this reason is not a failure but it is not a completed sync either — it exits 8 (transfer_limit_exceeded) so a script can tell the difference.

The limit is never exceeded, not by a byte. A file is not started when moving it would take the run past the ceiling. This is rclone's cautious cutoff mode rather than its default hard one, and the choice follows from the durability contract: this engine writes an object in one call, and a partial object at the destination is exactly what verified writes exist to prevent.

The visible consequence, because somebody will meet it: --max-transfer 1M against a single 10 MiB file transfers nothing and exits 8. rclone would have moved 1 MiB of it and left that behind.

What counts against the budget is bytes measured leaving, including every attempt of a retried file — because every attempt used the link and is on the invoice. Everything already transferred is committed and verified, so re-running the same command continues from where it stopped.

--max-duration DURATION#

Stop the whole run after this long (30s, 90m, 4h, 7d, off; a bare number is seconds). The flag that bounds a backup window, and the only one that does. --timeout and --contimeout each bound one attempt; this bounds the invocation, from the moment it starts to the moment it must be over. A run that stops for this reason exits 10 (duration_limit_exceeded), so a scheduler can tell "my window ran out" from "the network broke" (exit 5) and from "some files failed" (exit 6).

It is a hard cutoff. When the window closes the request in flight is cancelled, the retry loop is not re-entered, no further file is started, and the process exits with the counters showing what really completed. That is rclone's default for the same flag — --cutoff-mode hard, implemented by giving the transfer context a deadline — and it is what "be finished by 06:00" means. It is deliberately not the cautious behaviour --max-transfer uses, because there is no honest way to predict how long a file will take, and a flag that only stopped between files would not stop a run whose last object is a terabyte.

Nothing is left half-written by the cut. A verified write commits only when the stored bytes match, so an abandoned object was never an object. What a cut transfer does leave is reclaimable debris — a staging file on local: and sftp:, an unfinished large file on B2 — and the message on the way out names dctl cleanup, which removes both. Re-running the same command continues from what landed, because the transfer verbs compare on size and modification time.

It is enforced at three depths, because no one of them can promise the wall clock on its own:

DepthWhat it stopsWhy the one below it is not enough
the requestthe read that will never returna request that never answers is never observed to be late by anything above it
the retry loopthe next attempt, and the backoff before ita cancelled request classifies as transient, and six attempts of that is the arithmetic above
the processeverything, including work no future ownsa blocking read, a spawn_blocking, an ssh child

Where the between-files check applies, and where only the bound does. The transfer family — copy, move, sync, copyto, moveto — declines to start a file once the window has closed, so the last line names the file it stopped at. restore, cat, replicate, scrub, verify and check do not pass through that pipeline and get no such line; they are still bounded, by the request-level and process-level enforcement above, and they still exit 10. That asymmetry is the same one --max-transfer has and it is named here for the same reason: it is a gap rather than a decision, and the honest fix is for those paths to share the pipeline rather than for six more call sites to grow their own check and drift.

The duration dialect is the one --min-age uses. rclone accepts a compound duration here (1h30m) and DCTL does not — write 90m. A value it cannot read is refused at the command line rather than silently leaving the run unbounded.


Filtering#

FlagShortValueDefaultRepeatable
--includePATTERNnoneyes
--excludePATTERNnoneyes
--include-fromPATHnoneyes
--exclude-fromPATHnoneyes
--filter-fRULEnoneyes
--filter-fromPATHnoneyes
--files-fromPATHnoneyes
--min-sizeSIZEnoneno
--max-sizeSIZEnoneno
--min-ageAGEnoneno
--max-ageAGEnoneno
--max-depthN-1 (unlimited)no

One engine answers for every command that takes these flags. The transfer family (copy, copyto, move, moveto, sync), the recovery family (backup, restore) and every listing verb evaluate them through crate::filter, so a rule means exactly the same thing wherever it is typed. Three implementations of one flag would eventually disagree, and the way they disagree is that a listing shows a file the transfer then omits — or, in a sync, that a rule reaching only one side turns an excluded destination file into an "extra" and deletes it.

A filter is applied to both sides of a diff. That is the property that makes sync --exclude 'archive/**' protect archive/ at the destination rather than empty it: the rule hides the tree on both sides, so it is neither transferred nor deleted.

What is refused is a filter that will not compile — a malformed pattern, an unreadable or unparseable rule file, a size without a unit, an age that does not parse, a --max-depth that is not a depth, or a pair of bounds that cross. Those are usage errors (exit 1) raised before anything is listed, because a run that proceeded with a rule the operator believes is in force is the data-loss case this whole group exists to prevent.

purge is the exception that neither honours nor refuses: it removes a whole tree by definition, so it warns that filters are being ignored and points at delete instead. replicate refuses every one of them by name.

Order: the rules are tried in rclone's order, and the first match wins#

This is the part most worth reading before writing a filter, because the obvious guess is wrong in both tools.

The rules are assembled by flag kind, not by position on the command line — matching the order rclone assembles them in:

  1. every --include, in the order given;
  2. every --include-from file, in file order;
  3. every --exclude;
  4. every --exclude-from file;
  5. every --filter rule and every --filter-from file, interleaved in flag order;
  6. an implicit - ** at the end if any inclusion was used, from any of those flags.

They are then evaluated top to bottom and the first rule that matches decides. So:

sh
--include '**' --exclude 'private/**'      keeps private/a.jpg

because the inclusion is tried first. That surprises people, it is rclone's behaviour, and rclone itself prints a warning recommending --filter when it sees both flags used together. Use --filter when the order matters:

sh
--filter '- private/**' --filter '+ **'    drops private/a.jpg

A --filter argument is one line of a rule file: + pattern, - pattern, or a lone ! that discards every rule accumulated so far. A rule file keeps its internal order exactly, and unlike --include it does not append an implicit - **: a rule file is an ordered program whose author writes their own final rule.

The asymmetry --include introduces. With no --include or --include-from anywhere, an unmatched file is kept. The moment one appears, the implicit - ** is appended and an unmatched file is dropped — so --include '*.jpg' --exclude '*.png' means "the JPEGs only", and the .txt files nobody mentioned are gone.

Three details of that rule are worth spelling out, because each one surprises somebody and all three are rclone's:

  • The flag arms it, not the rules. An --include-from file holding only comments still arms it; a + inside --filter or --filter-from does not. --filter '+ *.jpg' therefore keeps everything else, exactly as a rule file containing that one line does.
  • ! clears the rules and leaves the implicit exclusion standing. So --include '*.jpg' --filter '!' selects nothing: the inclusion is gone and the - ** it armed is not. If you want a clean slate, do not pass an inclusion flag in the first place.
  • It is appended last, after every rule from every flag, so any rule can beat it.

Anchoring and the glob dialect#

Anchoring follows rclone's rules, because rclone's patterns are the ones users bring:

  • A pattern beginning with / is anchored at the listing root: /tmp/* matches tmp/a but never photos/tmp/a.
  • A pattern with no / at all matches the file name at any depth: *.jpg means what everyone assumes it means.
  • Anything else matches the root-relative path and every component-aligned suffix of it, so tmp/* finds photos/tmp/a as well as tmp/a.
  • A trailing / makes a rule match directories only. --exclude 'cache/' skips the tree; to name the contents by pattern, write cache/**.

Glob dialect: * within one path component, ** across them, ? for a single character, [a-z] for a class and [!a-z] or [^a-z] for a negated one, {a,b} for an alternation (which nests), \ to escape any of them. A malformed pattern is a usage error (exit 1) naming the flag it came from and the position.

Two differences from rclone worth knowing if you are bringing patterns across:

  • rclone hands the inside of […] to a full regexp engine, so it accepts [[:alnum:]], [\d], [\s] and [\w]. DCTL refuses those by name and tells you the spelling to use ([0-9a-zA-Z]) rather than reading them as a set of literal characters, which is what silently selecting the wrong files would look like.
  • In rclone a class negates only with ^, so [!a-z] there matches ! or a letter. Here ! negates, as it does in a shell and in rsync.

Sizes and ages#

Size syntax. A unit is required for --min-size and --max-size: 100B is a hundred bytes and 100K is a hundred kibibytes, and a bare 100 is a usage error naming both readings. That refusal exists because rclone reads a bare number as kibibytes while every size DCTL prints is in bytes — a factor of 1024 on the flag that decides which files move, and on a sync the files in between are not merely absent from the copy but candidates for deletion at the destination. off needs no unit and removes the limit.

An IEC spelling is binary (10G = 10Gi = 10GiB = 2³⁰ × 10) and an explicit SI spelling is decimal (10GB = 10⁹ × 10), because someone writing --max-size 5TB copied it off a provider's invoice and means the invoice's terabyte. The same syntax, without the unit requirement, applies to --bwlimit and --max-transfer.

Age syntax. ms, s, m, h, d, w, M (30 days), y (365 days), or a bare number of seconds — rclone's table, including that M is a month and m is a minute. off removes the limit.

Both pairs are inclusive at the boundary and both are refused if they cross (--min-size 10G --max-size 1G can never match, so it is a usage error rather than a run that reports success having moved nothing).

--include PATTERN / --exclude PATTERN#

Include only paths matching this glob, or drop paths matching it; repeat either flag to accept several patterns. See the ordering section above before mixing them — the two are not tried in the order you wrote them.

--include-from PATH / --exclude-from PATH#

The same, read from a file, one bare pattern per line — the flag supplies the +/-. Blank lines and lines starting # or ; are skipped. Use these when the list is longer than a command line or wants version-controlling.

--filter RULE / --filter-from PATH#

+ pattern, - pattern or !, one rule per argument or per line. The only form whose order is the order you wrote, which is why it is the one to reach for when an exclusion has to beat an inclusion. A file that cannot be read or parsed is a usage error rather than a run with the rules dropped, because a transfer whose filter file was silently ignored looks complete.

--files-from PATH#

Transfer only the paths named in this file, one per line, skipping the directory walk entirely — the right tool when an upstream process already knows exactly which files changed. Repeatable; several lists are merged into one set.

It is more than a convenience in backup and restore, where it is the way to give a restore drill an exact path set. Blank lines and # comments are skipped, and every surviving line is canonicalised the same way an index key is (/-separated, NFC), so a list written on Windows with backslashes selects the same objects as one written on Linux. A line containing .. is a usage error (exit 1) naming the file and line number, rather than a path quietly resolved outside the transfer root.

Unlike rclone, which refuses --files-from combined with any other filter, DCTL applies the intersection: the list narrows what a walk would have found and the other rules narrow it further. That can only ever select a subset of the list, which is recoverable by re-running.

A listing verb applies the list as an exact filter rather than as a way to skip a walk: an index range scan and a provider listing are already flat, so there is no directory recursion to be skipped. The set of objects shown is the same set a transfer would take, which is the property that matters.

--min-size SIZE / --max-size SIZE#

Skip files smaller, or larger, than this. Applied to objects only, never to directories: a directory's size is an aggregate, and filtering directories on it would hide every small file inside a large one. A file whose size was never measured — a row written by dctl index rebuild, which is a list-only pass — is not filtered, because both ways of guessing at the size are wrong in a direction the operator cannot see.

--min-age AGE / --max-age AGE#

--min-age 7d keeps files at least seven days old; --max-age 7d keeps files modified within the last seven days. The window is fixed once when the run starts, so a transfer that takes an hour selects the same set at the end as at the beginning — and a sync sees the same window on both sides.

A file whose modification time nobody recorded is kept, not dropped. This is a deliberate difference from rclone, which treats an absent time as older than every floor: a vault index rebuilt from object headers records no times at all, so rclone's rule would hide the entire vault from --max-age, including objects that plainly qualify. Directories carry no time either — a directory's timestamp moves when a child is added, which says nothing about the age of what is inside.

--max-depth N#

Limit recursion depth; -1 (the default) means unlimited, 1 means the immediate children of the target. Depth is counted from whatever the command was pointed at, not from the vault root. The directory-oriented commands (lsd, tree) apply the limit to the directories they synthesise rather than to the objects they derive them from, so dctl lsd --max-depth 1 reports top-level directories properly instead of reporting them all as empty.


Traversal#

FlagShortValueDefaultRepeatable
--linksskip | follow | in-treeskipno

Its own group, and deliberately not part of Filtering. A filter selects among the things a walk found; this decides what the walk finds at all. dctl replicate refuses every filtering flag — a filtered replica of a vault's object store is a store with dangling references — and honours this one, because a store on local: or sftp: is walked by the same code as any other tree.

What a walk does with the symbolic links it finds inside a tree. The root a command is pointed at is a different question and is always resolved: dctl ls /srv/data and dctl backup /var/log/current vault: name a path a person chose, and refusing to look through it produced an empty listing that sync --force read as permission to delete a destination.

ValueWhat happens
skip (default)Nothing behind a link is read. Every link is counted, and -v names each one with the reason.
followA link is followed wherever it points, including out of the tree — which is the canonical layout, /srv/data -> /mnt/bigdisk/data.
in-treeA link is followed only while its target stays under the walk root. One that would leave is reported and not followed.

The default skips, and says so. Following by default would change what every existing backup contains on its next run, and sync deletes on the difference — so a machine whose links happened not to resolve one night would lose those files at the destination. It would also let one link named etc -> /etc pull a machine's whole configuration into an archive the operator believes holds photographs, past every --exclude they wrote. rclone settled the same way: its local backend ignores symlinks unless -L/--copy-links is given, and logs one line per link it passes over.

What the default may not be is quiet, and that is the change. Every run prints skipped N symbolic link(s) on stderr, with the flag that stores them; -v names each link and what happened to it. A tree with no links prints nothing.

It cannot loop. When links are followed, the walk remembers the identity of every directory on the path from the root down to the one it is reading — (st_dev, st_ino) on Unix, the canonical path over SFTP, which carries no inode. A link whose target is already on that path is reported as a cycle and not followed. It is the ancestors and not "everywhere the walk has been", because two links to one directory are two legitimate names for it: a global set would walk the first and silently drop the second, which is the same class of loss.

A broken link is counted and named, and it fails the run. Under a policy that follows, a link with nothing behind it is a path that was asked for and not stored, so it raises the error count and the exit code (6, partial_failure) while the rest of the tree still transfers. Under skip nothing looks behind a link, so nothing can call one broken.

A fifo, socket or device node has no flag and is reported anyway. No setting would store one — there are no bytes behind any of them — so there is nothing to decide, only something to disclose. Every walk prints skipped N special file(s) on stderr and names each one at -v with what it is (pipe: a named pipe, run/docker.sock: a unix socket, dev/sda: a block device). It is a warning and never an error, which is also where rclone settled: it declines to store the entry and logs that a non file/directory cannot be transferred, without raising an error count, so a /var full of sockets does not fail a nightly run. A tree with none prints nothing.

On restore, a followed link comes back as a copy. A vault is keyed by logical path and has no record type for "this path is a link to that one", so srv/data -> /mnt/bigdisk/data backed up with --links follow restores as a real directory holding real files. Two links to one 400 GB directory restore as 800 GB. A link skipped by the default restores as nothing at all, because nothing about it was ever stored. tests/restore_drill/links.rs proves both halves.

dctl backup --follow-symlinks is the older spelling of --links follow and still works. Giving both is accepted when they agree and refused (exit 1) when they do not, rather than one silently winning.


Output#

FlagShortValueDefaultEnvironment
--formattext | json | json-linestext
--jsonoff
--unitsbinary | decimalbinary
--colorauto | always | neverauto
--asciioff
--progress-Poff
--statsSECONDS60
--stats-one-lineoff
--quiet-qoff

stdout carries data; stderr carries everything else. Progress, logs, warnings, prompts and the end-of-run summary all go to stderr, which is what lets dctl cat vault:film.mkv | ffplay - keep its progress bars while stdout stays byte-exact.

--format FORMAT#

How structured results are serialised. text is aligned columns for a person; json is one document for the whole result; json-lines is one JSON object per line, which streams without buffering and is therefore the only sane choice on a ten-million-object listing. Nothing about the work changes between them — only the rendering — and machine formats suppress the human extras (summary, separators, notes) that a parser would choke on.

--json#

Shorthand for --format json. Conflicts with --format: passing both is a usage error rather than a silent precedence rule, since the two would otherwise disagree about the same setting.

--units UNITS#

The byte-size convention used in every rendered size and rate. binary gives KiB/MiB/GiB (powers of 1024), which is what the operating system reports; decimal gives kB/MB/GB (powers of 1000), which is what providers bill in. Switch to decimal when you are reconciling a DCTL report against an invoice or a quota page. This affects output only — it never changes how a --max-size argument is parsed.

--color WHEN#

Whether to emit ANSI styling. auto (the default) colours a terminal and stays plain through a pipe, honouring NO_COLOR (disables), CLICOLOR_FORCE (forces), and TERM=dumb (disables) along the way. always is for a CI system that renders colour but does not look like a terminal to isatty. JSON output is never coloured whatever this says, since escape sequences inside string values break every downstream parser.

It reaches both streams and every command that renders anything: ls, lsl, lsd, tree, size, check, about, the warnings and errors on stderr, and the end-of-run summary. Sizes and counts are one colour, paths another, directories bold, timestamps and structural chrome dimmed. Styling never changes the layout — columns are measured before they are painted, so --color always and --color never produce the same visible text.

--ascii#

Draw bars, spinners, tree branches and status marks from ASCII instead of Unicode box-drawing and braille glyphs. DCTL already picks ASCII automatically on a legacy Windows console and in a non-UTF-8 locale; this flag forces it for the cases the detection cannot see, such as a log viewer or a terminal emulator that renders the Unicode set as mojibake.

-P, --progress#

Watch this run. DCTL shows progress by default in every environment it can — bars when stderr is a terminal, periodic status records when it is redirected — so this flag does not switch progress on. It changes the two things a person standing over a run wants changed:

  • The cadence. A redirected run reports every --stats seconds, a minute by default: right for an unattended nightly job, useless to somebody watching. -P selects one second.
  • Machine output stops silencing it. --json turns the display off by default, because a program is reading stdout. That is a courtesy rather than a constraint — progress is written to stderr and cannot reach the JSON — so -P brings it back, and stdout still carries exactly one JSON document.

--stats 0 beats it: that is a direct instruction about this exact output. --quiet beats everything.

It does not conjure bars through a pipe, and an earlier release's promise that it would was worse than useless — bars draw through a terminal handle, so forcing them off a terminal rendered nothing and stopped the periodic record, making -P the only way to make a redirected run quieter. Off a terminal, "progress" means the periodic record, and this flag makes it frequent.

One limit, stated plainly: progress is per file, not per byte. A single very large file's bar moves once, at the end, because the storage layer takes a whole buffer and returns a count. A tree of files behaves as expected.

--stats SECONDS#

How often the periodic status record is emitted when bars are unavailable — that is, whenever output is redirected. 0 disables it entirely. Sixty seconds is a readable cadence for a long transfer in a log file; shorten it if you are watching a CI job and want more evidence of life, or pass -P, which shortens it to one second for you.

The record is the same report the run prints at the end, taken mid-run: the same rows, in the same order, in the same units. A watcher reading a log at 3 a.m. should not have to learn a second format to find out how many errors there have been.

--stats-one-line#

Condense each periodic status record onto a single line — percentage, bytes, rate, ETA, files and errors — which is what makes the output greppable and keeps a long-running job from dominating a log.

This used to be indistinguishable from its absence: the periodic record only ever had the condensed shape, so asking for one line was asking for what you already had. The block is now the default and this selects the condensed form, which is also rclone's arrangement.

-q, --quiet#

Suppress all non-error output: no progress, no summary, no commentary. It also pins the log level to error, so a quiet run stays quiet on both sinks, and it beats --progress — a user who asked for silence gets it even if they also asked for bars. Errors are never suppressed; a silent failure is the one outcome PLAN.md §7 forbids outright.


Logging & debugging#

FlagShortValueDefaultEnvironment
--verbose-vrepeatableoff (warn)
--log-levelerror | warn | info | debug | tracewarnDCTL_LOG_LEVEL
--log-formathuman | json | plainhumanDCTL_LOG_FORMAT
--log-filePATHnone
--dumpheaders | bodies | requests | retries | filters | confignone
--log-sourceoff

The effective level is resolved once: an explicit --log-level wins, then --quiet (which forces error), then the -v count, otherwise warn. DCTL_LOG overrides the whole filter — see The DCTL_LOG filter override.

Redaction is not optional. Keys and tokens are never logged at any level or under any --dump target; secrets appear only as BLAKE3 fingerprints.

-v, --verbose#

Increase verbosity by repetition: -v is info (one record per file), -vv is debug (per-stage detail and every retry decision), -vvv is trace (per-chunk activity). -vvv is genuinely extreme — a 50 GB transfer at 4 MiB chunks emits roughly 12,800 records — so prefer DCTL_LOG to raise one module to trace rather than the whole program. -v is also what turns on the explanatory commentary the integrity commands print about what they actually checked.

--log-level LEVEL#

Set the level explicitly, overriding any -v count. The named form is the one to use in a script or a systemd unit, where -vv is a puzzle and --log-level debug is not. DCTL_LOG_LEVEL sets the same thing for a whole shell or container.

--log-format FORMAT#

How records are rendered. human is aligned and colourised for a person at a terminal; json is newline-delimited objects with structured fields preserved, for ingestion by a log pipeline; plain is the human layout with no ANSI, for a CI transcript where escape sequences are noise. A log file never receives ANSI regardless — --log-format human --log-file x.log writes plain to the file and keeps the colour on the console.

--log-file PATH#

Append records to this file in addition to stderr, creating parent directories as needed. Existing content is never truncated, because the point of the flag is an audit trail. Failing to open the file is fatal rather than a warning: continuing without the trail you explicitly asked for would be a silent downgrade of what you were promised.

--dump TARGET#

Refused (exit 7). The protocol tracing layer these targets select from is not installed — nothing in the storage layer or the logging setup captures headers, bodies, requests or retry decisions — so every one of them would produce silence. Raise -vvv for the tracing this build does emit.

The targets remain in --help and are validated, so the vocabulary does not change when the layer lands: headers (HTTP headers, Authorization always redacted), bodies (request and response bodies, never plaintext file content), requests (one line per request: method, URL, status, duration), retries (every retry decision with its classification), filters (which rule included or excluded each path), config (the resolved configuration with secrets redacted).

--log-source#

Include the source file and line in every log record. This is for reporting a bug against DCTL itself — it turns "something in the transfer path warned" into a line number — and is noise for everything else.


Safety#

FlagShortDefault
--dry-run-noff
--interactive-ioff
--forceoff

-n, --dry-run#

Report what would happen and change nothing. Every destructive decision declines under it and prints the action it skipped, so the output is a plan rather than a result. Note what it is not: a dry run of dctl verify proves nothing about your data, because the check it would have performed never ran.

-i, --interactive#

Prompt before each destructive action; you must type yes exactly. Confirmation is opt-in rather than the default, so an unattended job does not stall on a question nobody can see — which also means that without this flag, destructive commands proceed on their own authority once their own guards are satisfied. With --interactive and no terminal to ask on, the command fails (exit 1) rather than assuming an answer. Conflicts with --force.

--force#

Approve destructive actions without asking. Beyond skipping --interactive prompts, it is required by the operations that refuse on their own regardless of this group: dctl init over an existing index, dctl config create over an existing remote, and a sync whose source is empty and which would therefore delete everything at the destination. --dry-run still wins — --force --dry-run changes nothing.

--force is not an override for the addressing rule below. It approves work you are allowed to do; it does not grant permission DCTL does not have.

What no flag in this document does#

No flag on this page changes what a command encrypts. Not --force, not --verify, not --remote, not any combination of them. Encryption is decided by the remote name typed — see the addressing invariants — and the matrix in crates/dctl-cli/tests/invariant_i4/ crosses every flag here with every write verb and asserts it on the bytes left on disk.

That extends to --dry-run, and deliberately so: a rehearsal reaches the same addressing decision as the real run, so a plan you approved is a plan the run will actually perform. A dry run that printed "would copy" for a destination the real run refuses would be worse than no dry run, because it is trusted.

The one behaviour that is not decided by the name typed is a refusal. For a bare path that no configured remote describes, DCTL inspects the destination for a vault envelope and fails closed if it finds one — it can stop a command, and that is all it can ever do. The reasoning, and the honest limits of it, are on the root command page.


Environment variables#

Every DCTL_* name is derived from one prefix, so the whole set renames together if the product ever does.

Flag equivalents#

VariableEquivalent toNotes
DCTL_CONFIG--configEmpty value is treated as unset.
DCTL_REMOTE--remoteDefault target when a command is given no path.
DCTL_INDEX--index
DCTL_PASSWORD--passwordNever echoed in help output.
DCTL_PASSWORD_COMMAND--password-command
DCTL_LOG_LEVEL--log-level
DCTL_LOG_FORMAT--log-format

The command-line flag always wins over the variable.

Provider credentials#

Credentials live in the environment (and, later, the OS keychain), never in config.toml — rclone's reversibly-obscured secrets are the specific mistake PLAN.md §14 is avoiding. A missing, empty, or non-UTF-8 credential variable is a fatal error (exit 7), not a temporary one: no amount of retrying invents a credential, and reporting it as transient would have a scheduled job back off for an hour instead of failing loudly. An exported-but-empty variable counts as missing, because sending an empty key to a provider produces an opaque 403 instead.

VariableProviderConfig alternative
DCTL_B2_KEY_IDB2— (secret)
DCTL_B2_APP_KEYB2— (secret)
DCTL_S3_ENDPOINTS3endpoint in the remote's section
DCTL_S3_REGIONS3region in the remote's section
DCTL_S3_ACCESS_KEYS3— (secret)
DCTL_S3_SECRET_KEYS3— (secret)
DCTL_R2_ACCOUNT_IDR2account in the remote's section
DCTL_R2_ACCESS_KEYR2— (secret)
DCTL_R2_SECRET_KEYR2— (secret)

The non-secret settings are read from the config when a named remote pins them and from the environment otherwise, which is what lets a bare s3:bucket work with no configuration at all.

The DCTL_LOG filter override#

DCTL_LOG replaces the computed log filter entirely with a tracing-subscriber EnvFilter directive. It is the targeted-debugging escape hatch: it lets you turn one module up to trace without drowning in everything else, which -vvv cannot do.

sh
DCTL_LOG=dctl_store::b2=trace        dctl copy ./photos vault:photos
DCTL_LOG=warn,dctl_cli::commands=debug  dctl sync ./src vault:src

Because it replaces the filter rather than adjusting it, it overrides --log-level, -v and the level --quiet would have pinned. It does not override --quiet on the output side: the progress display, the summary and the stderr commentary stay suppressed, since those are a different sink from the log. A directive that fails to parse is ignored and the computed filter is used instead, so a typo degrades to normal logging rather than to no logging.

There is no flag equivalent; this variable exists only in the environment.

Environment DCTL honours but does not own#

VariableEffect
NO_COLORSet to anything: disables colour under --color auto.
CLICOLOR_FORCESet and non-zero: forces colour under --color auto.
TERM=dumbDisables colour under --color auto.
WT_SESSIONMarks a modern Windows Terminal, which gets the Unicode glyph set.
LC_ALL, LC_CTYPE, LANGConsulted in that order for a UTF-8 signal; without one, glyphs fall back to ASCII.
VISUAL, EDITORThe editor dctl config edit launches; VISUAL wins.

Flags that are refused#

These are accepted by the parser, shown by --help, and then fail the run with exit 7 before anything is read, written or unlocked. The message names the flag, what you were doing, why this build cannot do it, and what it does instead. Nothing here is silently ignored.

  • --key-file — the key-encryption key is derived from the password alone; there is no parameter through which a second factor could be mixed in. See --key-file PATH.
  • --low-level-retries — request-level retries exist on every backend, but on a per-provider schedule of four numbers that one N cannot set.
  • --dump — the protocol tracing layer these select from is not installed, so every target would produce silence. Raise -vvv for the tracing this build does emit.

Two more are refused only for the values this build cannot deliver:

  • --transfers N and --checkers N accept 1, which is a true statement about a sequential executor, and refuse anything larger.

The filtering flags are deliberately not on this list, because none of them is ever silently ignored: the transfer and recovery families evaluate all of them through one engine, and a rule that will not compile is a usage error rather than a run with the rule dropped. See Filtering.

The one place a filter is neither honoured nor refused is purge, which removes a whole tree by definition and warns that it is ignoring them.


See also#

  • docs/commands/ — per-command pages, each listing the global flags that change that command's behaviour.
  • docs/EXIT_CODES.md — the exit-code contract.
  • PLAN.md §6 (verified-write durability), §7 (logging and audit), §14 (configuration and secrets).