All articles
Keeping Names, Permissions and Bytes Aligned in My Private Cloud
21 March 2026Homelab, Storage, Backups, Self-hosting

Keeping Names, Permissions and Bytes Aligned in My Private Cloud

From ext4 journal recovery to mount identity, file-object relationships and application invariants in a self-hosted cloud.

After an interruption involving external storage, I reconnected and remounted an ext4 filesystem. It performed journal recovery and mounted successfully. The filtered checks I used at the time did not show the I/O errors I was looking for.

That was a useful recovery observation. It also left a more demanding question: what would establish that my file platform could return the intended content to the intended user from the intended storage?

I had combined OpenMediaVault, Cloudreve, PostgreSQL, Redis and Docker, with Cloudflare Tunnel for browser access and WireGuard for private connectivity. Operating those components brought several different meanings of “working” into the same system. A filesystem could mount, a connector could establish a session, and a web application could display a file record. Each observation covered part of the operation I actually wanted.

The deployment events here come from my project account. The mount scenario, object graph and quota calculation are illustrative analyses. Current Cloudreve v4 and kernel documentation explain mechanisms without identifying the historical versions or mount options of my installation.

What the filesystem recovered

Ext4's journal helps the filesystem avoid ending a crash halfway through a metadata transition. JBD2 records transaction contents and a commit record; checkpointing subsequently places journaled changes in their final locations. Recovery can replay committed work interrupted during that later stage. A transaction without a valid commit, or with failed applicable journal checksums, is not replayed as a completed transaction. The kernel's journal description explains that sequence.

Consider a simplified metadata update that allocates blocks and changes an inode. If its journal transaction is committed but checkpointing is interrupted, replay can complete the recorded metadata transition. This makes the filesystem's structures consistent at that boundary. It does not compare the file's contents with a trusted version from before the interruption.

The distinction is especially important because ext4 normally journals metadata rather than every ordinary file-data block. In ordered mode, associated data is written before committing the related metadata. That ordering still does not make arbitrary application updates one durable transaction. Delayed allocation, application synchronization, errors and the storage stack affect the result. My incident's actual mount options are not recorded. Ext4's data-mode documentation supports the conditional explanation, rather than a reconstruction of my server.

Checksums also need a scope. Journal checksums validate journal records; ext4's metadata checksums cover particular filesystem structures. Neither is a trusted historical digest of every application file. A successful mount and absence of matching messages in a filtered log therefore cannot establish that every file survived unchanged.

Application synchronization has its own contract. fsync() addresses changed file data and associated metadata, while persistence of a new directory entry can require synchronizing its directory too. Return errors matter. The system-call manual describes those obligations; it does not establish which calls my application made or what my storage device acknowledged.

The practical consequence is to keep two checks separate: can the filesystem present its structures, and can the application retrieve the expected bytes? Journal recovery helped with the first. The mount and filtered-log observations do not establish byte-for-byte correctness across the installation.

A directory can exist on the wrong disk

Storage identity introduces a failure class that a path-existence check can miss.

A small NAS with its drive bays openA path on the host only means the intended disk if the right filesystem is mounted there. Photo: Korrupt / Wikimedia Commons, CC BY 4.0

Suppose an external filesystem is intended to mount at /srv/archive, and a new container binds that location to /data. If the external mount fails at startup, /srv/archive may still exist as an ordinary directory on the system filesystem. If the container starts against that directory and has permission to write, its data can go to the system filesystem instead of the external disk.

The fresh-start assumption matters. Unplugging a device underneath an already-running process can instead involve existing mount references and I/O failures. Those cases require different diagnosis.

Docker binds a source path; it does not infer which disk the administrator intended. Compose's bind.create_host_path: false prevents creating an absent source directory. It cannot reject a wrong backing filesystem merely because the directory already exists. Compose's bind options define that narrower control.

A proposed read-only investigation could begin with:

findmnt --mountpoint /srv/archive --output TARGET,SOURCE,FSTYPE,UUID,OPTIONS
findmnt --target /srv/archive/uploads --output TARGET,SOURCE,FSTYPE,UUID,OPTIONS

The first query asks about the exact mountpoint. The second finds the mount containing a path, which may simply be the parent system filesystem. Successful output is not an acceptance condition. The findmnt manual describes both queries and process-specific inspection.

Illustrative observationWhat it means for a fresh start
Expected external source, target, filesystem identity and required options agreeStartup identity precondition is satisfied; content correctness still needs its own evidence
The containing filesystem is the system volumeRefuse application writes to this unintended destination
Another mounted filesystem occupies the expected pathA mount exists, but it is not the intended storage
Host and application mount views differInspect the relevant process namespace before deciding which storage the application sees

UUIDs help avoid unstable device names, but cloned filesystems can duplicate them. They are operational identifiers, not cryptographic proof of trusted content. The mount interface also explains how mounting hides a directory's underlying contents. A later successful external mount can conceal files previously written underneath it.

Nor should I assume that a later host mount repairs an existing container's view. Docker bind propagation defaults to private behavior, and processes can have different mount namespaces. The bind-mount documentation makes that propagation choice explicit.

To inspect the application's view, an illustrative lookup is:

findmnt --task 12345 --mountpoint /data --nocanonicalize --output TARGET,SOURCE,FSTYPE,UUID,OPTIONS

Here 12345 must be replaced by the relevant application process ID as visible from the inspection host, and /data is the mountpoint in that process's view. The query reads that process's mount information rather than the caller's. Access permissions and available output columns depend on the environment. It does not enter the application's root or prove which object a particular request read.

An identity gate is consequently a proposed startup safeguard, not complete runtime protection. A check can pass and a mount can subsequently disappear. A proposed writer-admission boundary would reject new mutations when the required storage precondition fails and propagate storage errors from already-admitted work instead of reporting success. Monitoring can signal that transition; an alert alone does not enforce refusal. Races between a check and subsequent I/O still need explicit lifecycle handling.

The application file is a relationship

Cloudreve adds another layer above the filesystem. In its current v4 model, SQL records describe files, blobs and metadata, while storage providers hold physical content. Application copies can share immutable blobs; updates create new blob relationships. The documented concepts explain why an application file is more than a familiar pathname.

A small logical graph makes the consequence visible:

File A and File B both reference blob X in the database, which points to object X in storageTwo files can share one blob, and the blob is only useful while its physical object exists. Diagram: Daniel Buttigieg

If an independent writer overwrites or removes the physical object, both application relationships can be affected. A second visible filename has not necessarily created an independent content copy.

That matters when considering local network shares. I use SMB as well as Cloudreve, but the account does not establish that they expose the same physical files. If an independently writable SMB share did expose application-managed blob storage, it would introduce a mutation path outside the application's update rules. Keeping such a path read-only or separate would be a deliberate authority decision, not merely a directory-naming preference.

Recovery must preserve the relationship in the graph. Provider bytes without the SQL records do not reconstruct the application namespace or sharing associations. SQL records without the referenced content cannot supply the file. Effective configuration must also identify and interpret the provider correctly. Cloudreve's configuration model includes file-held settings, database-held application settings and possible environment overrides.

Possessing both backups is insufficient if they describe incompatible moments. Consider this illustrative capture history, using the current model's eventual collection of unused blobs:

TimeLive application stateBackup action
1A file references blob XCapture an internally consistent database snapshot containing that reference
2Updates or deletion remove the last reference to X, including any retained versionsNo provider copy yet
3Background collection removes X's physical objectCapture provider contents afterward, without X
RestoreThe selected database still references XThe selected provider copy cannot supply it

The live application need never have served a dangling reference. The incompatible recovery set was assembled from different moments.

A proposed capture contract would pause relevant application mutations, drain in-flight work and suspend collection or other writers that could alter the selected content. While that boundary holds, take a valid database backup, preserve the required provider objects and effective configuration, and record the set's identity. This trades a mutation outage for a simpler consistency argument. An equivalent retention or snapshot procedure needs its own justification if writers continue.

PostgreSQL's SQL-dump contract supplies an internally consistent database snapshot. It does not coordinate external objects or include every cluster-wide role needed for restoration. Pausing Cloudreve is also not permission to copy arbitrary live PostgreSQL data files. The necessary property is that every blob referenced by the chosen recovery state remains available with sufficient configuration and permissions to interpret it.

Draining work also requires classifying supporting state. Redis is optional in current v4, whose fallback memory cache is saved on normal exit. A tagged 4.18.0 callback handler rejects missing or expired pending-session state from KV. That gives a concrete reason to account for pending work during capture rather than label every cache disposable. Deployment guidance describes these current alternatives.

Permission depends on the access path

The same application object can be reached under different authority rules. Current v4 permission semantics distinguish inherited permissions inside shared directories from the permissions on a sharing link. For a single-file share, the link's settings govern that path instead of the file's own permission settings.

Consider a disposable private file F owned by non-administrator user A. User B is another authenticated, non-administrator application user. The intended test policy is:

Request pathIntended result and deciding rule
A reads its private FAllow under the owner's application authority
B requests private F without an applicable grantDeny at application object authorization
B follows a deliberately created, valid single-file link granting download of FAllow under that link's permissions and conditions

The last outcome is intentional delegated access, not evidence of a bypass. If the requirement is that only A can download F, a still-valid download share would contradict that requirement even when B's direct private-object request is denied. Testing one URL cannot establish the policy across both access paths.

To attribute a denial to object authorization, B should first reach the application, authenticate successfully and read its own control file. Then correlate B's denied F request with application evidence. A rejection by the tunnel gate or a failed login would test an earlier boundary instead.

An admitted request still has invariants to preserve

The public route taught me a connectivity lesson. Cloudreve worked locally while the connected tunnel could not reach it publicly. I placed a dedicated connector on the application's Docker network, then checked internal and public responses separately. That repaired the reported route; it did not establish every property of a file operation.

Cloudflare Tunnel provides outbound connector connectivity. The service URL selects the connector-to-origin HTTP or HTTPS hop; connector encryption does not transform a configured HTTP origin into HTTPS. Origin certificate identity and HTTP Host have distinct roles. Cloudflare's origin-route explanation describes those boundaries. My account does not establish an origin TLS configuration or end-to-end file encryption.

Cloudflare Access is a separately configured web gate. WireGuard associates authenticated peers with permitted packet source addresses and selects outgoing peers by destination. Its cryptokey-routing model does not grant a peer permission to a particular Cloudreve file. Reachability, web admission, object authorization and storage resolution remain separate decisions.

There is a further boundary inside an authorized operation: concurrent requests must preserve application invariants.

A maintainer-published Cloudreve quota-race advisory describes authenticated upload admissions checking stale capacity before separate accounting updates. It lists affected versions through 4.17.0 and 4.18.0 as patched. This is a documented counterexample, not evidence that my unidentified release was vulnerable, and I did not reproduce it.

Consider a simpler illustrative interleaving:

StepRequest ARequest BAccounted usage
Initial state90 GiB, with a 100 GiB limit
Independent checks90 + 8 = 98, accepted90 + 8 = 98, acceptedBoth used the same old value
Both reservations appliedAdds 8 GiBAdds 8 GiB106 GiB, exceeding the limit by 6 GiB

Every requester may be authenticated and allowed to upload. The failure is in combining decisions about shared state. Making each increment atomic does not make a separate earlier check part of that atomic operation.

If excess reservations are completed into stored content, the consequence can extend beyond one user's quota to exhaustion of shared storage. That threatens other users' ability to upload or maintain the state needed to retrieve their files. This availability consequence connects admission correctness to the same persistent file service.

A concrete relational illustration makes the admission boundary clearer. Assume one pre-existing quota row per account, validated nonnegative integer demands within the schema's numeric range, and an accounted total that includes pending reservations. Named parameters below denote bound values, not SQL interpolation or Cloudreve's actual schema:

UPDATE account_quota
SET accounted_bytes = accounted_bytes + :bytes
WHERE account_id = :trusted_account
  AND :bytes >= 0
  AND :bytes <= quota_bytes - accounted_bytes;

In PostgreSQL's Read Committed behavior, a contending update waits and re-evaluates its condition against the updated target row. After A reserves 8 GiB, B now sees 98 GiB and its additional 8 GiB fails the condition. The caller must require exactly one affected row before admitting work; zero rows is rejection.

Every relevant reservation writer must participate in this boundary. Creating the corresponding upload-session record belongs in the same transaction so failure does not leave a partial reservation. Cancellation and retry identity need separate handling to avoid leaking or duplicating capacity. This demonstrates one admission mechanism, not an audit of the advisory's patch or a complete upload implementation.

Verify the operation at its endpoints

Saltzer, Reed and Clark's End-to-End Arguments in System Design examines careful file transfer through a failure model that includes storage, software, memory, transport and crashes. Its endpoint check-and-retry argument explains why reliable communication alone cannot establish the application's complete result. It also recognizes the efficiency value of lower-layer checks.

For my next validation, I would use the disposable file F with a trusted expected digest. Fetch it through the intended route as A, then exercise B's positive control, private-object denial and deliberate share-link case under the policy above. That would establish those tested cases at that time, not every permission combination or the future durability of the disk.

Storage provenance needs additional evidence because two different copies can have identical bytes. The record should connect the tested application object to its actual blob and provider reference, then to the serving process's relevant mount view for local storage. The preferred policy for future blobs is not a substitute for the existing blob's provider. Correlated retrieval evidence must also distinguish a provider read from a cached response before attributing that request to a particular storage path.

An illustrative local-serving record could join request R, file F, blob X, provider P and object key K with its resolved local path, the serving process and an observed read under expected mount U. Obtaining that chain requires suitable application or provider instrumentation; a standard log is not assumed to expose every field. If the read cannot be correlated, content may pass while storage attribution remains inconclusive.

A separate isolated startup test would withhold or replace the expected storage mount and require the application to refuse writes to an unintended destination. Recovery testing would recreate the compatibly captured SQL, content and effective configuration in an isolated environment, with background writers and writable destinations controlled. Neither test belongs on production storage simply to obtain an article result.

The ext4 remount gave me a real, bounded recovery observation. The next step is to retain evidence for the application operation built above it: which object was requested, who could access it, which storage supplied it, and whether its bytes matched the expected content. That is the unit of success I would use for this private cloud.


Image credits