Skip to content

Storage Adapters

Storage adapters handle file operations: upload, download, list, and delete.

Available Adapters

AdapterIDDescription
Locallocal-filesystemLocal filesystem
S3 Generics3-genericAny S3-compatible storage
AWS S3s3-awsAmazon S3
Cloudflare R2s3-r2Cloudflare R2
Hetzners3-hetznerHetzner Object Storage
SFTPsftpSSH File Transfer
SMBsmbSMB/CIFS network shares
WebDAVwebdavWebDAV (Nextcloud, ownCloud, Apache, etc.)
FTP / FTPSftpFTP with optional TLS encryption
Rsync (SSH)rsyncRsync over SSH (delta transfers)
Google Drivegoogle-driveGoogle Drive via OAuth 2.0
DropboxdropboxDropbox via OAuth 2.0
OneDriveonedriveMicrosoft OneDrive via OAuth 2.0
Docker Volumesdocker-volumeDocker volume contents. Directory source only - see below

Roles

A storage config is either a backup destination or a directory source, never both. That is normally the user's choice, because most storage serves either end equally well. An adapter that only works one way round declares it on its definition, not on the runtime adapter:

typescript
{
  id: "docker-volume", type: "storage", group: "Containers", name: "Docker Volumes",
  supportedRoles: [STORAGE_ROLES.SOURCE],
  configSchema: DockerVolumeSchema,
}

It lives on the definition because the role picker runs in the browser, and definitions are plain data - importing the runtime adapters there would pull ssh2 and the cloud SDKs into the client bundle. validateStorageRole enforces it on all three write paths: create, update, and clone, the last of which exists specifically to produce a config with the opposite role.

Preparing a source before it is read

Some sources cannot be read as they are. A Docker volume is only reachable from inside a container, and an SMB share can offer a point-in-time shadow copy instead of a live tree. Both go through the same three optional members:

typescript
supportsSnapshot?(config, remotePath): Promise<{ supported: boolean; message: string }>;
createSnapshot?(config, remotePaths: string[], options?): Promise<SnapshotHandle>;
releaseSnapshot?(config, handle): Promise<void>;

/** Prepare unconditionally, rather than only when the config asks for it. */
alwaysSnapshot?: true;

/** Group this adapter's sources so shared preparation happens once. */
planSourceGroups?(config, remotePaths: string[]): Promise<string[][]>;

The handle's configOverride is merged onto the config the collection reads through, which is how a prepared source hands its state to downloadDirectory without the runner knowing a connection exists.

planSourceGroups exists for preparation that reaches beyond the source itself. Backing up a container volume means stopping the containers holding it, two volumes of one container must stop it once rather than twice, and a container whose volumes are done should start again without waiting out the rest of the job. The runner collects each returned group in turn and releases it as soon as the group is finished - which is why an adapter without this member behaves exactly as it always did, with one source per group.

The returned partition has to cover the given paths exactly once. A dropped path is a directory missing from a backup that would otherwise report success, so the runner refuses it before anything is collected.

Keeping a library at arm's length

storage/docker/ is worth reading as a pattern for an adapter built on a client library. Everything above engine/dockerode-engine.ts speaks volumes and containers; that one file speaks dockerode, and a lint guard holds the line. The point is not a second implementation

  • there will not be one - but that the grouping, the container bookkeeping and the crash recovery are testable against a fake with fourteen methods instead of a mock reproducing getVolume(...).remove().

Interface

typescript
interface StorageAdapter {
  id: string;
  type: "storage";
  name: string;

  // Core operations
  upload(
    config: unknown,
    localPath: string,
    remotePath: string,
    onProgress?: (percent: number) => void,
    onLog?: (msg: string, level?: LogLevel, type?: LogType, details?: string) => void,
    options?: UploadOptions
  ): Promise<boolean>;

  download(
    config: unknown,
    remotePath: string,
    localPath: string,
    onProgress?: (processed: number, total: number) => void,
    onLog?: (msg: string, level?: LogLevel, type?: LogType, details?: string) => void
  ): Promise<boolean>;

  list(config: unknown, path: string): Promise<FileInfo[]>;
  delete(config: unknown, path: string): Promise<boolean>;

  // Connection tests
  test?(config: unknown): Promise<TestResult>;  // Full write/delete test (~15 s timeout)
  ping?(config: unknown): Promise<TestResult>;  // Lightweight check — no test file written

  // Optional: native checksum verification (avoids full re-download)
  // Implemented by: S3, Google Drive, OneDrive
  verifyChecksum?(
    config: unknown,
    remotePath: string,
    checksums: { sha256?: string; md5?: string }
  ): Promise<boolean>;

  // Optional: persistent session for multiple uploads in one job run
  // Avoids reconnecting for each destination file
  openSession?(
    config: unknown,
    onLog?: (msg: string, level?: LogLevel, type?: LogType, details?: string) => void
  ): Promise<StorageSession>;

  // Optional: read small files directly (used for .meta.json sidecar retrieval)
  read?(config: unknown, path: string): Promise<string | null>;
}

No configSchema on the adapter

The Zod schema lives in src/lib/adapters/definitions/storage.ts, registered in ADAPTER_DEFINITIONS. It is not a property on the adapter instance.

ping() vs test()

ping() is used by the health check system (every minute) — it must be fast and must not write any files to storage. test() is used for manual "Test Connection" clicks and may write and delete a test file (~15 s timeout). If ping() is not implemented, the health check falls back to test().

FileInfo Interface

typescript
interface FileInfo {
  name: string;           // Filename only
  path: string;           // Full path
  size: number;           // Size in bytes
  lastModified: Date;     // Last modified
  locked?: boolean;       // Locked from deletion
}

Local Adapter

Simple filesystem operations:

typescript
const LocalAdapter: StorageAdapter = {
  id: "local-filesystem",
  type: "storage",
  name: "Local Storage",
  configSchema: LocalStorageSchema,

  async upload(config, localPath, remotePath) {
    const validated = LocalSchema.parse(config);
    const fullPath = path.join(validated.basePath, remotePath);

    // Ensure directory exists
    await mkdir(path.dirname(fullPath), { recursive: true });

    // Copy file
    await copyFile(localPath, fullPath);
  },

  async download(config, remotePath, localPath) {
    const validated = LocalSchema.parse(config);
    const fullPath = path.join(validated.basePath, remotePath);
    await copyFile(fullPath, localPath);
  },

  async list(config, dirPath) {
    const validated = LocalSchema.parse(config);
    const fullPath = path.join(validated.basePath, dirPath);

    const entries = await readdir(fullPath, { withFileTypes: true });

    return Promise.all(
      entries.map(async (entry) => {
        const stats = await stat(path.join(fullPath, entry.name));
        return {
          name: entry.name,
          path: path.join(dirPath, entry.name),
          size: stats.size,
          modifiedAt: stats.mtime,
          isDirectory: entry.isDirectory(),
        };
      })
    );
  },

  async delete(config, filePath) {
    const validated = LocalSchema.parse(config);
    const fullPath = path.join(validated.basePath, filePath);
    await unlink(fullPath);
  },

  async test(config) {
    const validated = LocalSchema.parse(config);

    try {
      await access(validated.basePath);
      return { success: true, message: "Path accessible" };
    } catch {
      return { success: false, message: "Path not accessible" };
    }
  },

  async read(config, filePath) {
    const validated = LocalSchema.parse(config);
    const fullPath = path.join(validated.basePath, filePath);
    return readFile(fullPath, "utf-8");
  },
};

S3 Adapter

Uses AWS SDK for S3-compatible storage:

typescript
import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";

const S3Adapter: StorageAdapter = {
  id: "s3-generic",
  type: "storage",
  name: "S3 Compatible",
  configSchema: S3GenericSchema,

  async upload(config, localPath, remotePath) {
    const validated = S3Schema.parse(config);
    const client = createS3Client(validated);

    const fileStream = createReadStream(localPath);

    // Use multipart upload for large files
    const upload = new Upload({
      client,
      params: {
        Bucket: validated.bucket,
        Key: remotePath,
        Body: fileStream,
      },
    });

    await upload.done();
  },

  async download(config, remotePath, localPath) {
    const validated = S3Schema.parse(config);
    const client = createS3Client(validated);

    const response = await client.send(
      new GetObjectCommand({
        Bucket: validated.bucket,
        Key: remotePath,
      })
    );

    const fileStream = createWriteStream(localPath);
    await pipeline(response.Body as Readable, fileStream);
  },

  async list(config, prefix) {
    const validated = S3Schema.parse(config);
    const client = createS3Client(validated);

    const response = await client.send(
      new ListObjectsV2Command({
        Bucket: validated.bucket,
        Prefix: prefix,
      })
    );

    return (response.Contents || []).map((item) => ({
      name: path.basename(item.Key!),
      path: item.Key!,
      size: item.Size || 0,
      modifiedAt: item.LastModified || new Date(),
      isDirectory: false,
    }));
  },

  async delete(config, filePath) {
    const validated = S3Schema.parse(config);
    const client = createS3Client(validated);

    await client.send(
      new DeleteObjectCommand({
        Bucket: validated.bucket,
        Key: filePath,
      })
    );
  },

  async test(config) {
    const validated = S3Schema.parse(config);
    const client = createS3Client(validated);

    try {
      await client.send(
        new ListObjectsV2Command({
          Bucket: validated.bucket,
          MaxKeys: 1,
        })
      );
      return { success: true, message: "S3 connection successful" };
    } catch (error) {
      return { success: false, message: `S3 error: ${error}` };
    }
  },

  async read(config, filePath) {
    const validated = S3Schema.parse(config);
    const client = createS3Client(validated);

    const response = await client.send(
      new GetObjectCommand({
        Bucket: validated.bucket,
        Key: filePath,
      })
    );

    return response.Body!.transformToString();
  },
};

function createS3Client(config: S3Config): S3Client {
  return new S3Client({
    endpoint: config.endpoint,
    region: config.region,
    credentials: {
      accessKeyId: config.accessKey,
      secretAccessKey: config.secretKey,
    },
    forcePathStyle: config.forcePathStyle,
  });
}

SFTP Adapter

Uses ssh2-sftp-client for SSH file transfers:

typescript
import SftpClient from "ssh2-sftp-client";

const SFTPAdapter: StorageAdapter = {
  id: "sftp",
  type: "storage",
  name: "SFTP",
  configSchema: SFTPSchema,

  async upload(config, localPath, remotePath) {
    const validated = SFTPSchema.parse(config);
    const sftp = new SftpClient();

    try {
      await sftp.connect({
        host: validated.host,
        port: validated.port,
        username: validated.username,
        password: validated.password,
        privateKey: validated.privateKey,
      });

      const fullPath = path.join(validated.basePath || "", remotePath);

      // Ensure directory exists
      await sftp.mkdir(path.dirname(fullPath), true);

      // Upload file
      await sftp.put(localPath, fullPath);
    } finally {
      await sftp.end();
    }
  },

  async download(config, remotePath, localPath) {
    const validated = SFTPSchema.parse(config);
    const sftp = new SftpClient();

    try {
      await sftp.connect(/* ... */);
      const fullPath = path.join(validated.basePath || "", remotePath);
      await sftp.get(fullPath, localPath);
    } finally {
      await sftp.end();
    }
  },

  async list(config, dirPath) {
    const validated = SFTPSchema.parse(config);
    const sftp = new SftpClient();

    try {
      await sftp.connect(/* ... */);
      const fullPath = path.join(validated.basePath || "", dirPath);
      const entries = await sftp.list(fullPath);

      return entries.map((entry) => ({
        name: entry.name,
        path: path.join(dirPath, entry.name),
        size: entry.size,
        modifiedAt: new Date(entry.modifyTime),
        isDirectory: entry.type === "d",
      }));
    } finally {
      await sftp.end();
    }
  },

  async test(config) {
    const validated = SFTPSchema.parse(config);
    const sftp = new SftpClient();

    try {
      await sftp.connect(/* ... */);
      await sftp.list(validated.basePath || "/");
      return { success: true, message: "SFTP connection successful" };
    } catch (error) {
      return { success: false, message: `SFTP error: ${error}` };
    } finally {
      await sftp.end();
    }
  },
};

The read() Method

The optional read() method is crucial for the Storage Explorer. It allows reading small text files (like .meta.json) without downloading to disk:

typescript
async read(config, path) {
  // Returns file content as string
  return "{ \"jobName\": \"daily-backup\", ... }";
}

If not implemented, the system falls back to:

  1. Download to temp file
  2. Read temp file
  3. Delete temp file

Streaming Support

For large files, implement streaming methods:

typescript
createUploadStream(config, remotePath): Writable {
  const validated = S3Schema.parse(config);
  // Return a writable stream that uploads to S3
  return new PassThrough();
}

createDownloadStream(config, remotePath): Readable {
  const validated = S3Schema.parse(config);
  // Return a readable stream from S3
  return response.Body as Readable;
}

Adding a New Storage Adapter

Adding a storage adapter requires changes across multiple layers: backend adapter, schema/definitions, UI integration, and RBAC. Follow every step below to avoid missing integration points.

OAuth-Based Cloud Adapters

If your adapter requires browser-based OAuth authorization (like Google Drive, Dropbox, OneDrive), additional steps are needed beyond the standard checklist. See the OAuth-specific steps section below.

Step-by-Step Checklist

1. Install dependency

bash
pnpm add webdav

2. Create Zod schema + type in src/lib/adapters/definitions.ts

typescript
export const WebDAVSchema = z.object({
  url: z.string().url("Server URL is required"),
  username: z.string().min(1, "Username is required"),
  password: z.string().optional().describe("Password"),
  pathPrefix: z.string().optional().describe("Remote destination folder"),
});

export type WebDAVConfig = z.infer<typeof WebDAVSchema>;

Then update the StorageConfig union type and add an entry to the ADAPTER_DEFINITIONS array:

typescript
export type StorageConfig = LocalStorageConfig | S3GenericConfig | ... | WebDAVConfig;

// In ADAPTER_DEFINITIONS:
{ id: "webdav", type: "storage", name: "WebDAV", configSchema: WebDAVSchema },

3. Create adapter in src/lib/adapters/storage/webdav.ts

Implement the full StorageAdapter interface. All six methods are required:

typescript
import { StorageAdapter, FileInfo } from "@/lib/core/interfaces";
import { WebDAVSchema } from "@/lib/adapters/definitions";
import { logger } from "@/lib/logging/logger";
import { wrapError } from "@/lib/logging/errors";

const log = logger.child({ adapter: "webdav" });

export const WebDAVAdapter: StorageAdapter = {
  id: "webdav",
  type: "storage",
  name: "WebDAV",
  configSchema: WebDAVSchema,

  async upload(config, localPath, remotePath, onProgress, onLog) { /* ... */ },
  async download(config, remotePath, localPath, onProgress, onLog) { /* ... */ },
  async read(config, remotePath) { /* ... */ },
  async list(config, dir) { /* ... */ },
  async delete(config, remotePath) { /* ... */ },
  async test(config) { /* ... */ },
};

read() method

The read() method is used by the Storage Explorer to read .meta.json sidecar files. If not implemented, the system falls back to download → read → delete, which is slower.

test() method

The test() method is used for both manual connection tests and automatic health checks (online/offline status). It should perform a write + delete to verify full access.

4. Register adapter in src/lib/adapters/index.ts

typescript
import { WebDAVAdapter } from "./storage/webdav";

// Inside registerAdapters():
registry.register(WebDAVAdapter);

5. UI: Form field rendering (src/components/adapter/form-constants.ts)

The adapter form renders fields dynamically from the Zod schema. Fields are split into two tabs based on these arrays:

Connection tab - Add any new connection-related field keys your schema introduces:

typescript
export const STORAGE_CONNECTION_KEYS = [
    'host', 'port',
    'endpoint', 'region',
    'accountId', 'bucket', 'basePath',
    'address', 'domain',             // ← SMB added these
    'user', 'username',
    'password', 'accessKeyId', 'secretAccessKey',
    'privateKey', 'passphrase'
];

Configuration tab - Add any new config-related field keys:

typescript
export const STORAGE_CONFIG_KEYS = [
    'pathPrefix', 'storageClass', 'forcePathStyle',
    'maxProtocol',                    // ← SMB added this
    'options'
];

Placeholders - Add helpful placeholder values for your adapter's fields:

typescript
export const PLACEHOLDERS: Record<string, string> = {
    // WebDAV
    "webdav.url": "https://nextcloud.example.com/remote.php/dav/files/user/",
    "webdav.username": "backupuser",
    "webdav.password": "secure-password",
    "webdav.pathPrefix": "backups/server1",
};

WARNING

If your schema introduces field keys that are not in either STORAGE_CONNECTION_KEYS or STORAGE_CONFIG_KEYS, those fields will not appear in the form UI. This is the most common issue when adding a new adapter.

6. UI: Adapter icon (src/components/adapter/utils.ts)

Add a bundled Iconify icon for your adapter. See the Icon System guide for full details.

  1. Import the icon data (prefer SVG Logos, fall back to Simple Icons or Lucide):
typescript
import myBrandIcon from "@iconify-icons/logos/my-brand-icon";
  1. Add it to ADAPTER_ICON_MAP:
typescript
"my-adapter": myBrandIcon,
  1. If using Simple Icons (monochrome), also add a brand color to ADAPTER_COLOR_MAP.

7. UI: Details column (src/components/adapter/adapter-manager.tsx)

Add a case to the getSummary() function to show a useful detail in the adapter table:

typescript
case 'webdav':
    return <span className="text-muted-foreground">{config.pathPrefix || config.url}</span>;

Always use the text-muted-foreground class for consistency.

8. RBAC: Permission regex (src/app/api/adapters/)

Two API routes use regex to map adapter IDs to permission groups. Add your adapter ID to the storage regex in both files:

  • src/app/api/adapters/test-connection/route.ts
  • src/app/api/adapters/access-check/route.ts
typescript
} else if (/local-filesystem|s3|sftp|smb|webdav|ftp|rsync/i.test(adapterId)) {
    return PERMISSIONS.DESTINATIONS.READ;
}

WARNING

If your adapter ID is missing from this regex, the test-connection endpoint will skip RBAC permission checks for your adapter. Health checks may also behave unexpectedly.

9. Dockerfile (if CLI tools needed)

If your adapter depends on a system CLI tool (like smbclient for SMB), add it to the Dockerfile:

dockerfile
RUN apk add --no-cache \
    # ... existing packages
    your-package \

10. macOS dev setup script (if CLI tools needed)

Update scripts/setup-dev-macos.sh to install the CLI dependency:

bash
echo "Installing YourTool..."
brew install your-package

Integration Checklist Summary

#FileWhat to do
1package.jsonInstall npm dependency
2src/lib/adapters/definitions.tsZod schema, config type, StorageConfig union, ADAPTER_DEFINITIONS
3src/lib/adapters/storage/<name>.tsFull adapter implementation (6 methods)
4src/lib/adapters/index.tsImport + registry.register()
5src/components/adapter/form-constants.tsSTORAGE_CONNECTION_KEYS, STORAGE_CONFIG_KEYS, PLACEHOLDERS
6src/components/adapter/utils.tsADAPTER_ICON_MAP + optional ADAPTER_COLOR_MAP (Icon System)
7src/components/adapter/adapter-manager.tsxgetSummary() case for details column
8src/app/api/adapters/test-connection/route.tsAdd ID to storage permission regex
9src/app/api/adapters/access-check/route.tsAdd ID to storage permission regex
10DockerfileSystem CLI tools (if needed)
11scripts/setup-dev-macos.shLocal dev CLI setup (if needed)
12docs/User guide + developer guide + changelog

OAuth-Specific Additional Steps

If the new adapter requires browser-based OAuth (e.g., Google Drive, Dropbox, OneDrive), these additional steps are needed on top of the standard checklist:

#FileWhat to do
13src/app/api/adapters/<name>/auth/route.tsOAuth authorization URL generation endpoint
14src/app/api/adapters/<name>/callback/route.tsOAuth callback - exchange code for tokens, store refresh token encrypted
15src/components/adapter/<name>-oauth-button.tsxOAuth button component with authorized/unauthorized status
16src/components/adapter/form-sections.tsxSpecial form layout: show OAuth button in connection tab, hide auto-managed fields (e.g., refreshToken)
17src/lib/crypto.tsAdd OAuth secret fields to SENSITIVE_KEYS (e.g., clientSecret, refreshToken)
18src/app/api/system/filesystem/<name>/route.tsFolder browse API (if provider supports folder selection)
19src/components/adapter/<name>-folder-browser.tsxFolder browser dialog (if provider supports folder selection)

Reference implementations: See the Google Drive, Dropbox, and OneDrive adapters for complete examples of this pattern:

  • Storage adapters: src/lib/adapters/storage/google-drive.ts, src/lib/adapters/storage/dropbox.ts, src/lib/adapters/storage/onedrive.ts
  • OAuth routes: src/app/api/adapters/google-drive/, src/app/api/adapters/dropbox/, and src/app/api/adapters/onedrive/ (each with auth/ + callback/)
  • OAuth buttons: src/components/adapter/google-drive-oauth-button.tsx, src/components/adapter/dropbox-oauth-button.tsx, src/components/adapter/onedrive-oauth-button.tsx
  • Folder browsers: src/components/adapter/google-drive-folder-browser.tsx, src/components/adapter/dropbox-folder-browser.tsx, src/components/adapter/onedrive-folder-browser.tsx
  • Folder browse APIs: src/app/api/system/filesystem/google-drive/route.ts, src/app/api/system/filesystem/dropbox/route.ts, src/app/api/system/filesystem/onedrive/route.ts

Released under the GNU General Public License. | Privacy · Legal Notice