docs: init project (#792)

* docs: init project

docs: design

content

header sticky

docs: content

docs: update starting guide

docs: corrections

docs: oidc, sso & more

feat: landing page

style: card design

style: colors

style: zerobyte logo

style: corner content

style: docs cards

ci(docs): auto deploy to cloudflare

docs: 3-2-1 strategy

* fix: anchor links

* style: refactor landing hero

* feat: og

* chore: fix ci

* ci: build docs before publishing
This commit is contained in:
Nico 2026-04-15 23:13:10 +02:00 committed by GitHub
parent 33601dde24
commit d10a3d2d65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 8791 additions and 7 deletions

View file

@ -1,6 +1,11 @@
name: Install dependencies name: Install dependencies
description: Install dependencies description: Install dependencies
inputs:
workingDirectory:
description: "The working directory to install dependencies in"
required: false
default: "."
runs: runs:
using: "composite" using: "composite"
@ -21,4 +26,6 @@ runs:
- name: Install dependencies - name: Install dependencies
shell: bash shell: bash
run: sfw vp install --frozen-lockfile run: |
cd ${{ inputs.workingDirectory }}
sfw vp install --frozen-lockfile

42
.github/workflows/docs-deploy.yml vendored Normal file
View file

@ -0,0 +1,42 @@
name: Docs deploy
permissions:
contents: read
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "docs/**"
jobs:
deploy:
name: Deploy docs
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Install dependencies
uses: "./.github/actions/install-dependencies"
with:
workingDirectory: docs
- name: Build docs
working-directory: docs
run: bun run build
- name: Build and deploy docs
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: docs

View file

@ -17,10 +17,10 @@
#### Join the community #### Join the community
[![Discord](https://img.shields.io/discord/1466834119873925263?label=discord&logo=discord)](https://discord.gg/MzBXz5v5XB) [![Discord](https://img.shields.io/discord/1466834119873925263?label=discord&logo=discord)](https://discord.gg/XjgVyXrcEH)
> [!WARNING] > [!WARNING]
> Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedbacks. Expect bugs! Please open issues or feature requests > Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedbacks. Please open issues for bugs or feature requests
<p align="center"> <p align="center">
<a href="https://www.buymeacoffee.com/nicotsx" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a> <a href="https://www.buymeacoffee.com/nicotsx" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
@ -30,6 +30,12 @@
Zerobyte is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage. Zerobyte is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage.
## Documentation
The official documentation website is available at [zerobyte.app](https://zerobyte.app).
It contains up-to-date setup guides, configuration reference, and usage documentation for running Zerobyte in production.
### Features ### Features
- &nbsp; **Automated backups** with encryption, compression and retention policies powered by Restic - &nbsp; **Automated backups** with encryption, compression and retention policies powered by Restic

View file

@ -16,7 +16,7 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
}, },
loader: async () => getRootLoaderData(), loader: async () => getRootLoaderData(),
head: () => ({ head: () => ({
meta: [{ title: "Zerobyte - Open Source Backup Solution" }], meta: [{ title: "Zerobyte - Open Source Backup Solution" }, { name: "robots", content: "noindex, nofollow" }],
links: [ links: [
{ {
rel: "stylesheet", rel: "stylesheet",

15
docs/.cta.json Normal file
View file

@ -0,0 +1,15 @@
{
"projectName": "docs",
"mode": "file-router",
"typescript": true,
"packageManager": "npm",
"includeExamples": false,
"tailwind": true,
"addOnOptions": {},
"envVarValues": {},
"git": false,
"routerOnly": false,
"version": 1,
"framework": "react",
"chosenAddOns": ["nitro"]
}

13
docs/.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
.env
.nitro
.tanstack
.wrangler
.output
.vinxi
__unconfig*
todos.json

19
docs/.source/browser.ts Normal file
View file

@ -0,0 +1,19 @@
// @ts-nocheck
/// <reference types="vite/client" />
import { browser } from 'fumadocs-mdx/runtime/browser';
import type * as Config from '../source.config';
const create = browser<typeof Config, import("fumadocs-mdx/runtime/types").InternalTypeConfig & {
DocData: {
}
}>();
const browserCollections = {
docs: create.doc("docs", import.meta.glob(["./**/*.{mdx,md}"], {
"base": "./../content/docs",
"query": {
"collection": "docs"
},
"eager": false
})),
};
export default browserCollections;

9
docs/.source/dynamic.ts Normal file
View file

@ -0,0 +1,9 @@
// @ts-nocheck
/// <reference types="vite/client" />
import { dynamic } from 'fumadocs-mdx/runtime/dynamic';
import * as Config from '../source.config';
const create = await dynamic<typeof Config, import("fumadocs-mdx/runtime/types").InternalTypeConfig & {
DocData: {
}
}>(Config, {"configPath":"source.config.ts","environment":"vite","outDir":".source"}, {"doc":{"passthroughs":["extractedReferences"]}});

24
docs/.source/server.ts Normal file
View file

@ -0,0 +1,24 @@
// @ts-nocheck
/// <reference types="vite/client" />
import { server } from 'fumadocs-mdx/runtime/server';
import type * as Config from '../source.config';
const create = server<typeof Config, import("fumadocs-mdx/runtime/types").InternalTypeConfig & {
DocData: {
}
}>({"doc":{"passthroughs":["extractedReferences"]}});
export const docs = await create.docs("docs", "content/docs", import.meta.glob(["./**/*.{json,yaml}"], {
"base": "./../content/docs",
"query": {
"collection": "docs"
},
"import": "default",
"eager": true
}), import.meta.glob(["./**/*.{mdx,md}"], {
"base": "./../content/docs",
"query": {
"collection": "docs"
},
"eager": true
}));

11
docs/.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"search.exclude": {
"**/routeTree.gen.ts": true
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
}
}

1988
docs/bun.lock Normal file

File diff suppressed because it is too large Load diff

25
docs/components.json Normal file
View file

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-lyra",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "phosphor",
"rtl": false,
"aliases": {
"components": "#/components",
"utils": "#/lib/utils",
"ui": "#/components/ui",
"lib": "#/lib",
"hooks": "#/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View file

@ -0,0 +1,248 @@
---
title: Backups
description: Learn how backup jobs connect volumes to repositories with scheduling, retention policies, and restore capabilities
---
Learn how backup jobs connect volumes to repositories with scheduling, retention policies, and restore capabilities.
## What are backup jobs?
A backup job is the central piece of Zerobyte. It defines four things:
- **What** to back up, a [volume](/docs/concepts/volumes) (your data source)
- **Where** to store it, a [repository](/docs/concepts/repositories) (your encrypted storage destination)
- **When** to run, a cron schedule
- **How long** to keep snapshots, a retention policy
Each time a backup job runs, Restic creates an incremental, encrypted snapshot of the volume's contents and stores it in the repository.
## Creating a backup job
To create a backup job, navigate to **Backups** in the sidebar and click **Create a backup job**. First choose a volume, then fill in the backup form:
- **Backup name**, a descriptive name for the job (e.g., "Daily Database Backup")
- **Backup repository**, select from your configured repositories
- **Backup frequency**, choose `Manual only`, `Hourly`, `Daily`, `Weekly`, `Specific days`, or `Custom (Cron)`
- **Retention policy**, optionally define how many snapshots to keep
<Callout type="info">
Choose **Manual only** if you want to create the job without scheduled runs and trigger it yourself.
</Callout>
## Scheduling
Backup jobs use **cron expressions** to define when they run. Cron syntax gives you precise control over scheduling.
### Common cron patterns
| Cron Expression | Schedule |
| ----------------- | ---------------------------- |
| `0 2 * * *` | Daily at 2:00 AM |
| `0 2 * * 0` | Weekly on Sunday at 2:00 AM |
| `0 2 1 * *` | Monthly on the 1st at 2:00 AM |
| `0 */6 * * *` | Every 6 hours |
| `0 0 * * 1-5` | Weekdays at midnight |
<Callout type="info">
The timezone for cron schedules is controlled by the `TZ` environment variable set on the Zerobyte container. If `TZ` is not set, the system defaults to UTC.
</Callout>
## Retention policies
Retention policies control how long snapshots are kept. After each backup, Restic automatically prunes snapshots that fall outside the defined policy, freeing up storage space.
You can configure the following retention rules:
| Rule | Description |
| -------------- | ------------------------------------------ |
| **Keep Last** | Number of most recent snapshots to keep |
| **Keep Daily** | Number of daily snapshots to keep |
| **Keep Weekly** | Number of weekly snapshots to keep |
| **Keep Monthly** | Number of monthly snapshots to keep |
| **Keep Yearly** | Number of yearly snapshots to keep |
### Example retention policy
A balanced policy for most workloads:
- **Keep Daily**: 7, one snapshot per day for the past week
- **Keep Weekly**: 4, one snapshot per week for the past month
- **Keep Monthly**: 6, one snapshot per month for the past half year
- **Keep Yearly**: 1, one snapshot per year for long-term archival
<Callout type="info">
Retention rules are additive. A snapshot that satisfies any rule is kept. Restic determines which snapshots best represent each time period automatically.
</Callout>
## Include and exclude paths
By default, the entire volume is backed up. You can refine this with include and exclude patterns.
### Include paths
Specify subdirectories within the volume to back up. Only these paths will be included in the snapshot.
### Exclude patterns
Exclude files or directories that do not need to be backed up. Common patterns:
- `*.tmp`, temporary files
- `node_modules/`, dependency directories
- `.cache/`, cache directories
- `*.log`, log files
<Callout type="warn">
Exclude patterns use Restic's pattern matching. Test your patterns after the first backup by browsing the snapshot contents to confirm the right files are included.
</Callout>
## Compression
Backups inherit the compression mode from the repository they write to. Compression is configured per repository and applies to all backup jobs targeting that repository.
Available modes are **auto** (default), **off**, and **max**. See [Repositories](/docs/concepts/repositories) for details on each mode.
## Running backups
There are two ways to run a backup job:
- **Scheduled**, the job runs automatically according to its cron schedule. No intervention required.
- **Manual**, click **Backup now** on any backup job to trigger it immediately, regardless of the schedule.
Both methods produce identical snapshots. Manual runs are useful for verifying a new backup job or creating an extra snapshot before a major change.
<Callout type="info">
You can monitor backup progress in real time through the web interface. Zerobyte streams file counts, data processed, and upload progress as the backup runs.
</Callout>
## Backup status
The UI exposes two related status views:
- In the **Backups** list, the status dot shows **Active**, **Paused**, **Error**, **Warning**, or **Backup in progress**
- In the backup job details page, the last run status shows **Success**, **Error**, **Warning**, or **in progress**
- If a backup job has not completed a run yet, the details page shows no last run value
## Snapshots
Each successful backup creates a snapshot in the repository. Snapshots have several important properties:
- **Immutable**, once created, a snapshot cannot be modified
- **Incremental**, only changed data since the last snapshot is uploaded
- **Browsable**, you can explore snapshot contents directly in the web interface, viewing file sizes, modification times, and directory structure
The first backup uploads all data and may take longer. Subsequent backups are significantly faster because Restic deduplicates data at the chunk level and only transfers new or changed blocks.
## Restoring data
To restore files from a backup:
<Steps>
<Step>
### Navigate to the backup job
Go to **Backups** and select the job containing the data you need.
</Step>
<Step>
### Select a snapshot
Choose the snapshot from the time period you want to restore from.
</Step>
<Step>
### Browse and select files
Navigate the file tree and select the files or folders you want to restore.
</Step>
<Step>
### Choose a restore location
Decide whether to restore to the original location or specify a custom path.
</Step>
<Step>
### Restore
Click **Restore** and wait for the operation to complete.
</Step>
</Steps>
<Callout type="warn">
By default, restored files are placed back in their original location. This can overwrite current files. If you want to inspect the restored data before replacing anything, specify an alternate restore path.
</Callout>
## Mirror repositories
You can optionally copy snapshots to additional repositories after each backup. Mirrors provide:
- **Geographic redundancy**, store copies in different regions or data centers
- **Provider diversification**, avoid depending on a single cloud provider
- **Compliance**, meet data locality or regulatory requirements
Mirrors are configured per backup job. After each successful backup, snapshots are automatically copied to all enabled mirror repositories.
For a practical pattern that combines fast local restores with an offsite copy, see [3-2-1 Backup Strategy](/docs/guides/3-2-1-backup-strategy).
## Monitoring
The Zerobyte web interface provides visibility into each backup job:
- **Schedule**, the cron expression or `Manual only` configuration for the job
- **Last backup**, when the most recent run happened
- **Next backup**, when the job is expected to run next
- **Snapshot history**, the list of snapshots created for that job
- **Snapshot statistics**, data added, data stored, files processed, bytes processed, and duration for the selected snapshot
- **Restore actions**, restore or download data from the selected snapshot
## Best practices
<Accordions type="multiple">
<Accordion title="Schedule backups during off-peak hours">
Run backups when system load is lowest to minimize impact on production workloads. Late night or early morning schedules (e.g., `0 2 * * *`) work well for most setups.
</Accordion>
<Accordion title="Use retention policies to balance storage and history">
Keeping every snapshot indefinitely wastes storage. A policy like 7 daily, 4 weekly, 6 monthly, and 1 yearly provides good coverage without excessive cost.
</Accordion>
<Accordion title="Test restores regularly">
A backup is only valuable if you can restore from it. Periodically restore files to a test location to verify data integrity and familiarize yourself with the restore process.
</Accordion>
<Accordion title="Use exclude patterns for temporary files">
Excluding directories like `node_modules/`, `.cache/`, and `*.tmp` files reduces backup size and duration without losing important data.
</Accordion>
<Accordion title="Monitor failed backups and set up notifications">
Check the backup dashboard regularly for failed jobs. Configure notifications so you are alerted immediately when a backup fails rather than discovering it during a restore. See the [Notifications guide](/docs/guides/notifications) for destination setup, event routing, and delivery examples.
</Accordion>
</Accordions>
## Next steps
<Cards>
<Card title="Configuration" icon={<Settings />} href="/docs/configuration">
Configure environment variables, Docker secrets, and deployment options
</Card>
<Card title="Troubleshooting" icon={<LifeBuoy />} href="/docs/troubleshooting">
Diagnose and resolve common backup issues
</Card>
</Cards>
import { Step, Steps } from "fumadocs-ui/components/steps";
import { Accordion, Accordions } from "fumadocs-ui/components/accordion";
import { Card, Cards } from "../../../src/components/DocsCard";
import { Callout } from "fumadocs-ui/components/callout";
import { Settings, LifeBuoy } from "lucide-react";

View file

@ -0,0 +1,4 @@
{
"title": "Concepts",
"pages": ["volumes", "repositories", "backups", "organizations"]
}

View file

@ -0,0 +1,75 @@
---
title: Organizations
description: Understand how Zerobyte scopes resources, invitations, and roles
---
Zerobyte is organization-scoped. Repositories, volumes, backup schedules, notification destinations, SSO providers, and invitations all belong to an organization.
## What an organization controls
- Every request runs inside an active organization context.
- Resource lists and mutations are filtered to that active organization.
- Membership determines who can access organization-scoped features.
- The organization ID shown in **Settings > Organization** is the same ID used by provisioning.
## How organizations are created
For a normal local signup, if the new user has no existing membership, Zerobyte creates a default workspace named like `Alice's Workspace` and makes that user an `owner`.
Invited SSO users behave differently: Zerobyte creates their membership in the invited organization during the first successful SSO callback, so they land in that organization instead of getting a separate default workspace.
## Organization roles
| Role | What it means in practice |
| --- | --- |
| `member` | Standard organization member. Can use the organization, but cannot access organization-management actions such as member management or SSO settings. |
| `admin` | Can access the **Organization** settings tab, manage members, manage invitations, toggle SSO auto-linking, and download the active organization's recovery key. |
| `owner` | Has all admin capabilities and is additionally required for registering new SSO providers. Owners cannot be demoted or removed through the current organization member-management endpoints. |
<Callout type="info">
Zerobyte also has an instance-wide user role named `admin` for global tasks such as registration control and user management. That global role is separate from organization roles like `member`, `admin`, and `owner`.
</Callout>
## Invitations
Organization invitations carry four important pieces of data:
- The invitee email address
- The role to grant on acceptance
- The invitation status
- The expiration time
In the current UI, invitation management lives inside **Settings > Organization > Single Sign-On** because SSO access is invite-oriented.
### Role assignment by invitation
New invitations can assign one of these organization roles immediately:
- `member`
- `admin`
- `owner`
That matters because the current **Members** table only lets you toggle existing members between `member` and `admin`. If you want to add another owner, invite that person as an `owner` from the invitation flow.
## Member management behavior
- Organization admins and owners can promote `member -> admin`.
- Organization admins and owners can demote `admin -> member`.
- Owners cannot be demoted through the member-management API.
- Owners cannot be removed through the member-management API.
- Removing a member immediately removes their access to that organization's resources.
If the removed user belongs to another organization, Zerobyte rehomes their active session to a fallback organization membership. If they do not belong to any other organization, Zerobyte revokes their sessions and they must sign in again after being re-added.
## Organization ID
The **Organization ID** shown in **Settings > Organization** is primarily a reference value, but it is important for operator-managed setups because provisioning entries must point at an existing organization ID.
See [Provisioning](/docs/guides/provisioning) if you manage repositories and volumes declaratively.
## Related docs
- [OIDC and Single Sign-On](/docs/guides/oidc-sso)
- [Recovery keys and repository passwords](/docs/guides/recovery-key-and-repository-passwords)
import { Callout } from "fumadocs-ui/components/callout";

View file

@ -0,0 +1,247 @@
---
title: Repositories
description: Encrypted storage destinations where your backup snapshots are stored
---
Repositories are encrypted storage destinations where your backup snapshots are stored. Every piece of data that Zerobyte backs up ends up in a repository, protected by strong encryption and deduplicated to save space.
## What are repositories?
A repository is a Restic-powered storage location that holds your backup snapshots. When you create a backup job, you point it at a repository, and that is where the encrypted data goes.
Repositories provide:
- **End-to-end encryption**, all data is encrypted before it leaves your machine, so even your storage provider cannot read it
- **Deduplication**, identical data blocks are stored only once, even across different backups
- **Compression**, configurable compression reduces storage costs
- **Immutable snapshots**, once a snapshot is created, it cannot be modified
- **Incremental backups**, only changed data is transferred and stored after the initial backup
Each repository is self-contained. You can store repositories on local disks, cloud object storage, remote servers, or any of 40+ backends supported through rclone.
## Supported repository backends
Zerobyte supports eight backend types. When creating a repository, you select a backend and fill in the required fields in the UI.
### Local
Store backups on a local disk or mounted filesystem. Zerobyte creates the repository directory under the path you provide.
**Use cases:** fast local backups, NAS devices, USB-attached drives, high-speed recovery scenarios.
**Fields:** Name, Path.
<Callout type="info">
Zerobyte automatically creates a subdirectory under the given path to keep each repository isolated.
</Callout>
### S3-compatible
Store backups on Amazon S3 or any S3-compatible service, including MinIO, Wasabi, Backblaze B2, and DigitalOcean Spaces.
**Use cases:** cloud-native storage, offsite backups, long-term archival.
**Fields:** Name, Endpoint, Bucket, Access Key ID, Secret Access Key.
Sensitive fields are encrypted before storage. See [Credential security](#credential-security) below.
### Cloudflare R2
S3-compatible object storage from Cloudflare with zero egress fees, making it cost-effective for frequent restores.
**Use cases:** cost-effective cloud backups, frequent restore operations, global edge storage.
**Fields:** Name, Endpoint, Bucket, Access Key ID, Secret Access Key.
### Google Cloud Storage
Store backups on Google Cloud Platform's object storage.
**Use cases:** GCP-native storage, integration with existing GCP infrastructure, Nearline/Coldline storage tiers.
**Fields:** Name, Bucket, Project ID, Service Account JSON.
<Callout type="info">
The service account needs permissions to create, read, and delete objects in the target bucket.
</Callout>
### Azure Blob Storage
Store backups on Microsoft Azure's object storage.
**Use cases:** Azure-native storage, enterprise compliance, Hot/Cool/Archive tier optimization.
**Fields:** Name, Container, Account Name, Account Key, Endpoint Suffix (optional).
### Rclone
Access any of rclone's 40+ supported cloud storage backends, including Dropbox, Google Drive, OneDrive, and many more.
**Use cases:** exotic cloud providers, consumer cloud storage, cross-cloud backup strategies.
**Fields:** Name, Remote (as configured in your rclone config), Path.
<Callout type="warn">
Your rclone configuration file must be mounted into the Zerobyte container. Consumer services like Google Drive may have API rate limits and are not recommended for production backups.
</Callout>
### REST server
Connect to a Restic REST server for network-based backup storage.
**Use cases:** self-hosted backup infrastructure, dedicated backup servers, REST-based appliances.
**Fields:** Name, URL, Path (optional), Username (optional), Password (optional).
### SFTP
Store backups on remote servers accessible via SSH.
**Use cases:** backup to remote Linux servers, VPS-based offsite storage, SSH-accessible targets.
**Fields:** Name, Host, Port, User, Path, SSH Private Key, Known Hosts or Skip Host Key Verification.
<Callout type="warn">
For production use, keep host key verification enabled and provide the server's known host key to prevent man-in-the-middle attacks.
</Callout>
## Connecting to an existing repository
When creating a repository, you can mark it as **existing** to connect to a Restic repository that was already initialized elsewhere. This is useful when:
- Another Zerobyte instance already created the repository
- You initialized the repository directly with the Restic CLI
- You are migrating from another backup tool that uses Restic
Zerobyte will verify that it can access the existing repository and begin using it for backups and restores without re-initializing it.
When importing an existing repository, Zerobyte can either use the active organization's recovery key or store a separate custom password for that repository. See [Recovery keys and repository passwords](/docs/guides/recovery-key-and-repository-passwords) for the exact password model and when to choose each option.
## Compression modes
Compression is set when creating a repository and applies to all backups stored in it. Three modes are available:
<Accordions>
<Accordion title="Auto (default, recommended)">
Restic automatically decides whether to compress each data chunk. This provides the best balance of speed and space savings for most workloads.
</Accordion>
<Accordion title="Off">
No compression is applied. Use this when backing up data that is already compressed, such as video files, ZIP archives, or compressed images. Avoids wasting CPU on data that will not shrink further.
</Accordion>
<Accordion title="Max">
Maximum compression for the best space savings. Uses more CPU time during backups. Best suited for text-heavy data, source code, and database dumps where CPU is not a bottleneck.
</Accordion>
</Accordions>
## Bandwidth limiting
You can configure upload and download speed limits per repository to prevent backups from saturating your network. Limits are set in the repository settings with the following units: **Kbps**, **Mbps**, or **Gbps**.
- **Upload limit**, controls the speed at which data is sent to the repository during backups. Useful for avoiding bandwidth contention during business hours or staying within ISP caps.
- **Download limit**, controls the speed at which data is retrieved during restores or snapshot browsing. Helps manage egress costs on cloud providers.
<Callout type="info">
Bandwidth limits are enforced at the application layer. Actual network usage may be slightly higher due to protocol overhead.
</Callout>
## Repository maintenance
Zerobyte provides maintenance actions in the repository details page.
### Doctor
A background maintenance run that unlocks the repository, runs `restic check`, and repairs the index if restic reports that repair is needed.
<Callout type="warn">
Doctor blocks other repository operations while it runs, but it does **not** currently perform a full `check --read-data` scrub and it does **not** prune unused data. See the [Repository maintenance guide](/docs/guides/repository-maintenance) for the exact behavior.
</Callout>
### Unlock
Removes stale locks from a repository. Use this when you see "repository is locked" errors after a crash or forced termination of a backup or restore operation.
<Callout type="info">
Only run unlock if you are certain no other operations are actively accessing the repository.
</Callout>
### Statistics refresh
The refresh button in **Compression Statistics** recalculates stored-size, compression, and snapshot-count metrics. It does not replace Doctor and it does not change repository health status.
## Snapshots
Each backup creates an immutable snapshot in the repository. Snapshots are incremental, so after the initial full backup, only changed data is stored in subsequent snapshots. This means the 10th backup of a 100 GB dataset might only add a few megabytes if little has changed.
You can browse the contents of any snapshot and restore individual files or entire directories from the Zerobyte interface.
## Mirror repositories
You can configure backup jobs to copy snapshots to additional repositories after each successful backup. This provides:
- **Geographic redundancy**, store copies in different regions or data centers
- **Provider diversification**, avoid depending on a single cloud provider
- **Compliance**, meet data locality or multi-copy retention requirements
Mirrors are configured per backup job. See [Backups](/docs/concepts/backups) for details on setting up mirror repositories.
## Credential security
All sensitive fields (passwords, access keys, private keys, service account credentials) are encrypted before being stored. Credentials are never saved in plaintext.
Provisioned repositories also support secret references:
- **`env://VARIABLE_NAME`**, resolves the value from the specified environment variable during provisioning
- **`file://secret_name`**, resolves the value from a Docker secret file at `/run/secrets/secret_name` during provisioning
During provisioning, Zerobyte resolves these references on startup and stores the resolved value encrypted in the database.
The regular repository form in the UI currently expects the actual credential value. Entering `env://...` or `file://...` there will not resolve it at runtime.
## Best practices
<Accordions>
<Accordion title="Use separate repositories for different data classes">
Create distinct repositories for critical data, archive data, and test/development environments. This lets you apply different retention policies, storage tiers, and maintenance schedules to each class.
</Accordion>
<Accordion title="Enable bandwidth limits for cloud repositories">
Prevent backup operations from saturating your internet connection, especially during business hours. Even modest limits (e.g., 50 Mbps) can make a significant difference to other services sharing the same link.
</Accordion>
<Accordion title="Run maintenance (Doctor) periodically">
Schedule the Doctor operation monthly or quarterly for production repositories to verify data integrity, rebuild indexes, and reclaim space from deleted snapshots.
</Accordion>
<Accordion title="Test restore operations regularly">
Backups are only as good as your ability to restore from them. Periodically restore files from your repositories to verify that data can be recovered, recovery time meets your expectations, and restored data is complete.
</Accordion>
<Accordion title="Monitor repository health">
Keep an eye on repository status. A repository in an error state will cause backup jobs to fail. Set up notifications so you are alerted to issues before they affect your backup schedule.
</Accordion>
</Accordions>
## Next steps
<Cards>
<Card title="Backups" icon={<CalendarDays />} href="/docs/concepts/backups">
Learn how backup jobs create snapshots and manage retention
</Card>
<Card title="Rclone integration" icon={<Cloud />} href="/docs/guides/rclone">
Set up rclone to access 40+ cloud storage backends
</Card>
<Card title="Configuration reference" icon={<Wrench />} href="/docs/configuration">
Full reference for all environment variables and Docker settings
</Card>
</Cards>
import { Step, Steps } from "fumadocs-ui/components/steps";
import { Accordion, Accordions } from "fumadocs-ui/components/accordion";
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
import { Card, Cards } from "../../../src/components/DocsCard";
import { Callout } from "fumadocs-ui/components/callout";
import { Cloud, CalendarDays, Wrench } from "lucide-react";

View file

@ -0,0 +1,299 @@
---
title: Volumes
description: Understand volume types, mounting, and how Zerobyte connects to your data sources
---
Volumes are the data sources you want to protect with Zerobyte. Each volume represents a filesystem, such as a local directory, a network share, or a remote storage location, that Zerobyte mounts and reads from when running backups.
## What are volumes?
A volume tells Zerobyte *where* your data lives. When you create a volume, you provide connection details (a path, a server address, credentials) and Zerobyte takes care of mounting that filesystem, monitoring its health, and making it available to your backup jobs.
Volumes support a range of protocols, from simple local directories to NFS shares, Windows/Samba file servers, WebDAV endpoints, SFTP connections, and cloud storage via rclone. Once a volume is mounted, you can browse its contents directly in the UI, assign it to one or more backup jobs, and let Zerobyte handle the rest.
## Supported volume types
Zerobyte supports six volume types. Each one is configured through the web UI when you create or edit a volume.
<Tabs items={["Directory", "NFS", "SMB/CIFS", "WebDAV", "SFTP", "Rclone"]}>
<Tab value="Directory">
### Directory (local)
A local directory on the host machine, mounted into the Zerobyte container via your `docker-compose.yml` file.
**Use cases:**
- Backing up application data, configuration files, or databases on the host
- Testing backup configurations before adding remote volumes
- Protecting Docker volume data or bind-mounted directories
**Form fields:**
- **Name**, a descriptive label for this volume
- **Path**, the path *inside the container* where the directory is mounted (e.g., `/data`)
<Callout type="info">
You must first mount the host directory into the Zerobyte container by adding it to the `volumes` section of your `docker-compose.yml`. For example, to back up `/home/user/photos` on the host, add `- /home/user/photos:/photos:ro` to your compose file, restart the container, then create a Directory volume in the UI with the path `/photos`.
</Callout>
</Tab>
<Tab value="NFS">
### NFS (Network File System)
Mount NFS exports from a NAS, file server, or any system that shares directories over the NFS protocol.
**Use cases:**
- Backing up NAS devices (Synology, QNAP, TrueNAS, etc.)
- Enterprise file server protection
- Shared storage in Linux environments
**Form fields:**
- **Name**, a descriptive label for this volume
- **Server**, hostname or IP address of the NFS server
- **Export Path**, the exported directory on the server (e.g., `/volume1/data`)
- **NFS Version**, protocol version: `3`, `4`, or `4.1`
- **Port**, NFS port (default: `2049`)
- **Read Only**, mount the share in read-only mode
<Callout type="warn">
NFS volumes require the `SYS_ADMIN` capability in your Docker container configuration. See the [Installation guide](/docs/installation) for details on enabling `cap_add: [SYS_ADMIN]`.
</Callout>
</Tab>
<Tab value="SMB/CIFS">
### SMB/CIFS
Connect to Windows file shares or Samba servers using the SMB/CIFS protocol.
**Use cases:**
- Windows file server backups
- Samba shares on Linux or NAS devices
- Active Directory-integrated storage
**Form fields:**
- **Name**, a descriptive label for this volume
- **Server**, hostname or IP address of the SMB server
- **Share**, name of the shared folder (e.g., `documents`)
- **Guest Mode**, enable unauthenticated access if the share allows it
- **Username**, account used to connect when guest mode is disabled
- **Password**, account password when guest mode is disabled
- **Domain**, Windows domain (optional)
- **SMB Version**, protocol version: `1.0`, `2.0`, `2.1`, or `3.0`
- **Port**, SMB port (default: `445`)
- **Read Only**, mount the share in read-only mode
<Callout type="info">
Guest access is supported through the **Guest Mode** option.
</Callout>
<Callout type="warn">
SMB volumes require the `SYS_ADMIN` capability in your Docker container configuration. See the [Installation guide](/docs/installation) for setup details.
</Callout>
</Tab>
<Tab value="WebDAV">
### WebDAV
Mount storage over HTTP/HTTPS using the WebDAV protocol. This is a common option for self-hosted cloud platforms.
**Use cases:**
- Nextcloud or ownCloud instances
- Web-based file storage services
- Any server that exposes a WebDAV endpoint
**Form fields:**
- **Name**, a descriptive label for this volume
- **Server**, hostname or IP of the WebDAV server
- **Path**, path on the server (e.g., `/remote.php/dav/files/username`)
- **Username**, WebDAV account username (optional)
- **Password**, WebDAV account password (optional)
- **Port**, server port (e.g., `443` for HTTPS)
- **SSL**, enable HTTPS connection
- **Read Only**, mount in read-only mode
<Callout type="warn">
WebDAV volumes require the `SYS_ADMIN` capability in your Docker container configuration. See the [Installation guide](/docs/installation) for setup details.
</Callout>
</Tab>
<Tab value="SFTP">
### SFTP (SSH File Transfer Protocol)
Mount remote directories over SSH. Supports both password-based and private key authentication.
**Use cases:**
- Remote Linux server backups
- VPS data protection
- Any SSH-accessible storage
**Form fields:**
- **Name**, a descriptive label for this volume
- **Host**, hostname or IP of the SSH server
- **Port**, SSH port (default: `22`)
- **Username**, SSH account username
- **Password**, password authentication (optional if using a private key)
- **Private Key**, SSH private key authentication (optional if using a password)
- **Path**, directory path on the remote server
- **Skip Host Key Verification**, disable host key checking (not recommended for production)
- **Known Hosts**, required unless host key verification is skipped
- **Read Only**, mount in read-only mode
<Callout type="warn">
SFTP volumes require both the `SYS_ADMIN` capability and access to `/dev/fuse` in your Docker container configuration. See the [Installation guide](/docs/installation) for setup details.
</Callout>
</Tab>
<Tab value="Rclone">
### Rclone (40+ cloud providers)
Access cloud storage from providers like Google Drive, Dropbox, OneDrive, Backblaze B2, and many more through rclone.
**Use cases:**
- Backing up data stored in cloud services to an encrypted repository
- Consolidating data from multiple cloud providers
- Accessing specialized storage backends not natively supported
**Form fields:**
- **Name**, a descriptive label for this volume
- **Remote**, the rclone remote name as defined in your rclone configuration (e.g., `gdrive`)
- **Path**, path within the remote (e.g., `/documents`)
- **Read Only**, mount in read-only mode
<Callout type="info">
Rclone must be configured on your host first, and the configuration directory must be mounted into the Zerobyte container. See the [Installation guide](/docs/installation#mounting-rclone-configuration) for instructions on mounting your rclone config.
</Callout>
<Callout type="warn">
Rclone volumes require both the `SYS_ADMIN` capability and access to `/dev/fuse` in your Docker container configuration.
</Callout>
</Tab>
</Tabs>
## Volume status
Every volume is in one of three states, visible at a glance in the Volumes list.
<Steps>
<Step>
### Mounted
The volume is connected and accessible. Backup jobs can read from this volume.
</Step>
<Step>
### Unmounted
The volume exists in Zerobyte but is not currently connected. Backups that depend on this volume will not run until it is mounted again.
</Step>
<Step>
### Error
Something went wrong, the mount failed, the network is unreachable, or credentials were rejected. The volume detail view shows the specific error message. If auto-remount is enabled, Zerobyte will attempt to recover automatically.
</Step>
</Steps>
## Auto-remount
Auto-remount is enabled by default for every volume. When a mounted volume enters an error state (for example, due to a temporary network outage or a server restart), Zerobyte automatically attempts to re-establish the connection without any manual action.
This is especially valuable for network-based volume types (NFS, SMB, WebDAV, SFTP, Rclone) where transient connectivity issues are common. Auto-remount ensures your scheduled backups continue to run even after brief disruptions.
You can disable auto-remount for any volume if you prefer to handle reconnections manually.
## File browsing
Once a volume is mounted, you can browse its contents directly from the Zerobyte web interface. File browsing lets you:
- Verify that the volume mounted correctly and points to the expected data
- Explore the directory structure to identify paths you want to include or exclude in your backup jobs
- Confirm that the files and folders you need to protect are accessible
This is a read-only view of the volume's contents, browsing does not modify any files.
## Read-only mode
All six volume types support mounting in read-only mode. When enabled, Zerobyte can still read and back up files, but write operations are blocked at the filesystem level.
<Callout type="info">
Read-only mode adds an extra layer of safety when backing up production data. It guarantees that the backup process cannot accidentally modify or delete source files.
</Callout>
## Credential security
Sensitive fields, such as passwords, private keys, and other secrets, are encrypted before they are stored. Zerobyte never saves credentials in plain text.
Provisioned volumes also support secret references:
- **`env://VARIABLE_NAME`**, resolves the value from an environment variable set in your `docker-compose.yml` during provisioning
- **`file://secret_name`**, resolves the value from a Docker secrets file at `/run/secrets/secret_name` during provisioning
During provisioning, Zerobyte resolves these references on startup and stores the resolved value encrypted in the database.
The regular volume form in the UI currently expects the actual credential value. Entering `env://...` or `file://...` there will not resolve it at runtime.
<Callout type="info">
For a complete walkthrough of managing secrets through environment variables, Docker secrets, and provisioning files, see the [Provisioning guide](/docs/guides/provisioning).
</Callout>
## Best practices
<Accordions>
<Accordion title="Name volumes descriptively">
Use names that clearly identify what data the volume contains and where it comes from. Good examples:
- `production-database-dumps`
- `customer-uploads-nfs`
- `accounting-smb-share`
Avoid generic names like `volume1` or `backup`, they become confusing as you add more volumes.
</Accordion>
<Accordion title="Enable auto-remount for network volumes">
Network shares can experience transient failures from server restarts, brief network outages, or DNS hiccups. Keep auto-remount enabled so Zerobyte recovers on its own and your scheduled backups are not disrupted.
</Accordion>
<Accordion title="Use read-only mode when possible">
Mounting volumes in read-only mode prevents any chance of the backup process modifying source data. This is especially important for:
- Live production systems
- Archive or compliance data
- Shared storage used by multiple applications
</Accordion>
<Accordion title="Monitor volume health">
A volume in error state will cause its associated backup jobs to fail. Check the Volumes list regularly. Catching mount issues early prevents gaps in your backup coverage.
</Accordion>
</Accordions>
## Next steps
<Cards>
<Card title="Repositories" href="/docs/concepts/repositories">
Learn about encrypted storage destinations where your backup snapshots are kept
</Card>
<Card title="Backups" href="/docs/concepts/backups">
Configure backup jobs that connect your volumes to repositories with scheduling and retention
</Card>
</Cards>
import { Step, Steps } from "fumadocs-ui/components/steps";
import { Accordion, Accordions } from "fumadocs-ui/components/accordion";
import { Tab, Tabs } from "fumadocs-ui/components/tabs";

View file

@ -0,0 +1,186 @@
---
title: Configuration
description: Environment variables, Docker settings, and configuration reference
---
Zerobyte is configured through environment variables and Docker Compose settings. This page covers all available options.
## Environment Variables
### Required
| Variable | Description | Example |
|----------|-------------|---------|
| `BASE_URL` | The URL where Zerobyte will be accessed. Controls cookie security and CORS behavior. | `http://localhost:4096` or `https://zerobyte.example.com` |
| `APP_SECRET` | Random secret key (32+ characters) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | `94bad46e...c66e25d5c2b` |
<Callout type="warn">
Never share or commit your `APP_SECRET`. If you lose it, encrypted data (credentials stored for volumes and repositories) cannot be recovered.
</Callout>
### Recommended
| Variable | Description | Default |
|----------|-------------|---------|
| `TZ` | Timezone for the container. **Important for accurate backup scheduling.** | `UTC` |
### Optional
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | Port the web interface and API listen on inside the container. | `4096` |
| `RESTIC_HOSTNAME` | Hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` |
| `TRUST_PROXY` | Set to `true` to trust `X-Forwarded-For` headers from a reverse proxy. | `false` |
| `TRUSTED_ORIGINS` | Comma-separated list of additional trusted origins for CORS. | (none) |
| `LOG_LEVEL` | Logging verbosity: `debug`, `info`, `warn`, `error`. | `info` |
| `SERVER_IDLE_TIMEOUT` | Server idle timeout in seconds. | `60` |
| `RCLONE_CONFIG_DIR` | Path to the rclone config directory inside the container. | `/root/.config/rclone` |
| `PROVISIONING_PATH` | Path to a JSON file with operator-managed repositories and volumes to sync at startup. | (none) |
## Docker Compose Settings
### Volume Mounts
Essential volume mounts for Zerobyte:
```yaml
volumes:
# Sync container time with host (recommended)
- /etc/localtime:/etc/localtime:ro
# Zerobyte data directory (database, encryption keys, local repositories)
- /var/lib/zerobyte:/var/lib/zerobyte
```
<Callout type="warn">
**Do not** point `/var/lib/zerobyte` to a network share. This causes permission issues and severe performance degradation. Always use local storage.
</Callout>
<Callout type="info">
**TrueNAS users**: The `/var/lib` path is ephemeral and resets during system upgrades. Create a dedicated ZFS dataset instead:
```yaml
volumes:
- /mnt/tank/docker/zerobyte:/var/lib/zerobyte
```
</Callout>
#### Additional Volume Mounts
| Mount | Purpose |
|-------|---------|
| `/path/to/data:/data:ro` | Mount host directories for local directory backups (use `:ro` for read-only) |
| `~/.config/rclone:/root/.config/rclone:ro` | Mount rclone configuration for rclone-based repositories and volumes |
| `~/.ssh:/root/.ssh:ro` | Mount SSH keys for rclone SFTP remotes that use `key_file` |
| `./provisioning.json:/config/provisioning.json:ro` | Mount a provisioning file for operator-managed resources |
### Container Capabilities
Zerobyte supports two deployment modes depending on your needs:
#### Full Installation (with remote mounts)
Required for mounting NFS, SMB, WebDAV, and SFTP volumes directly from Zerobyte:
```yaml
cap_add:
- SYS_ADMIN
devices:
- /dev/fuse:/dev/fuse
```
#### Simplified Installation (local directories only)
If you only need local directory backups, no special capabilities are required:
```yaml
# No cap_add or devices needed
ports:
- "4096:4096"
```
### Port Configuration
By default, Zerobyte listens on port 4096:
```yaml
ports:
- "4096:4096"
```
To bind to localhost only (recommended when using a reverse proxy):
```yaml
ports:
- "127.0.0.1:4096:4096"
```
## Cookie Security
The `BASE_URL` determines how authentication cookies behave:
| BASE_URL | Cookie Behavior |
|----------|----------------|
| `http://192.168.1.50:4096` | Secure cookies **disabled**, allows login over HTTP |
| `http://localhost:4096` | Secure cookies **disabled**, allows local development |
| `https://zerobyte.example.com` | Secure cookies **enabled**, requires HTTPS |
<Callout type="info">
If `BASE_URL` starts with `https://`, browsers will only send auth cookies over HTTPS connections. Plain HTTP access may show the login page but authentication will fail.
</Callout>
<Callout type="warn">
`TRUSTED_ORIGINS` only allows additional origins for CORS. It does **not** disable secure cookies or make HTTP access work when `BASE_URL` is HTTPS.
</Callout>
## Secret References
When provisioning volumes or repositories, sensitive fields support secret references:
| Reference | Resolves From | Example |
|-----------|--------------|---------|
| `env://VARIABLE_NAME` | Container environment variable | `env://S3_SECRET_KEY` |
| `file://secret_name` | Docker secret at `/run/secrets/secret_name` | `file://smb_password` |
This allows you to keep credentials in your deployment configuration rather than writing them directly into the provisioning file.
<Callout type="info">
The standard volume and repository forms in the UI currently store the value you enter, encrypted at rest. `env://` and `file://` references are only resolved during provisioning.
</Callout>
### Example with Docker Secrets
```yaml docker-compose.yml
services:
zerobyte:
environment:
- S3_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE
secrets:
- s3_secret_key
volumes:
- /var/lib/zerobyte:/var/lib/zerobyte
secrets:
s3_secret_key:
file: ./secrets/s3_secret_key.txt
```
In the provisioning file, reference these as:
- Access Key: `env://S3_ACCESS_KEY`
- Secret Key: `file://s3_secret_key`
## Updating Zerobyte
To update to a new version:
```bash
docker compose pull
docker compose up -d
docker compose logs -f zerobyte
```
<Callout type="warn">
Always check the [release notes](https://github.com/nicotsx/zerobyte/releases) before updating, especially for v0.x.x versions which may include breaking changes.
</Callout>
import { Step, Steps } from "fumadocs-ui/components/steps";

View file

@ -0,0 +1,145 @@
---
title: 3-2-1 Backup Strategy
description: Build a practical 3-2-1 backup setup in Zerobyte with a fast local copy and an offsite encrypted mirror
---
The 3-2-1 backup strategy is a simple rule for reducing backup risk:
- Keep **3 copies** of your data
- Use **2 different storage systems**
- Keep **1 copy offsite**
In Zerobyte, that usually looks like this:
- Your live data on a server, NAS, or workstation
- A primary backup repository on fast local storage
- A mirrored repository on an offsite backend such as S3, Cloudflare R2, Google Cloud Storage, Azure Blob Storage, SFTP, or a remote REST server
## Why 3-2-1 is a good strategy
A single backup copy can still fail for the same reason as the original data. The 3-2-1 rule reduces correlated failure, so one bad delete, dead disk, provider issue, or site-level incident is less likely to wipe out every copy at once.
| Failure scenario | What goes wrong without 3-2-1 | How 3-2-1 helps |
| --- | --- | --- |
| Accidental deletion or bad change | Your only backup may be too recent or incomplete | Older snapshots give you a clean restore point |
| Disk or NAS failure | Data and backup can be lost together | A second storage system still has the data |
| Ransomware or host compromise | Local storage may be affected too | An offsite copy gives you another recovery path |
| Fire, theft, or power event | Everything in one building can disappear together | The offsite copy survives |
| Provider or account problem | One backend outage blocks restore | Different backends reduce that single point of failure |
Zerobyte fits this strategy well because it already gives you the building blocks:
- **Encrypted repositories** so offsite copies stay private
- **Incremental, deduplicated snapshots** so keeping multiple copies is more storage-efficient
- **Mirror repositories** so one backup job can copy snapshots to additional destinations
- **Browsable restores** so you can test recovery without dropping to the CLI
## How 3-2-1 maps to Zerobyte
| 3-2-1 element | Zerobyte example |
| --- | --- |
| **3 copies** | Live data + primary repository + mirror repository |
| **2 different storage systems** | Local disk or NAS for the primary copy, object storage or remote server for the second copy |
| **1 offsite copy** | A repository in another building, region, or provider |
<Callout type="info">
Historically, the "2" in 3-2-1 meant two different kinds of media. In modern setups, different storage systems and failure domains matter more than literal media type. A local disk plus cloud object storage is a strong practical interpretation.
</Callout>
## How to do it with Zerobyte
<Steps>
<Step>
### Create the volume for your live data
Add the directory or remote share you want to protect as a [volume](/docs/concepts/volumes).
</Step>
<Step>
### Create a primary repository for fast restores
Use a backend that is quick and close to the source, such as a local disk, mounted NAS path, nearby REST server, or nearby SFTP host. This gives you a fast day-to-day restore target.
</Step>
<Step>
### Create a second repository in a different failure domain
Create another repository on an offsite backend such as S3-compatible storage, Cloudflare R2, Google Cloud Storage, Azure Blob Storage, or a remote SFTP or REST server in another location.
If you want a strong "2" in 3-2-1, avoid putting both repositories on the same host, in the same rack, or behind the same provider account.
</Step>
<Step>
### Create the backup job and add the offsite copy as a mirror
Set the primary repository as the main backup repository, then configure the offsite repository as a mirror repository. After each successful backup, Zerobyte copies that snapshot to the mirror repository automatically.
</Step>
<Step>
### Add a schedule and retention policy
Pick a schedule that matches how often the data changes. A solid starting point for many workloads is:
- Schedule: `0 2 * * *` (daily at 2:00 AM)
- Keep Daily: `7`
- Keep Weekly: `4`
- Keep Monthly: `6`
- Keep Yearly: `1`
Adjust this based on your recovery needs and storage budget.
</Step>
<Step>
### Store the recovery key outside Zerobyte
Keep the organization's recovery key in a password manager, secret vault, or other secure place outside the Zerobyte server. The offsite copy only helps if you can still decrypt it during an outage.
</Step>
<Step>
### Run a manual backup and test restore
Use **Backup now** to create the first snapshot, then test a restore to a non-production path. A backup strategy is only real once you have verified that recovery works.
</Step>
</Steps>
## Example Zerobyte layout
| Copy | Example |
| --- | --- |
| Live data | `/srv/data` on your server |
| Local backup copy | Local repository on `/mnt/backup-disk/zerobyte` |
| Offsite backup copy | Cloudflare R2 bucket or remote SFTP repository |
One backup job can then write to the local repository and mirror each successful snapshot to the offsite repository.
## Common mistakes
- Keeping the live data and primary repository on the same physical disk
- Calling a second copy "offsite" when it is still in the same building
- Storing every copy under the same provider account without any separation
- Forgetting to protect the recovery key or imported repository password
- Never testing restores
## Related docs
- [Quickstart](/docs/quickstart)
- [Backups](/docs/concepts/backups)
- [Repositories](/docs/concepts/repositories)
- [Recovery keys and repository passwords](/docs/guides/recovery-key-and-repository-passwords)
import { Step, Steps } from "fumadocs-ui/components/steps";
import { Callout } from "fumadocs-ui/components/callout";

View file

@ -0,0 +1,14 @@
{
"title": "Guides",
"pages": [
"3-2-1-backup-strategy",
"notifications",
"recovery-key-and-repository-passwords",
"repository-maintenance",
"oidc-sso",
"provisioning",
"reverse-proxy",
"tailscale",
"rclone"
]
}

View file

@ -0,0 +1,275 @@
---
title: Notifications
description: Create notification destinations, route backup events, and choose the right delivery type
---
Zerobyte's notification system has two layers:
1. A **destination** is a reusable delivery channel such as Slack, SMTP, ntfy, or a webhook.
2. A **backup schedule assignment** decides which events should be sent to which destination.
You create destinations under **Notifications**. You route them per backup schedule from the backup details page.
## How notifications work in Zerobyte
- Destinations are organization-scoped.
- Sensitive destination fields are encrypted before storage.
- Disabled destinations stay saved, but Zerobyte skips them when sending notifications.
- The **Test** button sends a generic test message for that destination.
- Per-schedule routing supports four events: `start`, `success`, `warning`, and `failure`.
<Callout type="info">
Create destinations once, then reuse them across as many backup schedules as you want.
</Callout>
## Supported destination types
| Type | Best for | Key fields |
| --- | --- | --- |
| `Email (SMTP)` | Existing mail infrastructure, multiple recipients, ticketing aliases | SMTP host, port, sender, recipients, optional auth, TLS |
| `Slack` | Team chat channels | Webhook URL, optional bot username, optional icon emoji |
| `Discord` | Server channels or threads | Webhook URL, optional username, optional avatar URL, optional thread ID |
| `Gotify` | Self-hosted mobile or desktop push | Server URL, app token, priority, optional path |
| `ntfy` | Lightweight publish-subscribe notifications | Topic, optional server URL, optional auth, priority |
| `Pushover` | Personal mobile alerts | User key, API token, optional devices, priority |
| `Telegram` | Group or direct chat notifications | Bot token, chat ID, optional thread ID |
| `Generic Webhook` | Arbitrary HTTP endpoints and automation systems | URL, method, headers, content type, optional JSON template keys |
| `Custom (Shoutrrr URL)` | Power users or services not exposed by the built-in forms | Raw Shoutrrr URL |
## Which type should I use?
- Choose **Email** when you already trust an SMTP relay and want easy fan-out to multiple recipients.
- Choose **Slack** or **Discord** when backup events belong in a shared team room.
- Choose **Gotify** or **ntfy** when you prefer self-hosted push.
- Choose **Pushover** or **Telegram** when you want fast personal alerts on a phone.
- Choose **Generic Webhook** when another system expects a normal HTTP request.
- Choose **Custom (Shoutrrr URL)** when the built-in types are close but not flexible enough, or when you need a different Shoutrrr-supported service entirely.
## Create a destination
1. Open **Notifications** in the sidebar.
2. Click **Create Destination**.
3. Pick the notification type.
4. Fill in the type-specific settings.
5. Save the destination.
6. Open the destination and use **Test** before attaching it to production schedules.
The type is effectively fixed after creation in the current UI, so create a new destination instead of trying to repurpose an existing one from Slack to Email or similar.
## Route notifications per backup schedule
1. Open the backup schedule you want to configure.
2. In the **Notifications** section, click **Add notification**.
3. Select an existing destination.
4. Choose which events should trigger that destination.
5. Save the backup schedule notification settings.
Each assignment is independent. One schedule can send failures to Email, warnings to Slack, and successes nowhere.
## Backup event meanings
| Event | When Zerobyte sends it | What is included |
| --- | --- | --- |
| `start` | The backup begins running | Volume name, repository name, and schedule name |
| `success` | The backup completes cleanly | Volume, repository, schedule, and backup summary details when available |
| `warning` | The backup completes with warnings instead of a clean success | Success details plus warning text when available |
| `failure` | The backup fails validation or execution | Volume, repository, schedule, and the error text |
## Type reference
### Email (SMTP)
Use Email when you want alerts to go to people, shared mailboxes, or downstream systems that already process email.
**Fields**
- `SMTP Host`
- `SMTP Port`
- `Username` and `Password` (optional)
- `From Address`
- `From Name` (optional)
- `To Addresses`
- `Use TLS`
**Notes**
- `To Addresses` is a comma-separated list in the UI.
- TLS is a simple yes or no toggle.
- SMTP authentication is optional because some relays accept trusted network senders.
**Example**
```text
Type: Email (SMTP)
SMTP Host: smtp.example.com
SMTP Port: 587
Username: alerts@example.com
Password: <smtp-password>
From Address: alerts@example.com
From Name: Zerobyte Alerts
To Addresses: ops@example.com, backups@example.com
Use TLS: On
```
### Slack
Use Slack for shared operational visibility in a team channel.
**Fields**
- `Webhook URL`
- `Bot Username` (optional)
- `Icon Emoji` (optional)
**Example**
```text
Type: Slack
Webhook URL: https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX
Bot Username: Zerobyte
Icon Emoji: :floppy_disk:
```
### Discord
Use Discord when your team lives in a server channel, or when you want to post to a specific thread.
**Fields**
- `Webhook URL`
- `Bot Username` (optional)
- `Avatar URL` (optional)
- `Thread ID` (optional)
### Gotify
Use Gotify when you want self-hosted push with explicit priority control.
**Fields**
- `Server URL`
- `App Token`
- `Priority` from `0` to `10`
- `Path` (optional)
### ntfy
Use ntfy when you want a simple topic-based push service, either on `ntfy.sh` or your own server.
**Fields**
- `Server URL` (optional, leave empty for `ntfy.sh`)
- `Topic`
- `Username` and `Password` (optional)
- `Access token` (optional)
- `Priority`
**Notes**
- If you set an access token, it takes precedence over username and password.
- Priority values are `max`, `high`, `default`, `low`, and `min`.
**Example**
```text
Type: Ntfy
Server URL: https://ntfy.example.com
Topic: zerobyte-backups
Access token: <token>
Priority: high
```
### Pushover
Use Pushover for direct, personal push notifications.
**Fields**
- `User Key`
- `API Token`
- `Devices` (optional)
- `Priority` as `-1`, `0`, or `1`
### Telegram
Use Telegram when you want notifications in a bot chat or a group topic.
**Fields**
- `Bot Token`
- `Chat ID`
- `Thread ID` (optional)
**Example**
```text
Type: Telegram
Bot Token: 123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
Chat ID: -1231234567890
Thread ID: 3
```
### Generic Webhook
Use Generic Webhook when another service expects a normal HTTP request instead of a provider-specific webhook format.
**Fields**
- `Webhook URL`
- `Method` as `GET` or `POST`
- `Content Type`
- `Headers`
- `Use JSON Template`
- `Title Key` and `Message Key` when JSON mode is enabled
**Notes**
- Headers are entered one per line as `Key: Value`.
- With JSON mode enabled, Zerobyte sends a body shaped like `{titleKey: "...", messageKey: "..."}`.
- With JSON mode disabled, the request body is plain notification text.
**Example**
```text
Type: Generic Webhook
Webhook URL: https://hooks.example.com/backup-events
Method: POST
Content Type: application/json
Headers:
Authorization: Bearer <token>
X-Source: zerobyte
Use JSON Template: On
Title Key: title
Message Key: message
```
### Custom (Shoutrrr URL)
Use Custom when you already know the exact Shoutrrr URL you want Zerobyte to use.
**Fields**
- `Shoutrrr URL`
**Example**
```text
slack://hook:T000-B000-XXX@webhook?username=Zerobyte
```
## Practical routing patterns
| Goal | Suggested routing |
| --- | --- |
| Only wake someone up for real problems | `failure` to Email or Pushover |
| Keep the team informed without noise | `warning` and `failure` to Slack or Discord |
| Audit every run | `start`, `success`, `warning`, and `failure` to Email or Generic Webhook |
| Mobile-first personal alerting | `failure` to Telegram, ntfy, Gotify, or Pushover |
| Feed another incident or workflow system | `failure` and optionally `warning` to Generic Webhook |
## Related docs
- [Backups](/docs/concepts/backups)
- [Repository maintenance and status](/docs/guides/repository-maintenance)
import { Callout } from "fumadocs-ui/components/callout";

View file

@ -0,0 +1,110 @@
---
title: SSO
description: Register OIDC providers, control access with invitations, and understand account-linking behavior
---
Zerobyte's current SSO implementation is organization-scoped and OIDC-based. You register providers under **Settings > Organization > Single Sign-On**, or directly at `/settings/sso/new`.
<Callout type="warn">
Zerobyte SSO is invite-oriented. Enabling auto-linking does **not** mean "anyone with that email can join".
</Callout>
## What Zerobyte supports
- Each SSO provider belongs to exactly one organization.
- The login page exposes registered providers as `Log in with {providerId}` buttons.
- Organization owners can register new providers.
- Organization owners and admins can manage invitations, toggle per-provider auto-linking, and delete existing providers.
- Zerobyte currently registers OIDC providers with the `openid`, `email`, and `profile` scopes.
## Required values
| Zerobyte field | What it means | Example |
| --- | --- | --- |
| `Provider ID` | Stable unique identifier for this provider. It is used in the callback URL and is what users see on the login button. | `acme-oidc` |
| `Organization Domain` | Domain recorded with the provider and used for provider discovery metadata. | `example.com` |
| `Issuer URL` | The OIDC issuer published by your identity provider. | `https://idp.example.com/realms/acme` |
| `Discovery Endpoint` | The provider's `.well-known/openid-configuration` URL. | `https://idp.example.com/.well-known/openid-configuration` |
| `Client ID` | OIDC client identifier from your IdP. | `zerobyte` |
| `Client Secret` | OIDC client secret from your IdP. | `(secret)` |
| `Link matching emails to existing accounts` | Per-provider trust switch for matching an existing Zerobyte account by email. | `Off` by default |
## Callback URL
Register this exact redirect URI in your identity provider:
```text
${BASE_URL}/api/auth/sso/callback/${providerId}
```
Example:
```text
https://backup.example.com/api/auth/sso/callback/acme-oidc
```
## Setup checklist
1. In your identity provider, create a confidential or web OIDC client for Zerobyte.
2. Register the callback URL above as an allowed redirect URI.
3. Make sure the provider returns `email` and an email-verification claim.
4. In Zerobyte, open **Settings > Organization > Single Sign-On** and click **Register new**.
5. Fill in the provider details and save.
6. Decide whether `Link matching emails to existing accounts` should be enabled for this provider.
7. Invite users before asking them to sign in, unless their existing local account already belongs to the organization.
## How access is decided
The important rule is that Zerobyte checks **organization membership** and **pending invitations** in addition to whatever your identity provider says.
| Situation | Result | Why |
| --- | --- | --- |
| Brand-new user, no membership, no invitation | Blocked | There is no valid organization membership or pending invitation. |
| Brand-new user with a pending invitation | Allowed | Zerobyte creates the membership on first successful SSO login and marks the invitation as `accepted`. |
| Existing local account already belongs to the organization, auto-linking is `off` | Blocked with an account-linking error | The provider is not trusted to auto-link by email yet. |
| Existing local account already belongs to the organization, auto-linking is `on` | Allowed | The provider is trusted for matching-email account linking inside that organization. |
| Existing local account has the same email, has a pending invitation, but is **not already a member** of the organization | Blocked with an account-linking error | Zerobyte refuses to merge an existing account that is outside the organization, even if the email matches and there is a pending invitation. |
| User was invited before, signed in once, was later removed from the organization, then tries SSO again | Blocked | The old invitation remains `accepted`; it is not reopened automatically after removal. |
In practice, an **eligible local account** means an account that already belongs to the organization you are signing into. A matching email plus a pending invitation is **not** enough to merge an unrelated existing account.
## Invitations and membership lifecycle
- Invitations are email-based, organization-specific, and role-bearing.
- A successful first SSO login for an invited user creates the membership and marks the invitation as `accepted`.
- Pending invitations must still be unexpired to work.
- Expired, cancelled, or already accepted invitations do not grant first-time access.
- If a user is removed later, create a **new** invitation before they try SSO again.
The invitation UI lets you assign `member`, `admin`, or `owner` at invite time. The accepted membership is created with that invited role.
## Auto-linking semantics
- Auto-linking is stored **per provider**, not globally.
- Turning it on for one provider does not affect other providers.
- It only applies when the IdP email matches an existing Zerobyte account.
- It does **not** grant organization membership by email alone.
- It does **not** override invite-only access for brand-new users.
- It does **not** merge a matching account that is outside the organization.
- Leaving it off is the safest default for new rollouts.
<Callout type="info">
The safest mental model is: auto-linking can help an already eligible account add SSO, but it is not an open-enrollment feature.
</Callout>
## Common failure cases
| What the user sees | Typical cause | What to do |
| --- | --- | --- |
| `Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.` | No pending invitation, invitation expired, invitation was cancelled, or the user was removed after an earlier accepted invite | Create a new pending invitation or restore membership before retrying. |
| `SSO sign-in was blocked because this email already belongs to another user in this instance.` | Zerobyte found an existing account with the same email, but that account is not eligible for auto-linking with this provider | Confirm whether the account already belongs to the organization and whether auto-linking should be enabled on this trusted provider. |
| `Your identity provider did not mark your email as verified.` | The IdP did not send a verified email claim | Fix email verification in the IdP before retrying. |
| `You have been banned from this application.` | The user is banned at the application level | Remove the ban or use a different account. |
| `SSO authentication failed. Please try again.` | Generic provider or callback failure | Check IdP redirect URI, client credentials, issuer, and discovery endpoint. |
## Related docs
- [Organizations, members, and roles](/docs/concepts/organizations)
- [Recovery keys and repository passwords](/docs/guides/recovery-key-and-repository-passwords)
import { Callout } from "fumadocs-ui/components/callout";

View file

@ -0,0 +1,156 @@
---
title: Provisioned Resources
description: Manage repositories and volumes through a configuration file with secret references
---
Zerobyte can sync operator-managed repositories and volumes from a JSON configuration file at startup. This is useful when you want credentials and connection details to live in deployment-time configuration (environment variables, Docker secrets) instead of being entered through the UI.
## Why Use Provisioning?
- **Infrastructure as code**, define repositories and volumes in version-controlled config files
- **Secret management**, keep credentials in environment variables or Docker secrets, not in the UI
- **Easy rotation**, rotate secrets by updating env vars or secret files and restarting
- **Consistent deployments**, use the same configuration across staging and production
Provisioned resources appear in the normal UI alongside manually created ones, marked as managed entries.
## Prerequisites
- A running Zerobyte instance with an organization ID (found in Settings after first-run setup)
- The `PROVISIONING_PATH` environment variable pointing to your JSON file
## Setup
<Steps>
<Step>
### Create the provisioning file
Create a `provisioning.json` file:
```json provisioning.json
{
"version": 1,
"repositories": [
{
"id": "aws-prod",
"organizationId": "your-organization-id",
"name": "AWS Production Backups",
"backend": "s3",
"compressionMode": "auto",
"config": {
"backend": "s3",
"endpoint": "https://s3.amazonaws.com",
"bucket": "company-backups",
"accessKeyId": "env://AWS_ACCESS_KEY_ID",
"secretAccessKey": "file://aws_secret_access_key"
}
}
],
"volumes": [
{
"id": "webdav-team-a",
"organizationId": "your-organization-id",
"name": "Team A WebDAV",
"backend": "webdav",
"config": {
"backend": "webdav",
"server": "cloud.example.com",
"path": "/team-a",
"username": "team-a",
"password": "env://WEBDAV_PASSWORD",
"port": 443,
"ssl": true
}
}
]
}
```
</Step>
<Step>
### Configure docker-compose.yml
Mount the provisioning file and set the environment variable:
```yaml docker-compose.yml
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:latest
environment:
- PROVISIONING_PATH=/config/provisioning.json
- AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
- WEBDAV_PASSWORD=your-webdav-password
volumes:
- ./provisioning.json:/config/provisioning.json:ro
- /var/lib/zerobyte:/var/lib/zerobyte
secrets:
- aws_secret_access_key
secrets:
aws_secret_access_key:
file: ./secrets/aws_secret_access_key
```
</Step>
<Step>
### Start or restart Zerobyte
```bash
docker compose up -d
```
On startup, Zerobyte reads the provisioning file, resolves all secret references, encrypts the resolved values, and syncs the resources into the database.
</Step>
</Steps>
## Secret References
Provisioned resources support two types of secret references for sensitive fields (passwords, access keys, etc.):
| Reference | Resolves From | Example |
|-----------|--------------|---------|
| `env://VARIABLE_NAME` | Container environment variable | `env://AWS_ACCESS_KEY_ID` |
| `file://secret_name` | `/run/secrets/secret_name` (Docker secrets) | `file://aws_secret_access_key` |
<Callout type="info">
`file://` references always resolve from `/run/secrets/` and must be a single filename, not a nested path.
</Callout>
Resolved values are encrypted before Zerobyte stores them in the database, and the plaintext never persists on disk.
## Rotating Secrets
To rotate a secret:
1. Update the environment variable or secret file with the new value
2. Restart the Zerobyte container: `docker compose restart`
Zerobyte re-resolves all secret references on each startup.
## Removing Provisioned Resources
To remove a provisioned resource, add `"delete": true` to the entry in your provisioning file, then restart:
```json
{
"id": "aws-prod",
"delete": true
}
```
## Important Notes
<Callout type="warn">
Each provisioned entry must reference an existing `organizationId`. Complete first-run setup before enabling provisioning so you have a valid organization ID.
</Callout>
- Changes to `provisioning.json` only apply on container restart
- Each entry needs both a top-level `backend` and the matching `config.backend` field
- Provisioned resources can be viewed in the UI but credential fields managed by provisioning will be re-synced from the config file on restart
import { Step, Steps } from "fumadocs-ui/components/steps";
import { Callout } from "fumadocs-ui/components/callout";

View file

@ -0,0 +1,315 @@
---
title: Rclone Integration
description: Use rclone with Zerobyte to connect to 40+ cloud storage providers for repositories and volumes
---
Rclone is a command-line tool that connects to over 40 cloud storage providers, including Google Drive, Dropbox, OneDrive, Box, pCloud, Mega, and many more. Zerobyte leverages rclone in two ways:
- **Repositories**, store your encrypted backups on any rclone-supported cloud provider
- **Volumes**, mount cloud storage as a data source so you can back it up
This guide covers installing rclone, connecting it to Zerobyte, and using it for both repositories and volumes.
## Setting up rclone
Before Zerobyte can use rclone, you need to install and configure it on your host machine.
<Steps>
<Step>
### Install rclone
Install rclone on your host system:
```bash
curl https://rclone.org/install.sh | sudo bash
```
Alternatively, visit [rclone.org/install](https://rclone.org/install/) for platform-specific packages.
</Step>
<Step>
### Configure a remote
Run the interactive configuration wizard:
```bash
rclone config
```
Follow the prompts to add a new remote. Each provider has its own setup flow, and rclone will walk you through authentication, API keys, or OAuth as needed.
</Step>
<Step>
### Verify your remotes
List all configured remotes:
```bash
rclone listremotes
```
</Step>
<Step>
### Test the connection
List the top-level directories on your remote to confirm everything works:
```bash
rclone lsd myremote:
```
Replace `myremote` with the name you chose during configuration.
</Step>
</Steps>
## Mounting rclone config into the container
Zerobyte needs access to your rclone configuration file to discover and use your remotes. Mount it as a read-only volume in your `docker-compose.yml`:
```yaml docker-compose.yml
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
- ~/.config/rclone:/root/.config/rclone:ro
```
Then restart the container to pick up the change:
```bash
docker compose down && docker compose up -d
```
<Callout type="info">
For non-root container environments (e.g., TrueNAS), the config must be mounted to the correct user home directory. Set the `RCLONE_CONFIG_DIR` environment variable accordingly:
```yaml docker-compose.yml
environment:
- RCLONE_CONFIG_DIR=/home/appuser/.config/rclone
volumes:
- ~/.config/rclone:/home/appuser/.config/rclone:ro
```
</Callout>
## Using rclone for repositories
Rclone repositories let you store encrypted backups on any rclone-supported provider.
<Steps>
<Step>
### Open the Create Repository form
Navigate to **Repositories** in the sidebar and click **Create Repository**.
</Step>
<Step>
### Select rclone as the type
Choose **rclone** from the repository type dropdown.
</Step>
<Step>
### Choose your remote
Select your remote from the dropdown list. Zerobyte reads all configured remotes from the mounted rclone config.
</Step>
<Step>
### Specify a path
Enter a path within the remote where backups should be stored (e.g., `backups/zerobyte`).
</Step>
<Step>
### Create the repository
Click **Create and Initialize**. Zerobyte will initialize a new encrypted Restic repository on the remote.
</Step>
</Steps>
<Callout type="warn">
Consumer cloud services like Google Drive and Dropbox have API rate limits and are not designed for heavy I/O workloads. They work for personal or light-duty backups, but for production or critical data, use proper object storage such as S3, Backblaze B2, or Cloudflare R2.
</Callout>
## Using rclone for volumes
Rclone volumes mount cloud storage as a filesystem inside the container, allowing you to back up data stored in the cloud. This requires elevated container capabilities.
<Steps>
<Step>
### Ensure FUSE support
Your `docker-compose.yml` must include `SYS_ADMIN` and `/dev/fuse`:
```yaml docker-compose.yml
cap_add:
- SYS_ADMIN
devices:
- /dev/fuse:/dev/fuse
```
</Step>
<Step>
### Create a new volume
Navigate to **Volumes** in the sidebar and click **Create Volume**.
</Step>
<Step>
### Select rclone as the type
Choose **rclone** from the volume type dropdown.
</Step>
<Step>
### Choose the remote and path
Select your remote from the dropdown and specify the path within the remote you want to mount (e.g., `/` for the entire remote, or `/photos` for a subdirectory).
</Step>
<Step>
### Save and mount
Click **Create**. Zerobyte will mount the remote as a FUSE filesystem and make it available for backup jobs.
</Step>
</Steps>
<Callout type="warn">
Rclone volumes are **Linux-only**. Docker on macOS and Windows does not support FUSE mounts inside containers. If you are running Docker Desktop on macOS or Windows, rclone volumes will not work.
</Callout>
## SFTP remotes with SSH keys
If your rclone remote uses SFTP with `key_file` authentication, the path in the rclone config points to a location on the host filesystem, which is not accessible inside the container. There are three ways to solve this.
<Tabs items={["Mount SSH keys", "Embed key in config", "SSH agent"]}>
<Tab value="Mount SSH keys">
**Recommended.** Mount your SSH keys into the container so rclone can find them at the expected path:
```yaml docker-compose.yml
volumes:
- ~/.ssh:/root/.ssh:ro
```
Make sure the `key_file` path in your rclone config matches the container path (e.g., `/root/.ssh/id_rsa`).
</Tab>
<Tab value="Embed key in config">
Instead of referencing a file with `key_file`, embed the private key directly in your rclone config using the `key_pem` field. Run `rclone config` on your host and choose the option to paste the key inline.
This avoids any file path issues since the key is stored directly in the rclone configuration file.
</Tab>
<Tab value="SSH agent">
Forward your host's SSH agent socket into the container:
```yaml docker-compose.yml
environment:
- SSH_AUTH_SOCK=/ssh-agent
volumes:
- ${SSH_AUTH_SOCK}:/ssh-agent
```
This allows rclone to authenticate using keys loaded into your host's SSH agent without exposing private key files.
</Tab>
</Tabs>
## Refreshing OAuth tokens
Rclone remotes that use OAuth (Google Drive, Dropbox, OneDrive, Box, and others) store authentication tokens in the rclone config file. These tokens expire periodically and need to be refreshed.
<Steps>
<Step>
### Re-authenticate on the host
Run the rclone config command on your host machine and follow the prompts to re-authorize the remote:
```bash
rclone config reconnect myremote:
```
</Step>
<Step>
### Restart the container
After refreshing the token, restart the Zerobyte container so it picks up the updated config:
```bash
docker compose restart
```
</Step>
</Steps>
<Callout type="info">
If you mount the rclone config as a read-only volume (`:ro`), the updated tokens on the host are immediately visible to the container after a restart. No need to copy files manually.
</Callout>
## Troubleshooting
### No remotes available
If the rclone remote dropdown is empty when creating a repository or volume:
- Verify the rclone config is mounted correctly. Check that `~/.config/rclone/rclone.conf` exists on the host.
- Confirm the mount path in `docker-compose.yml` matches the expected location inside the container (`/root/.config/rclone` by default, or the path set in `RCLONE_CONFIG_DIR`).
- Restart the container after updating the volume mount.
### Failed to create file system
This usually means rclone cannot authenticate with the remote:
- For OAuth remotes (Google Drive, Dropbox, OneDrive), the token may have expired. Re-authenticate with `rclone config reconnect myremote:` on the host and restart the container.
- For key-based remotes (S3, SFTP), verify that credentials in the rclone config are correct.
- Test the remote directly on the host: `rclone lsd myremote:` to isolate whether the issue is with rclone itself or with Zerobyte.
### FUSE mount errors for rclone volumes
If you see errors related to FUSE when creating rclone volumes:
- Ensure your `docker-compose.yml` includes `cap_add: [SYS_ADMIN]` and `devices: [/dev/fuse:/dev/fuse]`.
- Confirm you are running on a Linux host. FUSE mounts do not work with Docker on macOS or Windows.
- Check that `/dev/fuse` exists on the host: `ls -la /dev/fuse`.
<Callout type="info">
For more detailed troubleshooting steps, see the [Troubleshooting](/docs/troubleshooting) page.
</Callout>
import { Step, Steps } from "fumadocs-ui/components/steps";
import { Tab, Tabs } from "fumadocs-ui/components/tabs";

View file

@ -0,0 +1,110 @@
---
title: Recovery Keys
description: Understand the organization recovery key, imported repositories, and custom repository passwords
---
Zerobyte separates **account authentication** from **Restic repository encryption**. Your login password, your organization's recovery key, any imported repository password, and `APP_SECRET` all serve different purposes.
## The important secrets in Zerobyte
| Secret | What it is for | Where it applies | Downloadable from the UI? |
| --- | --- | --- | --- |
| User password | Signing in to Zerobyte and confirming sensitive actions | Your user account | No |
| Recovery key (`restic.pass`) | Opening repositories that use the organization's default Restic password | The active organization | Yes, for org owners and admins after password re-authentication |
| Custom repository password | Opening an imported repository that uses a different Restic password | One imported repository | No separate download flow |
| `APP_SECRET` | Encrypting secrets that Zerobyte stores in the database | The application deployment | No |
## What the recovery key actually is
The downloaded `restic.pass` file contains the **Restic password for the active organization**.
That organization-level password is what Zerobyte uses when it:
- Initializes a brand-new repository from the UI
- Accesses a repository that relies on the organization's default password
- Runs normal backup, restore, snapshot, doctor, and stats operations for those repositories
<Callout type="warn">
Store the recovery key outside Zerobyte in a password manager, secret vault, or other encrypted storage. If you lose both server access and the correct Restic password, your backups are not recoverable.
</Callout>
## First-run behavior for local users
During first-run onboarding, the first local admin is created with a flag indicating they have **not** downloaded the recovery key yet.
Until that flag is cleared, Zerobyte redirects the user to `/download-recovery-key` instead of the normal app pages.
When the user downloads the file successfully:
- Zerobyte verifies the user's current password
- The response returns the plaintext `restic.pass` file
- Zerobyte marks that user as having downloaded the recovery key
After that, future logins go straight to the app.
## SSO behavior
SSO-created users are marked as having already downloaded the recovery key, so they are **not** forced through the recovery-key download screen during first login.
That means SSO rollouts should treat recovery key storage as an organization-admin responsibility. Ordinary SSO users are not individually prompted to save the key.
## Who can download the recovery key
The current download endpoint requires all of the following:
- You are signed in
- Your active organization role is `admin` or `owner`
- You re-enter your current account password
The downloaded filename is `restic.pass`.
## New repositories vs imported repositories
| Repository scenario | Password Zerobyte uses | What to choose in the UI |
| --- | --- | --- |
| New repository created in Zerobyte | The active organization's recovery key | No extra password choice is shown |
| Existing repository that already uses the active organization's recovery key | The active organization's recovery key | `Import existing repository` + `Use the existing recovery key` |
| Existing repository created elsewhere with a different Restic password | A per-repository custom password | `Import existing repository` + `Enter password manually` |
### Important limitation
The custom password option only exists when **Import existing repository** is enabled. Brand-new repositories created by Zerobyte always use the active organization's recovery key.
## How Zerobyte decides which password to use
Zerobyte's runtime password selection is simple:
1. If the repository is imported **and** has a stored custom password, use that custom password.
2. Otherwise, use the active organization's recovery key.
This has an important consequence: downloading the organization recovery key will **not** help with an imported repository that is configured with its own custom password.
## Choosing the right import option
Choose **Use the existing recovery key** only when the repository already uses the same Restic password as the active organization.
Typical examples:
- A repository previously created by the same Zerobyte organization
- A migrated environment where the organization password was preserved
Choose **Enter password manually** when the repository was initialized outside this organization, for example:
- A repository created with the Restic CLI
- A repository created by another Zerobyte organization
- A repository created by another tool that happened to use Restic
## Practical guidance
- Keep a secure inventory of which imported repositories use custom passwords.
- Do not assume all repositories in the same Zerobyte instance share the same password model.
- If you manage multiple organizations, remember that the downloaded recovery key is for the **active** organization.
- Test restores using the same password model the repository actually uses.
- Treat `APP_SECRET` and the recovery key as separate operational secrets. Losing one is not the same as losing the other.
## Related docs
- [Repositories](/docs/concepts/repositories)
- [Repository maintenance and status](/docs/guides/repository-maintenance)
import { Callout } from "fumadocs-ui/components/callout";

View file

@ -0,0 +1,124 @@
---
title: Repository Maintenance
description: Interpret repository health states and use Doctor, Unlock, and stats refresh correctly
---
The repository details page exposes a small set of maintenance tools, but they do different jobs. This guide explains what each status means and what the built-in actions actually do.
## Repository status values
| Status | What it means in Zerobyte | What to do next |
| --- | --- | --- |
| `healthy` | The last stored health verdict finished without errors | Keep monitoring and refresh stats when you want updated size metrics |
| `error` | The last health or doctor run recorded an error | Review **Last Error** and the **Doctor Report**, fix the underlying issue, then rerun maintenance if needed |
| `unknown` | Zerobyte does not currently have a fresh health verdict, or repository settings were changed and the old verdict was cleared | Expect this after some config changes; verify access and consider running Doctor |
| `doctor` | A Doctor run is in progress | Wait for it to finish or cancel it during a maintenance window |
| `cancelled` | A Doctor run was aborted before completion | Review any partial Doctor output and rerun Doctor if you still need validation |
Repository status is stored metadata inside Zerobyte. It is not a continuous live probe.
## What each action really does
| Action | What it does | What it does **not** do |
| --- | --- | --- |
| `Run doctor` | Runs `unlock`, then `check`, then `repair-index` if restic says it is needed. Saves step output into the Doctor Report. | It does **not** run a full `check --read-data` scrub, it does **not** prune unused data, and it does **not** refresh the stats card. |
| `Unlock` | Runs `restic unlock --remove-all` to clear stale repository locks. | It does **not** validate repository integrity and it does **not** change the stored health status by itself. |
| Stats refresh button | Runs `restic stats --mode raw-data` and updates stored size and snapshot metrics. | It does **not** change repository health status, **Last Checked**, or the Doctor Report. |
## Run doctor in detail
Doctor is the deepest repository-maintenance action currently exposed in the normal UI.
During a Doctor run, Zerobyte:
1. Sets the repository status to `doctor`
2. Acquires an exclusive repository lock inside the app
3. Runs an unlock step
4. Runs `restic check`
5. Runs `restic repair-index` only if the check output suggests that repair
6. Saves the step-by-step result and updates the final status to `healthy`, `error`, or `cancelled`
Because Doctor holds an exclusive repository lock, it can block other repository operations such as backups, restores, and snapshot mutations while it runs.
<Callout type="warn">
Doctor is helpful, but it is lighter than a full offline integrity scrub. Today it does not read every stored data pack and it does not prune repository data. Treat `healthy` as a useful application-level signal, not as proof that every blob was recently re-read.
</Callout>
## When to use Run doctor
- The repository status is `error`
- **Last Error** suggests metadata or index problems
- Operations keep failing after a crash or interrupted run
- You changed repository settings and want a fresh maintenance pass
- You want a stored Doctor Report before or after backend maintenance work
## When to use Unlock
Use **Unlock** when the problem is specifically stale repository locks, for example after:
- A crashed backup or restore
- A forced container restart
- A cancelled or interrupted maintenance run
- A Restic message that says the repository is locked
If the problem is not a lock issue, Unlock will not fix it.
<Callout type="warn">
Only run Unlock when you are certain no real backup, restore, or maintenance operation is still using that repository.
</Callout>
## What stats refresh actually recalculates
The refresh button in **Compression Statistics** recalculates repository metrics using `restic stats --mode raw-data`, including:
- Stored size
- Uncompressed size
- Compression ratio
- Compression progress
- Compression space saving
- Snapshot count
This is useful when:
- The stats card is still empty after connecting an existing repository
- You deleted snapshots and want updated numbers
- Retention cleanup changed repository usage
- You want fresh size numbers without running Doctor
## Reading the Doctor Report
The **Doctor Report** stores each maintenance step with its output and any error text. In current builds, the steps you will typically see are:
- `unlock`
- `check`
- `repair_index` when suggested by restic
If a Doctor run is cancelled, the report can still contain partial step output from the work that completed before cancellation.
## Practical workflows
### Repository is locked after a crash
1. Use **Unlock**.
2. Retry the blocked backup or restore.
3. If problems continue, run **Doctor**.
### Status is `unknown`
1. Check whether you recently edited repository settings.
2. Use the stats refresh button if you only need updated size numbers.
3. Run **Doctor** if you want a fresh maintenance verdict.
### Status is `error`
1. Read **Last Error**.
2. Expand the **Doctor Report** and identify the failing step.
3. Fix the real problem first, such as credentials, backend reachability, or repository corruption.
4. Run **Doctor** again after the fix.
## Related docs
- [Repositories](/docs/concepts/repositories)
- [Recovery keys and repository passwords](/docs/guides/recovery-key-and-repository-passwords)
import { Callout } from "fumadocs-ui/components/callout";

View file

@ -0,0 +1,193 @@
---
title: Reverse Proxy
description: Run Zerobyte behind Nginx, Caddy, or Traefik with HTTPS
---
When running Zerobyte behind a reverse proxy, you need to configure the `BASE_URL` environment variable with your HTTPS domain and ensure your proxy passes the correct headers.
## Prerequisites
Set the following environment variables in your `docker-compose.yml`:
```yaml docker-compose.yml
environment:
- BASE_URL=https://zerobyte.example.com
- TRUST_PROXY=true # Optional: trust X-Forwarded-For headers from your proxy
```
Restart the container after making changes:
```bash
docker compose down && docker compose up -d
```
## How BASE_URL affects cookie security
Zerobyte uses `BASE_URL` to determine whether authentication cookies should be marked as `Secure`. This directly affects how login sessions work:
| BASE_URL value | Cookie behavior |
|---|---|
| `http://` or IP address (e.g., `http://192.168.1.100:4096`) | Secure cookies **disabled**, cookies sent over HTTP. Suitable for local network access. |
| `https://` with domain (e.g., `https://zerobyte.example.com`) | Secure cookies **enabled**, cookies only sent over HTTPS. Required for production. |
<Callout type="warn">
If `BASE_URL` is set to an `https://` address, browsers will **only** send authentication cookies over HTTPS connections. Accessing Zerobyte over plain HTTP will fail with login loops or session errors. Make sure your reverse proxy terminates TLS before forwarding to Zerobyte.
</Callout>
## Proxy configurations
<Tabs items={["Nginx", "Caddy", "Traefik"]}>
<Tab value="Nginx">
### Nginx
Create a server block for Zerobyte. This configuration handles TLS termination and forwards requests to the container:
```nginx nginx.conf
server {
listen 443 ssl http2;
server_name zerobyte.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:4096;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Reload Nginx after saving:
```bash
sudo nginx -t && sudo systemctl reload nginx
```
</Tab>
<Tab value="Caddy">
### Caddy
Caddy automatically provisions and renews TLS certificates from Let's Encrypt. Add this to your `Caddyfile`:
```text Caddyfile
zerobyte.example.com {
reverse_proxy localhost:4096
}
```
Reload Caddy after saving:
```bash
sudo systemctl reload caddy
```
Caddy automatically handles `X-Forwarded-For`, `X-Forwarded-Proto`, and TLS, with no extra configuration needed.
</Tab>
<Tab value="Traefik">
### Traefik
If you run Traefik as your reverse proxy, add labels to the Zerobyte service in your `docker-compose.yml`:
```yaml docker-compose.yml
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.29
container_name: zerobyte
restart: unless-stopped
environment:
- BASE_URL=https://zerobyte.example.com
- APP_SECRET=your-secret-here
labels:
- "traefik.enable=true"
- "traefik.http.routers.zerobyte.rule=Host(`zerobyte.example.com`)"
- "traefik.http.routers.zerobyte.entrypoints=websecure"
- "traefik.http.routers.zerobyte.tls.certresolver=letsencrypt"
- "traefik.http.services.zerobyte.loadbalancer.server.port=4096"
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
```
Make sure your Traefik instance is configured with a `websecure` entrypoint and a `letsencrypt` certificate resolver.
</Tab>
</Tabs>
## Binding to localhost
If you are using a reverse proxy on the same machine, bind the Zerobyte port to `127.0.0.1` so it is not directly accessible from the network:
```yaml docker-compose.yml
ports:
- "127.0.0.1:4096:4096"
```
This ensures all traffic goes through your reverse proxy, which handles TLS and authentication headers.
## Security considerations
<Callout type="warn">
**Exposing Zerobyte to the internet requires care.** Even behind a reverse proxy with HTTPS, make sure you:
- Bind the container port to localhost only (`127.0.0.1:4096:4096`) so it cannot be accessed directly.
- Use a strong `APP_SECRET` (generated with `openssl rand -hex 32`).
- Keep Zerobyte updated to the latest version.
- Consider placing Zerobyte behind a secure tunnel (Cloudflare Tunnel, Tailscale, WireGuard) for an additional layer of protection.
</Callout>
### A note on TRUSTED_ORIGINS
The `TRUSTED_ORIGINS` environment variable allows you to add additional CORS origins for cross-origin requests. It does **not** make HTTP work when `BASE_URL` is set to HTTPS. If your `BASE_URL` uses `https://`, all access must go through HTTPS regardless of what is listed in `TRUSTED_ORIGINS`.
```yaml
# TRUSTED_ORIGINS only adds allowed CORS origins, it does NOT downgrade HTTPS to HTTP
environment:
- BASE_URL=https://zerobyte.example.com
- TRUSTED_ORIGINS=https://other-app.example.com
```
## Verifying your setup
After configuring your reverse proxy:
<Steps>
<Step>
### Test HTTPS access
Open `https://zerobyte.example.com` in your browser. You should see the Zerobyte login page with a valid TLS certificate.
</Step>
<Step>
### Check headers
Verify that your proxy is forwarding the correct headers. In the Zerobyte container logs, you should see requests coming from your proxy:
```bash
docker compose logs -f zerobyte
```
</Step>
<Step>
### Confirm login works
Log in with your admin credentials. If login fails or loops, double-check that `BASE_URL` matches the URL you are accessing and that cookies are being set correctly over HTTPS.
</Step>
</Steps>
import { Step, Steps } from "fumadocs-ui/components/steps";
import { Tab, Tabs } from "fumadocs-ui/components/tabs";

View file

@ -0,0 +1,146 @@
---
title: Tailscale Sidecar
description: Run Zerobyte behind a Tailscale VPN for secure remote access
---
Run Zerobyte behind a Tailscale sidecar container so the web UI is accessible over your private tailnet without exposing ports to the public internet.
## What is Tailscale?
[Tailscale](https://tailscale.com/) is a mesh VPN built on WireGuard. It connects devices and containers into a private network ("tailnet") without opening inbound ports on your router.
In this setup, Tailscale acts as a secure access layer in front of Zerobyte:
- You reach Zerobyte using the node's tailnet IP or MagicDNS name
- Access can be restricted using Tailscale ACLs and tags
- No port forwarding or public exposure required
## Prerequisites
- Docker and Docker Compose
- A [Tailscale account](https://tailscale.com/) and an [auth key](https://login.tailscale.com/admin/settings/keys)
## Setup
This example uses a sidecar networking pattern where Zerobyte shares the Tailscale container's network namespace.
<Steps>
<Step>
### Create docker-compose.yml
```yaml docker-compose.yml
services:
tailscale:
image: tailscale/tailscale:stable
container_name: zerobyte-tailscale
hostname: ${TS_HOSTNAME:-zerobyte}
restart: unless-stopped
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
- TS_AUTHKEY=${TS_AUTHKEY}
- TS_STATE_DIR=/var/lib/tailscale
- TS_USERSPACE=${TS_USERSPACE:-false}
- TS_EXTRA_ARGS=${TS_EXTRA_ARGS:-}
volumes:
- /var/lib/tailscale:/var/lib/tailscale
# Remove this section to make Zerobyte only accessible via Tailscale
ports:
- "4096:4096"
zerobyte:
image: ghcr.io/nicotsx/zerobyte:latest
container_name: zerobyte
restart: unless-stopped
# Uncomment if you need remote mounts (NFS/SMB/WebDAV):
# cap_add:
# - SYS_ADMIN
# devices:
# - /dev/fuse:/dev/fuse
network_mode: service:tailscale
depends_on:
- tailscale
environment:
- TZ=${TZ:-UTC}
- BASE_URL=http://${TS_HOSTNAME:-zerobyte}:4096
- APP_SECRET=${APP_SECRET}
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
```
</Step>
<Step>
### Create .env file
```bash .env
TS_AUTHKEY=tskey-auth-xxxxx
TS_HOSTNAME=zerobyte
APP_SECRET=your-secret-here # Generate with: openssl rand -hex 32
TZ=UTC
# Optional:
# TS_EXTRA_ARGS=--advertise-tags=tag:backup
# TS_USERSPACE=false
```
</Step>
<Step>
### Start the stack
```bash
docker compose up -d
```
</Step>
<Step>
### Verify in Tailscale
Confirm the node appears in your [Tailscale admin console](https://login.tailscale.com/admin/machines). Approve it if your policy requires approval.
</Step>
</Steps>
## Accessing Zerobyte
Once running, access Zerobyte over your tailnet:
- **By IP**: `http://<tailscale-ip>:4096`
- **By MagicDNS**: `http://zerobyte:4096` (if MagicDNS is enabled)
To make Zerobyte accessible **only** via Tailscale, remove the `ports:` section from the `tailscale` service.
## Kernel Mode vs Userspace Mode
| Mode | Requirements | Best For |
|------|-------------|----------|
| **Kernel** (default) | `/dev/net/tun`, `NET_ADMIN` | Linux servers, best performance |
| **Userspace** | No special devices | Docker Desktop, restricted hosts |
To use userspace mode:
1. Set `TS_USERSPACE=true` in your `.env`
2. Remove the `devices: /dev/net/tun` section from the tailscale service
## Tailscale-Only Access
If you want Zerobyte to be reachable **only** via Tailscale (not from the local network), remove the `ports:` section from the `tailscale` service. Zerobyte will still be able to access the internet and your LAN for backup operations, but the UI will only be accessible over Tailscale.
## Troubleshooting
- **TUN device error**: Ensure `/dev/net/tun` exists on the host. Switch to userspace mode if it doesn't.
- **Node not appearing**: Check the auth key is valid and not expired.
- **ACL issues**: Set `TS_EXTRA_ARGS=--advertise-tags=tag:backup` and configure ACLs in Tailscale admin.
Verify the tailnet address:
```bash
docker exec zerobyte-tailscale tailscale status
docker exec zerobyte-tailscale tailscale ip -4
```
import { Step, Steps } from "fumadocs-ui/components/steps";

View file

@ -0,0 +1,64 @@
---
title: Zerobyte Documentation
description: Backup automation platform built on Restic
---
Welcome to Zerobyte, a powerful backup automation platform built on [Restic](https://restic.net/).
## Getting Started
<Cards>
<Card title="Introduction" icon={<BookOpen />} href="/docs/introduction">
Learn about Zerobyte's features and capabilities
</Card>
<Card title="Installation" icon={<Download />} href="/docs/installation">
Deploy Zerobyte with Docker and Docker Compose
</Card>
<Card title="Quick Start" icon={<Rocket />} href="/docs/quickstart">
Set up your first backup in minutes
</Card>
<Card title="3-2-1 Strategy" icon={<ShieldCheck />} href="/docs/guides/3-2-1-backup-strategy">
Learn why 3-2-1 backups matter and how to set them up in Zerobyte
</Card>
</Cards>
## What is Zerobyte?
Zerobyte simplifies backup management by providing an intuitive web interface on top of Restic's powerful backup engine. Whether you're backing up local directories, NFS shares, or cloud storage, Zerobyte handles encryption, compression, scheduling, and retention policies automatically.
### Key Capabilities
- **Automated backups** with encryption, compression, and retention policies
- **Flexible scheduling** with cron-like expressions
- **Multi-protocol support** for NFS, SMB, WebDAV, SFTP, and local directories
- **Cloud storage** via S3, Google Cloud Storage, Azure Blob, and 40+ rclone backends
- **Snapshot browsing and restore** directly from the web interface
<Callout type="warn">
Zerobyte is still in version 0.x.x and is subject to major changes between versions. Core features are under active development
</Callout>
## Explore the Docs
<Cards>
<Card title="Concepts" icon={<Layers />} href="/docs/concepts/volumes">
Understand volumes, repositories, and backup jobs
</Card>
<Card title="Guides" icon={<BookMarked />} href="/docs/guides/rclone">
Step-by-step guides for rclone, reverse proxies, Tailscale, and more
</Card>
<Card title="Configuration" icon={<Settings />} href="/docs/configuration">
Environment variables and Docker configuration reference
</Card>
<Card title="Troubleshooting" icon={<LifeBuoy />} href="/docs/troubleshooting">
Solve common issues with permissions, mounts, and rclone
</Card>
</Cards>
import { BookOpen, Download, Rocket, ShieldCheck, Layers, BookMarked, Settings, LifeBuoy } from "lucide-react";

View file

@ -0,0 +1,358 @@
---
title: Installation
description: Deploy Zerobyte with Docker and Docker Compose
---
import { Check, LifeBuoy, Rocket, Settings, X } from "lucide-react";
import { Step, Steps } from "fumadocs-ui/components/steps";
Zerobyte runs as a Docker container and requires Docker and Docker Compose to be installed on your server.
## Prerequisites
<Steps>
<Step>
### Install Docker
Ensure Docker is installed on your server. Visit [docs.docker.com](https://docs.docker.com/get-docker/) for installation instructions for your platform.
</Step>
<Step>
### Install Docker Compose
Docker Compose is required for orchestration. It's included with Docker Desktop or can be installed separately on Linux servers.
</Step>
<Step>
### Check Installation
Verify your installation by running:
```bash
docker --version
docker compose version
```
</Step>
</Steps>
## Basic Installation
The standard installation includes remote mount support (NFS, SMB, WebDAV, SFTP) and requires elevated container capabilities.
### 1. Create docker-compose.yml
Create a `docker-compose.yml` file with the following configuration:
```yaml docker-compose.yml
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.30
container_name: zerobyte
restart: unless-stopped
cap_add:
- SYS_ADMIN
ports:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Zurich # Set your timezone here
- BASE_URL=http://localhost:4096 # URL you will use to access Zerobyte
- APP_SECRET=94bad46...c66e25d5c2b # Generate your own secret with `openssl rand -hex 32`
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
```
<Callout type="warn">
**Security Note**: The `SYS_ADMIN` capability and `/dev/fuse` device are required for mounting remote filesystems (NFS, SMB, WebDAV, SFTP). If you only need local directory backups, see the [Simplified Installation](#simplified-installation-no-remote-mounts) section below.
</Callout>
### 2. Configure Environment Variables
Update the environment variables in your `docker-compose.yml`:
**Generate APP_SECRET:**
```bash
openssl rand -hex 32
```
**Example Configuration:**
```yaml
environment:
- TZ=America/New_York
- BASE_URL=http://192.168.1.100:4096
- APP_SECRET=a1b2c3d4e5f6... # Output from openssl command
```
#### Required Environment Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `BASE_URL` | **Required.** The base URL where Zerobyte will be accessed. Used for cookie security and CORS. | `http://localhost:4096` or `https://zerobyte.example.com` |
| `APP_SECRET` | **Required.** A 32+ character random secret for encrypting sensitive data in the database. Generate with `openssl rand -hex 32`. | `94bad46...c66e25d5c2b` |
| `TZ` | **Recommended.** Timezone for accurate backup scheduling. | `Europe/Zurich`, `America/New_York`, `UTC` |
<Callout type="info">
The `BASE_URL` determines cookie security behavior:
* **HTTP or IP addresses**: Secure cookies disabled (allows local access)
* **HTTPS with domain**: Secure cookies enabled (required for production)
</Callout>
#### Optional Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | Port the web interface listens on inside the container | `4096` |
| `RESTIC_HOSTNAME` | Hostname used by Restic in snapshots | `zerobyte` |
| `TRUSTED_ORIGINS` | Comma-separated list of additional trusted CORS origins | (none) |
| `LOG_LEVEL` | Logging verbosity: `debug`, `info`, `warn`, `error` | `info` |
| `SERVER_IDLE_TIMEOUT` | Server idle timeout in seconds | `60` |
| `RCLONE_CONFIG_DIR` | Path to rclone config directory inside container | `/root/.config/rclone` |
### 3. Configure Volume Mounts
The essential volume mount stores Zerobyte's data:
```yaml
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
```
<Callout type="warn">
**Important**: Do not point `/var/lib/zerobyte` to a network share. This will cause permission issues and severe performance degradation. Always use local storage.
</Callout>
<Callout type="info">
**TrueNAS Users**: The `/var/lib` path is ephemeral on TrueNAS and resets during system upgrades. Instead, create a dedicated ZFS dataset:
```yaml
volumes:
- /etc/localtime:/etc/localtime:ro
- /mnt/tank/docker/zerobyte:/var/lib/zerobyte
```
This ensures your configuration, encryption keys, and database persist across upgrades.
</Callout>
### 4. Start Zerobyte
Start the container using Docker Compose:
```bash
docker compose up -d
```
Verify the container is running:
```bash
docker compose ps
docker compose logs -f zerobyte
```
### 5. Access the Web Interface
Once the container is running, access Zerobyte at the URL you specified in `BASE_URL`:
```
http://<your-server-ip>:4096
```
<Callout type="info">
On first access, you'll be prompted to create an admin account. This account will have full access to all backup management features.
</Callout>
## Simplified Installation (No Remote Mounts)
If you only need to back up locally mounted directories and don't require remote share mounting (NFS, SMB, WebDAV, SFTP), you can use a reduced-privilege deployment:
```yaml docker-compose.yml
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.30
container_name: zerobyte
restart: unless-stopped
ports:
- "4096:4096"
environment:
- TZ=Europe/Zurich
- BASE_URL=http://localhost:4096
- APP_SECRET=94bad46...c66e25d5c2b
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
- /path/to/your/directory:/mydata
```
**Trade-offs:**
<ul className="my-4 list-none space-y-2 pl-0">
<li className="flex items-start gap-2">
<Check className="mt-1.5 h-4 w-4 shrink-0 text-green-600" />
<span>Improved security by removing `SYS_ADMIN` capability</span>
</li>
<li className="flex items-start gap-2">
<Check className="mt-1.5 h-4 w-4 shrink-0 text-green-600" />
<span>Support for local directories mounted into the container</span>
</li>
<li className="flex items-start gap-2">
<Check className="mt-1.5 h-4 w-4 shrink-0 text-green-600" />
<span>All repository types still supported (local, S3, GCS, Azure, rclone)</span>
</li>
<li className="flex items-start gap-2">
<X className="mt-1.5 h-4 w-4 shrink-0 text-red-600" />
<span>Cannot mount remote shares (NFS, SMB, WebDAV, SFTP) from within Zerobyte</span>
</li>
</ul>
<Callout type="info">
If you need remote mount capabilities later, you can update your `docker-compose.yml` to add back the `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse` directives.
</Callout>
## Mounting Local Directories
To back up directories from your host system, mount them into the container:
```yaml
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
- /path/to/your/photos:/photos
- /path/to/your/documents:/documents
- /path/to/your/media:/media
```
<Callout type="info">
Use read-only mounts (`:ro`) if you want to prevent Zerobyte from modifying the source data. But you won't be able to restore files back to the original location if you use read-only mounts.
</Callout>
After adding volume mounts, restart the container:
```bash
docker compose down
docker compose up -d
```
The mounted directories will be available inside the container at the specified paths (e.g., `/photos`, `/documents`, `/media`).
## Advanced Configuration
### Using Docker Secrets for Sensitive Data
If you use provisioning, Zerobyte can resolve secrets from environment variables or Docker secret files before storing the resolved value encrypted in the database:
```yaml docker-compose.yml
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.30
container_name: zerobyte
restart: unless-stopped
cap_add:
- SYS_ADMIN
devices:
- /dev/fuse:/dev/fuse
ports:
- "4096:4096"
environment:
- TZ=Europe/Zurich
- BASE_URL=http://localhost:4096
- APP_SECRET=94bad46...c66e25d5c2b
- S3_ACCESS_KEY=your-access-key
- S3_SECRET_KEY=your-secret-key
secrets:
- smb_password
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
secrets:
smb_password:
file: ./secrets/smb_password.txt
```
When authoring a provisioning file, you can reference these secrets:
* `env://S3_SECRET_KEY` - Resolves from environment variable `S3_SECRET_KEY`
* `file://smb_password` - Resolves from `/run/secrets/smb_password`
<Callout type="info">
These references are currently resolved only during provisioning. The normal volume and repository forms in the UI expect the actual secret value. See the [Provisioning guide](/docs/guides/provisioning) for the full workflow.
</Callout>
### Mounting rclone Configuration
To use rclone-based repositories (Google Drive, Dropbox, OneDrive, etc.), mount your rclone configuration:
<Steps>
<Step>
### Configure rclone on Host
Install and configure rclone on your host system:
```bash
curl https://rclone.org/install.sh | sudo bash
rclone config
```
</Step>
<Step>
### Mount Config into Container
Update your `docker-compose.yml`:
```yaml
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
- ~/.config/rclone:/root/.config/rclone:ro
```
</Step>
<Step>
### Restart Container
```bash
docker compose down && docker compose up -d
```
</Step>
</Steps>
Your rclone remotes will now be available when creating repositories in Zerobyte.
### Reverse Proxy Setup
If you're running Zerobyte behind a reverse proxy (Nginx, Caddy, Traefik), see the [Reverse Proxy guide](/docs/guides/reverse-proxy) for full configuration examples.
## Next Steps
Now that Zerobyte is installed, proceed to the Quick Start guide to configure your first backup:
<Cards>
<Card title="Quick Start Guide" icon={<Rocket />} href="/docs/quickstart">
Set up your first volume, repository, and backup job
</Card>
<Card title="Configuration Reference" icon={<Settings />} href="/docs/configuration">
All environment variables and Docker settings
</Card>
<Card title="Troubleshooting" icon={<LifeBuoy />} href="/docs/troubleshooting">
Solve common issues with permissions, mounts, and more
</Card>
</Cards>

View file

@ -0,0 +1,137 @@
---
title: Introduction
description: Learn about Zerobyte, a powerful backup automation platform built on Restic
---
Zerobyte is a backup automation tool that helps you protect your data across multiple storage backends. Built on top of [Restic](https://restic.net/), it provides a modern web interface to schedule, manage, and monitor encrypted backups.
<Callout type="warn">
Zerobyte is still in version 0.x.x and is subject to major changes between versions. Core features are under active development
</Callout>
## Key Features
<Cards>
<Card title="Automated Backups" icon={<Clock />}>
Schedule encrypted, compressed backups with fine-grained retention policies powered by Restic
</Card>
<Card title="Flexible Scheduling" icon={<Calendar />}>
Configure automated backup jobs with cron expressions and sophisticated retention rules
</Card>
<Card title="End-to-End Encryption" icon={<Lock />}>
Your data is always protected with strong encryption at rest, ensuring security for every snapshot
</Card>
<Card title="Multi-Protocol Support" icon={<Network />}>
Back up from NFS, SMB, WebDAV, SFTP, or local directories with ease
</Card>
</Cards>
## Supported Storage
### Volume Sources (What to Back Up)
Volumes represent the data you want to protect:
- **Local directories**, directories mounted into the container
- **NFS**, Network File System shares (v3, v4, v4.1)
- **SMB/CIFS**, Windows and Samba shares
- **WebDAV**, WebDAV-compatible storage (Nextcloud, ownCloud, etc.)
- **SFTP**, SSH File Transfer Protocol servers
- **Rclone**, 40+ cloud storage providers via rclone (Google Drive, Dropbox, OneDrive, etc.)
### Repository Destinations (Where to Store Backups)
Repositories are encrypted storage locations for your backup snapshots:
- **Local directories**, store backups on local disk
- **S3-compatible storage**, Amazon S3, MinIO, Wasabi, Backblaze B2, DigitalOcean Spaces, and more
- **Cloudflare R2**, S3-compatible with zero egress fees
- **Google Cloud Storage**, Google's cloud storage service
- **Azure Blob Storage**, Microsoft Azure storage
- **SFTP**, remote servers accessible via SSH
- **REST server**, Restic REST server protocol
- **Rclone remotes**, 40+ cloud storage providers via rclone
## How It Works
Zerobyte operates on three core concepts:
<Steps>
<Step>
### Volumes
Define **volumes** as the source data you want to back up. These can be local directories, network shares, or cloud storage mounted via various protocols. Zerobyte handles mounting, health monitoring, and auto-recovery automatically.
</Step>
<Step>
### Repositories
Create **repositories** as encrypted storage destinations. Repositories use Restic's deduplication and encryption to store your backups efficiently and securely. Choose from local disks, cloud object storage, or any of 40+ rclone backends.
</Step>
<Step>
### Backup Jobs
Configure **backup jobs** that connect volumes to repositories with scheduling rules and retention policies. Jobs run automatically according to your schedule, with real-time progress monitoring in the web interface.
</Step>
</Steps>
<Callout type="info">
All backups are encrypted using Restic's encryption. Your encryption password is securely stored and never leaves your server.
</Callout>
## Use Cases
<Accordions>
<Accordion title="Home Server Backups">
Back up your home server data (photos, documents, media) to cloud storage or an external drive with automatic scheduling and retention.
</Accordion>
<Accordion title="NAS Data Protection">
Protect your NAS data by backing up critical shares to offsite cloud storage with encryption and versioning.
</Accordion>
<Accordion title="Development Server Backups">
Automatically back up development servers, databases, and configuration files to ensure quick recovery.
</Accordion>
<Accordion title="Multi-Site Backups">
Centralize backup management for multiple locations, backing up various sources to different repository types.
</Accordion>
</Accordions>
## Why Restic?
Zerobyte is built on [Restic](https://restic.net/) because it offers:
- **Fast and efficient**, incremental backups with content-defined chunking
- **Secure by design**, strong encryption and authenticated snapshots
- **Deduplication**, only unique data is stored, saving space
- **Verification**, built-in integrity checking for peace of mind
- **Cross-platform**, works across different operating systems
- **Well-maintained**, an active open-source project with regular updates
## Next Steps
<Cards>
<Card title="Installation" icon={<Download />} href="/docs/installation">
Deploy Zerobyte with Docker and Docker Compose
</Card>
<Card title="Quick Start" icon={<Rocket />} href="/docs/quickstart">
Set up your first backup in minutes
</Card>
</Cards>
import { Clock, Calendar, Lock, Network, Download, Rocket } from "lucide-react";
import { Step, Steps } from "fumadocs-ui/components/steps";
import { Accordion, Accordions } from "fumadocs-ui/components/accordion";

View file

@ -0,0 +1,15 @@
{
"title": "Documentation",
"pages": [
"introduction",
"installation",
"quickstart",
"---Concepts---",
"...concepts",
"---Guides---",
"...guides",
"---Reference---",
"configuration",
"troubleshooting"
]
}

View file

@ -0,0 +1,822 @@
---
title: Quick Start
description: Set up your first backup in minutes with Zerobyte
---
This guide walks you through creating your first backup in Zerobyte, from initial setup to verifying a successful backup.
<Callout type="info">
This guide assumes you've already [installed Zerobyte](/docs/installation) and can access the web interface.
</Callout>
## Overview
Setting up a backup involves three key steps:
<Steps>
<Step>
### Add a Volume
Define the source data you want to backup
</Step>
<Step>
### Create a Repository
Set up an encrypted storage destination for your backups
</Step>
<Step>
### Configure a Backup Job
Connect your volume to your repository with scheduling and retention policies
</Step>
</Steps>
Let's walk through each step.
## First-Time Setup
On a brand new Zerobyte install with no users yet, the app starts with onboarding:
<Steps>
<Step>
### Open Zerobyte
Navigate to the URL you configured in `BASE_URL` during installation. This is usually `http://<server-ip>:4096`.
</Step>
<Step>
### Create the Admin User
On the **Onboarding** page, enter:
- **Email**
- **Username**
- **Password**
- **Confirm Password**
Then click **Create admin user**.
</Step>
<Step>
### Download the Recovery Key
After the admin user is created, Zerobyte redirects to **Download Recovery Key**.
Enter your account password in **Confirm Your Password**, then click **Download Recovery Key**.
</Step>
<Step>
### Continue to Volumes
After the recovery key is downloaded, Zerobyte redirects to **Volumes**. The rest of this guide starts from there.
</Step>
</Steps>
<Callout type="warn">
**Critical**: Store the downloaded recovery key file safely. It contains the Restic password required to recover your backups. If you lose both server access and this file, your backups will be unrecoverable. See [Recovery keys and repository passwords](/docs/guides/recovery-key-and-repository-passwords) for the full model, including imported repositories with custom passwords.
</Callout>
<Callout type="info">
After onboarding is complete, future visits use the normal **Login** page instead of the onboarding flow.
</Callout>
## Step 1: Add a Volume
A **volume** represents the source data you want to backup. Zerobyte supports various volume types including local directories, NFS shares, SMB shares, WebDAV, SFTP, and rclone mounts.
### Adding a Local Directory
For this quick start, we'll add a local directory. First, ensure the directory is mounted into your Zerobyte container.
#### Mount the Directory
Update your `docker-compose.yml` to mount the directory you want to backup:
```yaml
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.30
container_name: zerobyte
restart: unless-stopped
# ... other configuration ...
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
- /path/to/your/data:/data # Add this line
```
Restart the container to apply changes:
```bash
docker compose down
docker compose up -d
```
#### Create the Volume in Zerobyte
<Steps>
<Step>
### Navigate to Volumes
After logging in, you are already on **Volumes**. If you navigate away, click **Volumes** in the sidebar.
</Step>
<Step>
### Create New Volume
Click the **Create Volume** button
</Step>
<Step>
### Configure Volume
Fill in the volume details:
- **Name**: Give it a descriptive name (e.g., "My Documents")
- **Backend**: Leave this set to `Directory`
- **Directory Path**: Use the filesystem browser to select the mounted directory (e.g., `/data`)
</Step>
<Step>
### Save Volume
Click **Create** to save the volume configuration
</Step>
</Steps>
<Callout type="info">
The directory browser shows paths from the Zerobyte server or container filesystem, not your host filesystem directly. In the Docker example above, the host path `/path/to/your/data` appears in Zerobyte as `/data`, so that is the path you select in the browser.
</Callout>
### Adding Remote Volumes
Zerobyte also supports remote volume types:
<Tabs items={['NFS', 'SMB/CIFS', 'WebDAV', 'SFTP']}>
<Tab value="NFS">
Configure an NFS share:
- **Server**: NFS server hostname or IP
- **Export Path**: NFS export path (e.g., `/mnt/data`)
- **Port**: NFS port (default: `2049`)
- **Version**: `3`, `4`, or `4.1`
- **Read-only Mode**: Optional, recommended for backup sources
</Tab>
<Tab value="SMB/CIFS">
Configure an SMB/CIFS share:
- **Server**: SMB server hostname or IP
- **Share**: Share name
- **Guest Mode**: Optional for unauthenticated shares
- **Username**: SMB username
- **Password**: SMB password
- **SMB Version**: `1.0`, `2.0`, `2.1`, or `3.0`
- **Domain**: Windows domain or workgroup (optional)
- **Port**: SMB port (default: `445`)
</Tab>
<Tab value="WebDAV">
Configure a WebDAV server:
- **Server**: WebDAV server hostname or IP
- **Path**: Remote path on the server
- **Username**: WebDAV username (optional)
- **Password**: WebDAV password (optional)
- **Port**: Server port (`80` by default, or `443` for HTTPS)
- **Use SSL/HTTPS**: Enable secure HTTPS connections
- **Read-only Mode**: Optional, recommended for backup sources
</Tab>
<Tab value="SFTP">
Configure an SFTP server:
- **Host**: SFTP server hostname or IP
- **Port**: SFTP port (default: 22)
- **Username**: SSH username
- **Password**: SSH password (optional if using a private key)
- **Private Key**: SSH private key (optional if using a password)
- **Path**: Remote directory path
- **Known Hosts**: Required unless you enable **Skip Host Key Verification**
</Tab>
</Tabs>
<Callout type="info">
Remote volumes require the full installation with `SYS_ADMIN` capability and `/dev/fuse` device. See the [Installation guide](/docs/installation) for details.
</Callout>
## Step 2: Create a Repository
A **repository** is the encrypted storage destination where your backups will be stored. Zerobyte supports multiple repository backends.
### Creating a Local Repository
For this quick start, we'll create a local repository:
<Steps>
<Step>
### Navigate to Repositories
Click on **Repositories** in the sidebar
</Step>
<Step>
### Create New Repository
On a new install, click **Create repository** from the empty state
</Step>
<Step>
### Configure Repository
Fill in the repository details:
- **Name**: Descriptive name (e.g., "Local Backup Repository")
- **Backend**: Select `Local`
- **Compression Mode**: `Auto (fast)` is the default and a good starting point
- **Import existing repository**: Leave this unchecked for a brand new repository
</Step>
<Step>
### Review Repository Directory
For local repositories, Zerobyte stores data under the repository base directory, which defaults to `/var/lib/zerobyte/repositories/{unique-id}`. Click **Change** only if you want to pick a different base directory.
</Step>
<Step>
### Create Repository
Click **Create repository**. Zerobyte creates and initializes the repository, then redirects you to the repository details page.
</Step>
</Steps>
<Callout type="info">
If you choose a custom local repository directory, make sure it is mounted from the host into the container. Otherwise the repository data can be lost when the container restarts.
</Callout>
<Callout type="info">
If you are importing an existing repository instead of creating a new one, read [Recovery keys and repository passwords](/docs/guides/recovery-key-and-repository-passwords) before choosing between the existing recovery key and a custom repository password.
</Callout>
### Cloud Storage Repositories
For production use, consider using cloud storage for offsite backups:
<Tabs items={['S3-Compatible', 'Google Cloud Storage', 'Azure Blob', 'rclone']}>
<Tab value="S3-Compatible">
Configure S3-compatible storage (AWS S3, MinIO, Wasabi, Backblaze B2, etc.):
```yaml
Type: S3
Endpoint: s3.amazonaws.com (or custom endpoint)
Bucket: my-backup-bucket
Access Key ID: AKIAIOSFODNN7EXAMPLE
Secret Access Key: my-secret-access-key
```
</Tab>
<Tab value="Google Cloud Storage">
Configure Google Cloud Storage:
```yaml
Type: Google Cloud Storage
Bucket: my-backup-bucket
Project ID: my-project-id
Service Account JSON: Paste the service account JSON
```
</Tab>
<Tab value="Azure Blob">
Configure Azure Blob Storage:
```yaml
Type: Azure Blob Storage
Container: backups
Account Name: myaccountname
Account Key: my-account-key
Endpoint Suffix: core.windows.net (optional)
```
</Tab>
<Tab value="rclone">
Configure rclone-based storage (requires rclone config mounted):
```yaml
Type: rclone
Remote: gdrive (must exist in rclone config)
Path: backups/zerobyte
```
<Callout type="info">
rclone must be configured on the host and mounted into the container. See [Installation - Mounting rclone Configuration](/docs/installation#mounting-rclone-configuration).
</Callout>
</Tab>
</Tabs>
<Callout type="info">
If you want credentials to come from environment variables or Docker secrets, use the [Provisioning guide](/docs/guides/provisioning). The standard volume and repository forms currently expect the actual secret value and store it encrypted.
</Callout>
## Step 3: Create a Backup Job
A **backup job** connects a volume to a repository and defines when and how backups should run.
<Steps>
<Step>
### Navigate to Backups
Click on **Backups** in the sidebar
</Step>
<Step>
### Start a New Backup Job
Click **Create a backup job**
</Step>
<Step>
### Choose the Volume
Select the volume you just created. Zerobyte then opens the backup configuration form for that volume.
</Step>
<Step>
### Configure Basic Settings
- **Backup name**: Descriptive name (e.g., "Daily Documents Backup")
- **Backup repository**: Select the repository you created earlier
- **Backup frequency**: Choose how often the job should run
</Step>
<Step>
### Configure Schedule
Choose the schedule that matches how you want this backup to run:
- **Manual only**: Create the job without a schedule and run it on demand
- **Hourly**
- **Daily**
- **Weekly**
- **Specific days**: Pick one or more days of the month
- **Custom (Cron)**: Enter a cron expression
If you choose **Daily**, **Weekly**, or **Specific days**, set an **Execution time**.
If you choose **Weekly**, also choose the day of the week.
<Accordions type="single">
<Accordion title="Common Cron Patterns">
- `0 2 * * *` - Daily at 2:00 AM
- `0 2 * * 0` - Weekly on Sunday at 2:00 AM
- `0 2 1 * *` - Monthly on the 1st at 2:00 AM
- `0 */6 * * *` - Every 6 hours
- `0 0 * * 1-5` - Weekdays at midnight
</Accordion>
</Accordions>
</Step>
<Step>
### Configure Retention Policy
Define how long backups should be kept:
```yaml
Keep Daily: 7
Keep Weekly: 4
Keep Monthly: 6
Keep Yearly: 1
```
<Callout type="info">
Retention policies automatically prune old backups to save storage space. Restic's deduplication ensures you only store unique data.
</Callout>
</Step>
<Step>
### Configure Paths (Optional)
By default, the entire volume is backed up. You can optionally:
- Use **Backup paths** to select only specific files or folders from the volume
- Add **Additional include patterns** for glob-based include rules
- Add **Exclude patterns** for files or folders to skip (e.g., `*.tmp`, `node_modules/**`)
</Step>
<Step>
### Advanced Options (Optional)
Open **Advanced** if you need extra Restic options.
Common options include:
- **Maximum retries**: Retry failed backup runs automatically
- **Retry delay**: Wait before retrying a failed run
- **Custom restic parameters**: Pass supported Restic flags one per line
</Step>
<Step>
### Save Backup Job
Click **Create** to save the backup job. Zerobyte redirects you straight to the backup job details page.
</Step>
</Steps>
## Step 4: Run Your First Backup
Instead of waiting for the scheduled time, let's run the backup immediately:
<Steps>
<Step>
### Open the Backup Job Details
After creating the job, Zerobyte opens the backup job details page automatically
</Step>
<Step>
### Run Backup Manually
Click **Backup now**
</Step>
<Step>
### Monitor Progress
Zerobyte starts the job immediately and updates the status on the details page. A success toast appears when the run begins.
</Step>
<Step>
### Wait for Completion
Wait for the backup to finish. When it completes successfully, the job status changes to **Success** and a new entry appears in the **Snapshots** section.
</Step>
</Steps>
<Callout type="info">
During the first backup, all data is uploaded. Subsequent backups are much faster due to Restic's incremental backup and deduplication.
</Callout>
## Step 5: Verify Your Backup
After the backup completes, verify it was successful:
<Steps>
<Step>
### Open the Snapshot
In the **Snapshots** section, click the snapshot you just created
</Step>
<Step>
### Check Snapshots
You should see snapshot details such as:
- Snapshot timestamp
- Total size
- Data added vs. data stored
- Files processed
- Duration
</Step>
<Step>
### Browse Backup Contents
Use the inline file browser to inspect the snapshot contents. You can:
- Navigate the directory structure
- View file sizes
- Confirm the expected files are present
</Step>
<Step>
### Test Restore (Optional)
For peace of mind, test restoring a file:
- Click **Restore** from the snapshot details
- Choose **Original location** or **Custom location**
- Use **Restore All** or select specific files and restore only those
- Verify the restored file is present in the target location
</Step>
</Steps>
<Callout type="warn">
By default, restored files are placed back in their original location. Be careful not to overwrite current files. You can specify an alternate restore path if needed.
</Callout>
## Understanding Backup Status
The UI exposes two related status views:
- In the **Backups** list, the status dot shows **Active**, **Paused**, **Error**, **Warning**, or **Backup in progress**
- In the backup job details page, the last run status shows **✓ Success**, **✗ Error**, **! Warning**, or **⟳ in progress...**
- If a backup job has not completed a run yet, the details page shows no last run value
<Callout type="info">
Click any backup job to view its snapshot history, restore actions, schedule summary, and repository links. If a run fails and the UI does not show enough detail, check your container logs with `docker compose logs -f zerobyte`.
</Callout>
## Monitoring Your Backups
Zerobyte provides several ways to monitor backup health:
### Backup Jobs List
The **Backups** page shows:
- Backup job names
- Source volume and target repository
- Schedule
- Last backup time
- Next backup time
### Backup Job Details
Each backup job displays:
- Last run time and status
- Next backup time
- Snapshot history
- Snapshot statistics for the selected snapshot
- Restore actions for the selected snapshot
### Repository Health
Repository details show:
- Current repository status (`healthy`, `error`, `unknown`, `doctor`, or `cancelled`)
- Last checked time
- Compression statistics and snapshot count
- Doctor report output
<Callout type="info">
Use **Run doctor** in the repository details page when you need a repository maintenance pass. Use the refresh button in **Compression Statistics** when you only need updated stored-size and snapshot metrics. For the exact meaning of the status values and actions, see [Repository maintenance and status](/docs/guides/repository-maintenance).
</Callout>
## Next Steps
Now that you have a working backup, consider:
<Cards>
<Card title="Add More Volumes" icon={<FolderPlus />}>
Back up additional directories or remote shares
</Card>
<Card title="Set Up Cloud Storage" icon={<Cloud />}>
Configure offsite backups to S3, Google Cloud, or Azure
</Card>
<Card title="Configure Notifications" icon={<Bell />} href="/docs/guides/notifications">
Get alerted when backups fail or complete
</Card>
<Card title="Optimize Retention" icon={<History />}>
Fine-tune retention policies to balance storage and history
</Card>
</Cards>
## Common Tasks
### Restoring Data
To restore data from a backup:
<Steps>
<Step>
### Navigate to Backup
Go to **Backups** and select the backup job containing the data you need
</Step>
<Step>
### Select Snapshot
Choose the snapshot from the time period you want to restore from
</Step>
<Step>
### Browse and Select
Browse the file tree and select files/folders to restore
</Step>
<Step>
### Choose Restore Location
Decide whether to restore to original location or specify a new path
</Step>
<Step>
### Restore
Click **Restore All** to restore everything, or select specific files first and restore only those
</Step>
</Steps>
### Pausing a Backup Job
To temporarily stop a backup from running:
1. Navigate to the backup job
2. Toggle the **Enabled** switch to off
3. The job will skip scheduled runs until re-enabled
### Checking Backup Details
To inspect a backup run:
1. Click on a backup job
2. Review the current **Status**, **Last backup**, and **Snapshots** sections
3. Click a snapshot to inspect its file browser and restore actions
4. For deeper troubleshooting, check `docker compose logs -f zerobyte`
### Deleting Old Snapshots
Backups are automatically pruned according to retention policies, but you can manually delete snapshots:
1. Navigate to backup job
2. Select the snapshot you want to delete
3. Click **Delete Snapshot**
4. Confirm deletion
<Callout type="warn">
Deleting snapshots is irreversible. Ensure you don't need the data before deleting.
</Callout>
## Troubleshooting
### Backup Failed: Permission Denied
Ensure the volume directory has proper permissions:
```bash
# On the host system
sudo chmod -R 755 /path/to/your/data
```
### Backup Running Slowly
Check:
- Network bandwidth (for cloud repositories)
- Disk I/O performance
- Enable compression if uploading large compressible files
- Review repository upload/download speed limits if you enabled them
### Repository Initialization Failed
Verify:
- Repository path is writable
- For cloud repositories, credentials are correct
- Network connectivity to cloud storage
### Can't Find Mounted Directory
Ensure:
- Directory is mounted in `docker-compose.yml`
- Container was restarted after mounting: `docker compose restart`
- Path matches the container path, not host path
## Getting Help
If you encounter issues:
<Cards>
<Card title="Check Logs" icon={<FileText />}>
View detailed logs with `docker compose logs -f zerobyte`
</Card>
<Card title="GitHub Issues" icon={<Github />}>
Report bugs or request features at [github.com/nicotsx/zerobyte/issues](https://github.com/nicotsx/zerobyte/issues)
</Card>
<Card title="Documentation" icon={<BookOpen />}>
Explore the full documentation for advanced topics
</Card>
<Card title="Community" icon={<Users />}>
Join discussions and get help from other users
</Card>
</Cards>
<Callout type="info">
When reporting issues, include:
- Zerobyte version (visible in UI footer)
- Docker and Docker Compose versions
- Relevant logs from `docker compose logs zerobyte`
- Steps to reproduce the issue
</Callout>
## Conclusion
You've successfully:
<ul className="my-4 list-none space-y-2 pl-0">
<li className="flex items-start gap-2">
<Check className="mt-1.5 h-4 w-4 shrink-0 text-green-600" />
<span>Created an admin account and downloaded the recovery key</span>
</li>
<li className="flex items-start gap-2">
<Check className="mt-1.5 h-4 w-4 shrink-0 text-green-600" />
<span>Added a volume to backup</span>
</li>
<li className="flex items-start gap-2">
<Check className="mt-1.5 h-4 w-4 shrink-0 text-green-600" />
<span>Created a repository for encrypted storage</span>
</li>
<li className="flex items-start gap-2">
<Check className="mt-1.5 h-4 w-4 shrink-0 text-green-600" />
<span>Configured and ran your first backup job</span>
</li>
<li className="flex items-start gap-2">
<Check className="mt-1.5 h-4 w-4 shrink-0 text-green-600" />
<span>Verified your backup was successful</span>
</li>
</ul>
Your data is now protected with encrypted, deduplicated backups. Zerobyte will continue to run backups automatically according to your schedule.
import { FolderPlus, Cloud, Bell, History, FileText, Github, BookOpen, Users, Check } from "lucide-react";
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
import { Accordion, Accordions } from "fumadocs-ui/components/accordion";

View file

@ -0,0 +1,264 @@
---
title: Troubleshooting
description: Solve common issues with permissions, mounts, rclone, and more
---
This guide covers common issues and their solutions. Before troubleshooting, enable debug logging to get more detail.
## Enable Debug Logging
Add `LOG_LEVEL=debug` to your environment variables and restart:
```yaml
environment:
- LOG_LEVEL=debug
```
Then view logs:
```bash
docker logs -f zerobyte
```
<Callout type="warn">
Never share sensitive information (passwords, access keys, personal data) in public issue reports. Redact them from logs before posting.
</Callout>
## Container Won't Start
1. Check logs: `docker compose logs zerobyte`
2. Verify `APP_SECRET` is set and at least 32 characters
3. Ensure `/var/lib/zerobyte` exists and has correct permissions
4. Verify port 4096 is not already in use: `netstat -tuln | grep 4096`
### Permission Issues
If you encounter permission errors on the data directory:
```bash
sudo chown -R 1000:1000 /var/lib/zerobyte
```
## Remote Mount Issues
### Permission Denied When Mounting
Mounting remote filesystems requires kernel-level privileges. Ensure:
- Remote share credentials are correct
- The host kernel supports the target filesystem (e.g., CIFS module is available)
- Docker is running in **rootful mode** (rootless Docker cannot perform kernel mounts)
- Your container has `SYS_ADMIN` capability:
```yaml
cap_add:
- SYS_ADMIN
```
### Alternative: Mount on Host
If container-level mounting causes issues, mount the remote share on the host first, then bind-mount it into the container:
```bash
# Mount on host
sudo mount -t cifs //server/share /mnt/your-remote-share -o credentials=/path/to/creds
```
```yaml
# Then in docker-compose.yml
volumes:
- /mnt/your-remote-share:/data
```
### Security Framework Issues
Linux security frameworks may block mount operations even with `SYS_ADMIN`. Try these solutions in order:
<Tabs items={["AppArmor (Ubuntu/Debian)", "Seccomp", "SELinux (CentOS/Fedora)"]}>
<Tab value="AppArmor (Ubuntu/Debian)">
Check if AppArmor is blocking mounts:
```bash
sudo aa-status
docker inspect --format='{{.AppArmorProfile}}' zerobyte
```
If it returns `docker-default`, disable AppArmor for the container:
```yaml
security_opt:
- apparmor:unconfined
```
</Tab>
<Tab value="Seccomp">
Docker's default seccomp profile may block mount-related syscalls:
```yaml
security_opt:
- seccomp:unconfined
```
</Tab>
<Tab value="SELinux (CentOS/Fedora)">
Adjust the SELinux context:
```yaml
security_opt:
- label:type:container_runtime_t
```
Or disable SELinux enforcement for the container:
```yaml
security_opt:
- label:disable
```
</Tab>
</Tabs>
<Callout type="warn">
**Last resort**: If nothing else works, you can run with `privileged: true`. This disables most container isolation and should only be used temporarily for diagnosis.
</Callout>
## FUSE Mount Failures
FUSE-based mounts (SFTP volumes, rclone volumes) require `/dev/fuse`:
```yaml
devices:
- /dev/fuse:/dev/fuse
```
Verify FUSE is accessible inside the container:
```bash
docker exec zerobyte ls -la /dev/fuse
```
<Callout type="info">
`/dev/fuse` is **not required** for SMB/CIFS or NFS mounts, only for SFTP and rclone volumes.
</Callout>
## Rclone Issues
### Test on Host First
Before reporting rclone issues, verify rclone works on your Docker host:
```bash
# List configured remotes
rclone listremotes
# Test listing a remote
rclone lsd myremote:
# Test reading files
rclone ls myremote:path/to/test
```
**If these commands fail on the host, fix your rclone configuration first.** Common causes:
- Expired OAuth tokens, run `rclone config` to re-authenticate
- Incorrect credentials
- Missing permissions on the cloud provider side
- Network or firewall issues
### "No Remotes Available" in Dropdown
Zerobyte can't find your rclone configuration. Check:
```bash
docker exec zerobyte ls -la /root/.config/rclone/
```
Ensure the config is mounted:
```yaml
volumes:
- ~/.config/rclone:/root/.config/rclone:ro
```
For non-root containers, set the correct path:
```yaml
environment:
- RCLONE_CONFIG_DIR=/home/appuser/.config/rclone
volumes:
- ~/.config/rclone:/home/appuser/.config/rclone:ro
```
**Restart the container** after mounting the config.
### "Failed to Create File System" Error
Usually an authentication failure. On your host:
1. Run `rclone config` and re-authenticate the remote
2. Verify with `rclone lsd remote:`
3. Restart the Zerobyte container
### SFTP Repository Authentication Failures
If your rclone SFTP remote uses `key_file`, the path points to the host filesystem which isn't accessible inside the container.
Solutions:
- **Mount SSH keys**: `~/.ssh:/root/.ssh:ro`
- **Embed key in config**: Use `key_pem` instead of `key_file`
- **Use ssh-agent**: Set `key_use_agent = true` and mount the SSH agent socket
See the [rclone guide](/docs/guides/rclone) for detailed instructions.
### Rclone Volume Mount Errors
Rclone volumes (mounting cloud storage as a data source) require:
- Linux host (Windows/macOS cannot use rclone volumes)
- `/dev/fuse` device mounted
- `SYS_ADMIN` capability
**"fusermount3: Permission denied"**: Verify `/dev/fuse` is mounted and check for AppArmor/seccomp restrictions.
**"Operation not permitted"**: Add `SYS_ADMIN` capability.
## Backup Issues
### Backup Failed: Permission Denied
Ensure the volume directory has proper permissions:
```bash
sudo chmod -R 755 /path/to/your/data
```
### Backup Running Slowly
- Check network bandwidth (for cloud repositories)
- Check disk I/O performance
- Enable compression for large compressible files
- Adjust parallel upload settings in repository configuration
- Consider bandwidth limits if other services are affected
### Repository Locked
If you see "repository is locked" errors, a previous operation may have been interrupted. Use the **Unlock** button in the repository settings.
<Callout type="warn">
Only unlock if you're certain no other backup or restore operations are running against the repository.
</Callout>
## Getting Help
If you've tried the solutions above and still have issues:
1. Enable debug logging (`LOG_LEVEL=debug`)
2. Collect relevant logs: `docker compose logs --tail=200 zerobyte`
3. Open an issue at [github.com/nicotsx/zerobyte/issues](https://github.com/nicotsx/zerobyte/issues)
Include in your report:
- Zerobyte version (visible in the UI footer)
- Docker and Docker Compose versions
- Relevant logs (with sensitive data redacted)
- Steps to reproduce the issue
import { Tab, Tabs } from "fumadocs-ui/components/tabs";

66
docs/package.json Normal file
View file

@ -0,0 +1,66 @@
{
"name": "docs",
"private": true,
"type": "module",
"imports": {
"#/*": "./src/*"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
"test": "vitest run",
"preview": "vite preview",
"deploy": "bun run build && wrangler deploy",
"cf-typegen": "wrangler types"
},
"dependencies": {
"@base-ui/react": "^1.4.0",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@phosphor-icons/react": "^2.1.10",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-router-devtools": "^1.132.0",
"@tanstack/react-router-ssr-query": "^1.131.7",
"@tanstack/react-start": "^1.132.0",
"@tanstack/router-plugin": "^1.132.0",
"@types/mdx": "^2.0.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"fumadocs-core": "^16.6.9",
"fumadocs-mdx": "^14.2.9",
"fumadocs-ui": "^16.6.9",
"lucide-react": "^0.545.0",
"nitro": "npm:nitro-nightly@latest",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"shadcn": "^4.2.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.32.2",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/devtools-vite": "^0.3.11",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/node": "^22.10.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.4",
"jsdom": "^27.0.0",
"typescript": "^5.7.2",
"vite": "^7.1.7",
"vite-plugin-killer-instincts": "^1.0.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.5",
"wrangler": "^4.82.2"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"lightningcss"
]
}
}

BIN
docs/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,21 @@
{
"name": "Zerobyte",
"short_name": "Zerobyte",
"icons": [
{
"src": "/images/favicon/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/images/favicon/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#1b1b1b",
"background_color": "#1b1b1b",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
docs/public/images/og.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
docs/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
docs/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
docs/public/manifest.json Normal file
View file

@ -0,0 +1,25 @@
{
"short_name": "TanStack App",
"name": "Create TanStack App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
docs/public/robots.txt Normal file
View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

BIN
docs/public/zerobyte.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

5
docs/source.config.ts Normal file
View file

@ -0,0 +1,5 @@
import { defineDocs } from "fumadocs-mdx/config";
export const docs = defineDocs({
dir: "content/docs",
});

View file

@ -0,0 +1,25 @@
import type { HTMLAttributes } from "react";
const baseClassName =
"bg-card text-card-foreground group relative border border-border shadow-[0_8px_30px_-15px_rgba(0,0,0,0.04)] transition-colors duration-300 dark:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.02)]";
export function CornerCard({ children, className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div {...props} className={`${baseClassName}${className ? ` ${className}` : ""}`}>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-10 select-none opacity-30 transition-opacity duration-300"
>
<span className="absolute -left-0.5 -top-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -left-0.5 -top-0.5 h-4 w-0.5 bg-foreground" />
<span className="absolute -right-0.5 -top-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -right-0.5 -top-0.5 h-4 w-0.5 bg-foreground" />
<span className="absolute -left-0.5 -bottom-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -left-0.5 -bottom-0.5 h-4 w-0.5 bg-foreground" />
<span className="absolute -right-0.5 -bottom-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -right-0.5 -bottom-0.5 h-4 w-0.5 bg-foreground" />
</span>
{children}
</div>
);
}

View file

@ -0,0 +1,52 @@
import type { HTMLAttributes, ReactNode } from "react";
import { Children } from "react";
import { CornerCard } from "./CornerCard";
type CardsProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
};
type CardProps = {
children?: ReactNode;
href?: string;
icon?: ReactNode;
title: string;
};
export function Cards({ children, className, ...props }: CardsProps) {
const count = Children.count(children);
return (
<div
{...props}
className={`not-prose my-6 grid gap-6 ${count > 1 ? "sm:grid-cols-2" : ""}${className ? ` ${className}` : ""}`}
>
{children}
</div>
);
}
export function Card({ children, href, icon, title }: CardProps) {
const content = (
<CornerCard className="flex h-full flex-col gap-6 py-6">
<div className={`grid auto-rows-min grid-rows-[auto_auto] items-start px-6 ${icon ? "gap-4" : "gap-0"}`}>
{icon ? <div className="text-strong-accent [&_svg]:h-5 [&_svg]:w-5">{icon}</div> : null}
<h3 className="leading-none font-semibold text-foreground">{title}</h3>
</div>
{children ? (
<div className="px-6">
<div className="text-sm leading-relaxed text-muted-foreground [&_p]:m-0">{children}</div>
</div>
) : null}
</CornerCard>
);
if (!href) return <div className="h-full">{content}</div>;
return (
<a href={href} className="block h-full text-inherit no-underline">
{content}
</a>
);
}

View file

@ -0,0 +1,13 @@
import type { ComponentProps } from "react";
import Link from "fumadocs-core/link";
import { usePathname } from "fumadocs-core/framework";
export function DocsMdxLink({ href, ...props }: ComponentProps<"a">) {
const pathname = usePathname();
if (href?.startsWith("#")) {
return <a {...props} href={`${pathname}${href}`} />;
}
return <Link {...props} href={href} />;
}

View file

@ -0,0 +1,44 @@
import { Link } from "@tanstack/react-router";
import { Github } from "lucide-react";
const repoUrl = "https://github.com/nicotsx/zerobyte";
export default function Footer() {
return (
<footer className="bg-secondary/30">
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<div className="flex flex-col items-center justify-between gap-6 sm:flex-row">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center">
<img src="/zerobyte.png" alt="" className="h-full w-full object-contain" />
</div>
<span className="text-lg font-semibold text-foreground">Zerobyte</span>
</div>
<nav className="flex items-center gap-6">
<Link
to="/docs/$"
params={{ _splat: "" }}
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
>
Docs
</Link>
<a
href={repoUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<Github className="h-4 w-4" />
GitHub
</a>
</nav>
</div>
<div className="mt-8 border-t border-border pt-8">
<p className="text-center text-sm text-muted-foreground">Open source backup automation for Restic.</p>
</div>
</div>
</footer>
);
}

View file

@ -0,0 +1,67 @@
import { Link } from "@tanstack/react-router";
import { Github } from "lucide-react";
const discordUrl = "https://discord.gg/MzBXz5v5XB";
const repoUrl = "https://github.com/nicotsx/zerobyte";
const homeSectionLinks = [{ href: "/", label: "Home" }];
const buttonBaseClass =
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0";
export default function Header() {
return (
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/80 backdrop-blur-sm">
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4 sm:px-6">
<div className="flex gap-6">
<Link to="/" className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center">
<img src="/zerobyte.png" alt="" className="h-full w-full object-contain" />
</div>
<span className="text-lg font-semibold text-foreground">Zerobyte</span>
</Link>
<nav className="hidden items-center gap-4 md:flex">
{homeSectionLinks.map((link) => (
<a
key={link.href}
href={link.href}
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
>
{link.label}
</a>
))}
<Link
to="/docs/$"
params={{ _splat: "" }}
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
>
Docs
</Link>
</nav>
</div>
<div className="flex items-center gap-3">
<a
href={discordUrl}
target="_blank"
rel="noopener noreferrer"
className={`${buttonBaseClass} size-9 text-muted-foreground hover:bg-accent hover:text-foreground`}
>
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-5 w-5 fill-current">
<path d="M20.317 4.369A19.791 19.791 0 0 0 15.44 3a13.967 13.967 0 0 0-.599 1.233 18.27 18.27 0 0 0-5.682 0A13.966 13.966 0 0 0 8.56 3a19.736 19.736 0 0 0-4.878 1.37C.598 9.04-.323 13.58.138 18.057A19.943 19.943 0 0 0 6.13 21a14.31 14.31 0 0 0 1.282-2.11 12.874 12.874 0 0 1-2.02-.964c.17-.123.336-.252.497-.385 3.897 1.78 8.148 1.78 12 0 .162.133.328.262.498.385a12.916 12.916 0 0 1-2.024.965A14.223 14.223 0 0 0 17.645 21a19.874 19.874 0 0 0 5.994-2.943c.541-5.19-.92-9.689-3.322-13.688ZM8.02 15.331c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.418 2.157-2.418 1.211 0 2.176 1.094 2.157 2.418 0 1.334-.955 2.419-2.157 2.419Zm7.96 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.418 2.157-2.418 1.211 0 2.176 1.094 2.157 2.418 0 1.334-.946 2.419-2.157 2.419Z" />
</svg>
<span className="sr-only">Join Discord</span>
</a>
<a
href={repoUrl}
target="_blank"
rel="noopener noreferrer"
className={`${buttonBaseClass} size-9 text-muted-foreground hover:bg-accent hover:text-foreground`}
>
<Github className="h-5 w-5" />
<span className="sr-only">View on GitHub</span>
</a>
</div>
</div>
</header>
);
}

View file

@ -0,0 +1,457 @@
import { Link } from "@tanstack/react-router";
import {
AlertTriangle,
ArrowRight,
Bell,
CalendarClock,
Check,
Clock,
Cloud,
Container,
Copy,
Database,
FileQuestion,
Github,
HardDrive,
Layers,
Lock,
RotateCcw,
Settings,
Shield,
ShieldCheck,
Wrench,
Zap,
type LucideIcon,
} from "lucide-react";
import { CornerCard } from "./CornerCard";
import Footer from "./Footer";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./ui/accordion";
const repoUrl = "https://github.com/nicotsx/zerobyte";
const buttonBaseClass =
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0";
const primaryButtonClass = `${buttonBaseClass} h-10 bg-strong-accent px-6 text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50`;
const outlineButtonClass = `${buttonBaseClass} h-10 border border-border bg-background px-6 shadow-xs hover:bg-accent hover:text-accent-foreground`;
const trustItems: Array<{ icon: LucideIcon; label: string }> = [
{ icon: Shield, label: "Open source" },
{ icon: Database, label: "Built on Restic" },
{ icon: Lock, label: "End-to-end encrypted" },
{ icon: Layers, label: "Incremental & deduplicated" },
{ icon: Cloud, label: "Multi-backend support" },
];
const problems: Array<{ icon: LucideIcon; text: string }> = [
{ icon: AlertTriangle, text: "Jobs fail quietly until you need a restore." },
{ icon: Settings, text: "Different storage backends lead to one-off scripts and fragile setup." },
{ icon: Clock, text: "Retention policies get buried in config nobody wants to touch." },
{ icon: Wrench, text: "Repository locks and health issues only show up when something is already broken." },
{ icon: FileQuestion, text: "Restore workflows stay untested until the pressure is high." },
];
const solutions: Array<{ icon: LucideIcon; title: string; description: string }> = [
{
icon: CalendarClock,
title: "Schedule with confidence",
description:
"Create backup jobs with cron-based schedules, retention policies, include and exclude rules, and manual runs when you need an extra snapshot before a risky change.",
},
{
icon: HardDrive,
title: "Protect data wherever it lives",
description:
"Back up local directories plus NFS, SMB/CIFS, WebDAV, SFTP, and rclone-backed sources from the same interface.",
},
{
icon: Database,
title: "Keep storage flexible",
description:
"Write encrypted snapshots to local repositories, S3-compatible storage, Cloudflare R2, Google Cloud Storage, Azure Blob Storage, REST servers, SFTP targets, and 40+ providers through rclone.",
},
{
icon: RotateCcw,
title: "Restore what you need",
description:
"Browse snapshots in the UI and restore individual files, directories, or larger paths without dropping back to the CLI.",
},
{
icon: Bell,
title: "Catch problems before they become incidents",
description:
"Track run status, next backup time, snapshot history, repository health, and send alerts to Slack, Discord, email, ntfy, Telegram, webhooks, and more.",
},
{
icon: ShieldCheck,
title: "Operate securely",
description:
"Zerobyte is organization-scoped, supports roles and invitations, offers OIDC-based SSO, and encrypts sensitive credentials before storage.",
},
];
const features: Array<{ icon: LucideIcon; title: string; description: string }> = [
{
icon: Lock,
title: "Encrypted by design",
description: "Data gets encrypted before it leaves the source, so your storage backend never sees plaintext.",
},
{
icon: Zap,
title: "Incremental and deduplicated",
description: "After the first run, only changed data is transferred and stored.",
},
{
icon: Settings,
title: "Compression controls",
description: "Choose auto, off, or max compression to balance CPU time and storage cost.",
},
{
icon: Copy,
title: "Mirror repositories",
description: "Copy snapshots to additional repositories for geographic redundancy or provider diversification.",
},
{
icon: Wrench,
title: "Repository maintenance",
description: "Run Doctor, unlock stale repositories, and refresh repository statistics from the UI.",
},
{
icon: Container,
title: "Operator-friendly deployment",
description: "Self-host with Docker Compose and manage backups from a web interface your team can actually use.",
},
];
const steps = [
{
number: "1",
title: "Connect a volume",
description: "Add a local directory, NAS share, remote filesystem, or rclone-backed source.",
},
{
number: "2",
title: "Create a repository",
description:
"Choose where encrypted snapshots should live and configure compression, bandwidth limits, or imported repository settings.",
},
{
number: "3",
title: "Set your schedule",
description: "Define when backups run, how long snapshots stay, and which paths to include or exclude.",
},
{
number: "4",
title: "Monitor and restore",
description:
"Watch backup progress, review snapshot history, receive notifications, and restore exactly what you need.",
},
];
const benefits = [
"You keep Restic's encryption, deduplication, and incremental snapshots.",
"You gain scheduling, monitoring, restore workflows, repository maintenance, and team access controls.",
"You keep your choice of storage backend instead of being tied to a single vendor.",
];
const faqs = [
{
question: "Is Zerobyte a backup engine or a UI for Restic?",
answer:
"Zerobyte is a Restic-based backup automation tool. It gives you a web control plane for scheduling, managing, monitoring, restoring, and maintaining Restic backups.",
},
{
question: "What can I back up with Zerobyte?",
answer:
"You can back up local directories, NFS shares, SMB/CIFS shares, WebDAV endpoints, SFTP locations, and rclone-backed sources.",
},
{
question: "Where can I store backups?",
answer:
"Zerobyte supports local repositories, S3-compatible storage, Cloudflare R2, Google Cloud Storage, Azure Blob Storage, REST servers, SFTP targets, and many additional providers through rclone.",
},
{
question: "Is my data encrypted?",
answer:
"Yes. Zerobyte relies on Restic's end-to-end encryption for repository data, and sensitive credentials stored by the app are encrypted before they are written to the database.",
},
{
question: "Can I restore individual files?",
answer:
"Yes. You can browse snapshots from the web interface and restore individual files, directories, or larger paths to the original or an alternate location.",
},
{
question: "Can teams use Zerobyte?",
answer:
"Yes. Zerobyte is organization-scoped and supports roles, invitations, and OIDC-based SSO for managed access.",
},
{
question: "How do I deploy it?",
answer: "Zerobyte is designed to be self-hosted and can be deployed with Docker Compose.",
},
];
function BrowserMockup() {
return (
<div className="w-full overflow-hidden rounded-lg border border-border bg-card shadow-2xl">
<div className="relative flex items-center gap-3 border-b border-border bg-secondary/80 px-4 py-2">
<div className="flex shrink-0 gap-1.5">
<div className="h-3 w-3 rounded-full bg-red-500" />
<div className="h-3 w-3 rounded-full bg-yellow-500" />
<div className="h-3 w-3 rounded-full bg-green-500" />
</div>
<div className="min-w-0 flex-1 sm:pointer-events-none sm:absolute sm:inset-0 sm:flex sm:items-center sm:justify-center sm:px-20">
<div className="w-full rounded bg-background/80 px-3 py-0.5 text-center text-xs text-muted-foreground sm:max-w-md">
localhost:4096
</div>
</div>
</div>
<div className="aspect-video bg-background/80">
<img
src="/images/screenshot.png"
alt="Zerobyte backups dashboard"
className="h-full w-full object-cover object-top"
/>
</div>
</div>
);
}
export default function LandingPage() {
return (
<div data-landing-page className="bg-background text-foreground">
<main>
<section className="relative overflow-hidden border-b border-border">
<div aria-hidden className="landing-hero-docs-grid pointer-events-none absolute inset-0" />
<div aria-hidden className="landing-hero-glow pointer-events-none absolute inset-0" />
<div className="relative mx-auto max-w-[90rem] px-4 py-20 sm:px-6 sm:py-24 lg:py-32">
<div className="grid items-center gap-12 min-[1100px]:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] min-[1100px]:gap-8 lg:gap-12">
<div className="text-left">
<p className="mb-4 text-sm font-medium uppercase tracking-wider text-strong-accent">
Open Source Backup Control Plane
</p>
<h1 className="text-balance text-4xl font-bold leading-tight tracking-tight text-foreground sm:text-5xl lg:text-6xl">
Backups you can finally forget about
</h1>
<p className="mt-6 max-w-xl text-pretty text-lg leading-relaxed text-muted-foreground">
Zerobyte gives you a clean web interface to schedule, monitor, restore, and maintain encrypted backups
across local disks, NAS shares, remote servers, and cloud storage.
</p>
<div className="mt-10 flex flex-wrap gap-3">
<Link to="/docs/$" params={{ _splat: "" }} className={primaryButtonClass}>
Documentation
<ArrowRight className="h-4 w-4" />
</Link>
<a href={repoUrl} target="_blank" rel="noopener noreferrer" className={outlineButtonClass}>
<Github className="h-4 w-4" />
View on GitHub
</a>
</div>
<p className="mt-6 max-w-xl text-sm text-muted-foreground">
Self-hosted. Restic-powered. Built for operators who want fewer scripts and more visibility.
</p>
</div>
<div className="min-[1100px]:-mr-8 xl:-mr-12">
<BrowserMockup />
</div>
</div>
</div>
</section>
<section className="border-b border-border bg-secondary/30">
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
<div className="flex flex-wrap items-center justify-center gap-6 sm:gap-10">
{trustItems.map((item) => (
<div key={item.label} className="flex items-center gap-2 text-muted-foreground">
<item.icon className="h-4 w-4 text-strong-accent" />
<span className="text-sm font-medium">{item.label}</span>
</div>
))}
</div>
</div>
</section>
<section className="border-b border-border">
<div className="mx-auto max-w-6xl px-4 py-20 sm:px-6 lg:py-28">
<div className="mx-auto max-w-3xl">
<h2 className="text-balance text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
Backups are easy to start and hard to trust
</h2>
<p className="mt-4 text-lg text-muted-foreground">
A few commands and a cron job can get backups running. Keeping them reliable is the hard part.
</p>
<ul className="mt-10 space-y-5">
{problems.map((problem) => (
<li key={problem.text} className="flex items-start gap-4">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-secondary">
<problem.icon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<span className="text-muted-foreground">{problem.text}</span>
</li>
))}
</ul>
</div>
</div>
</section>
<section className="border-b border-border">
<div className="mx-auto max-w-6xl px-4 py-20 sm:px-6 lg:py-28">
<div className="mx-auto max-w-3xl text-center">
<h2 className="text-balance text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
Zerobyte puts a real control plane on top of Restic
</h2>
<p className="mt-4 text-lg text-muted-foreground">
Instead of stitching together CLI commands, cron, and ad hoc monitoring, you manage the full backup
lifecycle from one place.
</p>
</div>
<div className="mt-16 grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
{solutions.map((solution) => (
<div key={solution.title} className="group">
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-secondary">
<solution.icon className="h-5 w-5 text-strong-accent" />
</div>
<h3 className="text-lg font-semibold text-foreground">{solution.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{solution.description}</p>
</div>
))}
</div>
</div>
</section>
<section id="features" className="border-b border-border bg-secondary/20">
<div className="mx-auto max-w-6xl px-4 py-20 sm:px-6 lg:py-28">
<div className="mx-auto max-w-3xl text-center">
<h2 className="text-balance text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
Everything you need to run serious backups
</h2>
</div>
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{features.map((feature) => (
<CornerCard key={feature.title} className="flex h-full flex-col gap-6 py-6">
<div className="grid auto-rows-min grid-rows-[auto_auto] items-start gap-4 px-6">
<feature.icon className="h-5 w-5 text-strong-accent" />
<h3 className="leading-none font-semibold text-foreground">{feature.title}</h3>
</div>
<div className="px-6">
<p className="text-sm leading-relaxed text-muted-foreground">{feature.description}</p>
</div>
</CornerCard>
))}
</div>
</div>
</section>
<section id="how-it-works" className="border-b border-border">
<div className="mx-auto max-w-6xl px-4 py-20 sm:px-6 lg:py-28">
<div className="mx-auto max-w-3xl text-center">
<h2 className="text-balance text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
From source to snapshot in four steps
</h2>
</div>
<div className="relative mt-16">
<div className="absolute left-6 top-0 hidden h-full w-px bg-border lg:left-1/2 lg:block" />
<div className="space-y-8 lg:space-y-12">
{steps.map((step, index) => (
<div
key={step.number}
className="relative flex flex-col gap-6 pl-16 lg:flex-row lg:items-center lg:gap-12 lg:pl-0"
>
<div className={`lg:w-1/2 ${index % 2 === 0 ? "lg:pr-12 lg:text-right" : "lg:order-2 lg:pl-12"}`}>
<h3 className="text-lg font-semibold text-foreground">{step.title}</h3>
<p className="mt-2 text-muted-foreground">{step.description}</p>
</div>
<div className="absolute left-0 top-0 flex h-12 w-12 shrink-0 items-center justify-center rounded-full border border-border bg-background text-lg font-bold text-strong-accent lg:left-1/2 lg:-translate-x-1/2">
{step.number}
</div>
<div className={`hidden lg:block lg:w-1/2 ${index % 2 === 0 ? "lg:order-2" : ""}`} />
</div>
))}
</div>
</div>
<div className="mt-16 text-center">
<Link to="/docs/$" params={{ _splat: "" }} className={outlineButtonClass}>
Read the Docs
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</div>
</section>
<section className="border-b border-border bg-secondary/20">
<div className="mx-auto max-w-6xl px-4 py-20 sm:px-6 lg:py-28">
<div className="mx-auto max-w-3xl">
<h2 className="text-balance text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
Built for the gap between raw CLI power and real-world operations
</h2>
<p className="mt-4 text-lg text-muted-foreground">
Restic is excellent at creating secure, efficient backups. Zerobyte makes that power practical day to
day.
</p>
<ul className="mt-10 space-y-4">
{benefits.map((benefit) => (
<li key={benefit} className="flex items-start gap-4">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-strong-accent/10">
<Check className="h-3.5 w-3.5 text-strong-accent" />
</div>
<span className="text-foreground">{benefit}</span>
</li>
))}
</ul>
</div>
</div>
</section>
<section id="faq" className="border-b border-border">
<div className="mx-auto max-w-6xl px-4 py-20 sm:px-6 lg:py-28">
<div className="mx-auto max-w-3xl">
<h2 className="text-balance text-center text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
Frequently asked questions
</h2>
<div className="mt-12">
<Accordion defaultValue={[faqs[0].question]}>
{faqs.map((faq) => (
<AccordionItem key={faq.question} value={faq.question}>
<AccordionTrigger className="py-4 text-sm font-medium text-foreground hover:text-strong-accent">
{faq.question}
</AccordionTrigger>
<AccordionContent className="pb-4 text-sm text-muted-foreground">{faq.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</div>
</div>
</section>
<section className="border-b border-border">
<div className="mx-auto max-w-6xl px-4 py-20 sm:px-6 lg:py-28">
<div className="mx-auto max-w-3xl text-center">
<h2 className="text-balance text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
Stop babysitting backup scripts
</h2>
<p className="mt-4 text-lg text-muted-foreground">
Give your setup a control plane that operators can actually use.
</p>
<div className="mt-10 flex flex-col items-center justify-center gap-4 sm:flex-row">
<Link to="/docs/$" params={{ _splat: "" }} className={primaryButtonClass}>
Documentation
<ArrowRight className="h-4 w-4" />
</Link>
<a href={repoUrl} target="_blank" rel="noopener noreferrer" className={outlineButtonClass}>
<Github className="h-4 w-4" />
View on GitHub
</a>
</div>
<p className="mt-8 text-sm text-muted-foreground">
Self-host Zerobyte and bring scheduling, visibility, restores, and repository maintenance into one
place.
</p>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}

View file

@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
type ThemeMode = "light" | "dark" | "auto";
function getInitialMode(): ThemeMode {
if (typeof window === "undefined") {
return "auto";
}
const stored = window.localStorage.getItem("theme");
if (stored === "light" || stored === "dark" || stored === "auto") {
return stored;
}
return "auto";
}
function applyThemeMode(mode: ThemeMode) {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const resolved = mode === "auto" ? (prefersDark ? "dark" : "light") : mode;
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(resolved);
if (mode === "auto") {
document.documentElement.removeAttribute("data-theme");
} else {
document.documentElement.setAttribute("data-theme", mode);
}
document.documentElement.style.colorScheme = resolved;
}
export default function ThemeToggle() {
const [mode, setMode] = useState<ThemeMode>("auto");
useEffect(() => {
const initialMode = getInitialMode();
setMode(initialMode);
applyThemeMode(initialMode);
}, []);
useEffect(() => {
if (mode !== "auto") {
return;
}
const media = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => applyThemeMode("auto");
media.addEventListener("change", onChange);
return () => {
media.removeEventListener("change", onChange);
};
}, [mode]);
function toggleMode() {
const nextMode: ThemeMode = mode === "light" ? "dark" : mode === "dark" ? "auto" : "light";
setMode(nextMode);
applyThemeMode(nextMode);
window.localStorage.setItem("theme", nextMode);
}
const label =
mode === "auto"
? "Theme mode: auto (system). Click to switch to light mode."
: `Theme mode: ${mode}. Click to switch mode.`;
return (
<button
type="button"
onClick={toggleMode}
aria-label={label}
title={label}
className="rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm font-semibold text-[var(--sea-ink)] shadow-[0_8px_22px_rgba(30,90,72,0.08)] transition hover:-translate-y-0.5"
>
{mode === "auto" ? "Auto" : mode === "dark" ? "Dark" : "Light"}
</button>
);
}

View file

@ -0,0 +1,60 @@
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
import { cn } from "#/lib/utils";
import { CaretDownIcon, CaretUpIcon } from "@phosphor-icons/react";
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
return <AccordionPrimitive.Root data-slot="accordion" className={cn("flex w-full flex-col", className)} {...props} />;
}
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
return (
<AccordionPrimitive.Item data-slot="accordion-item" className={cn("not-last:border-b", className)} {...props} />
);
}
function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-none border border-transparent py-2.5 text-left text-xs font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className,
)}
{...props}
>
{children}
<CaretDownIcon
data-slot="accordion-trigger-icon"
className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
/>
<CaretUpIcon
data-slot="accordion-trigger-icon"
className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({ className, children, ...props }: AccordionPrimitive.Panel.Props) {
return (
<AccordionPrimitive.Panel
data-slot="accordion-content"
className="overflow-hidden text-xs data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className,
)}
>
{children}
</div>
</AccordionPrimitive.Panel>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View file

@ -0,0 +1,49 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "#/lib/utils";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-none px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-none px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs": "size-6 rounded-none [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-none",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };

View file

@ -0,0 +1,30 @@
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
export function baseOptions(): BaseLayoutProps {
return {
nav: {
title: (
<>
<img src="/zerobyte.png" alt="" className="h-6 w-6 object-contain" />
<span>Zerobyte</span>
</>
),
},
links: [
{
type: "icon",
url: "https://discord.gg/MzBXz5v5XB",
text: "Discord",
label: "Discord",
icon: (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.369A19.791 19.791 0 0 0 15.44 3a13.967 13.967 0 0 0-.599 1.233 18.27 18.27 0 0 0-5.682 0A13.966 13.966 0 0 0 8.56 3a19.736 19.736 0 0 0-4.878 1.37C.598 9.04-.323 13.58.138 18.057A19.943 19.943 0 0 0 6.13 21a14.31 14.31 0 0 0 1.282-2.11 12.874 12.874 0 0 1-2.02-.964c.17-.123.336-.252.497-.385 3.897 1.78 8.148 1.78 12 0 .162.133.328.262.498.385a12.916 12.916 0 0 1-2.024.965A14.223 14.223 0 0 0 17.645 21a19.874 19.874 0 0 0 5.994-2.943c.541-5.19-.92-9.689-3.322-13.688ZM8.02 15.331c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.418 2.157-2.418 1.211 0 2.176 1.094 2.157 2.418 0 1.334-.955 2.419-2.157 2.419Zm7.96 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.418 2.157-2.418 1.211 0 2.176 1.094 2.157 2.418 0 1.334-.946 2.419-2.157 2.419Z" />
</svg>
),
external: true,
},
],
githubUrl: "https://github.com/nicotsx/zerobyte",
themeSwitch: { enabled: false },
};
}

7
docs/src/lib/source.ts Normal file
View file

@ -0,0 +1,7 @@
import { docs } from "fumadocs-mdx:collections/server";
import { loader } from "fumadocs-core/source";
export const source = loader({
baseUrl: "/docs",
source: docs.toFumadocsSource(),
});

6
docs/src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

104
docs/src/routeTree.gen.ts Normal file
View file

@ -0,0 +1,104 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DocsSplatRouteImport } from './routes/docs/$'
import { Route as ApiSearchRouteImport } from './routes/api/search'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const DocsSplatRoute = DocsSplatRouteImport.update({
id: '/docs/$',
path: '/docs/$',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSearchRoute = ApiSearchRouteImport.update({
id: '/api/search',
path: '/api/search',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/api/search': typeof ApiSearchRoute
'/docs/$': typeof DocsSplatRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/api/search': typeof ApiSearchRoute
'/docs/$': typeof DocsSplatRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/api/search': typeof ApiSearchRoute
'/docs/$': typeof DocsSplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/api/search' | '/docs/$'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/api/search' | '/docs/$'
id: '__root__' | '/' | '/api/search' | '/docs/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ApiSearchRoute: typeof ApiSearchRoute
DocsSplatRoute: typeof DocsSplatRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/docs/$': {
id: '/docs/$'
path: '/docs/$'
fullPath: '/docs/$'
preLoaderRoute: typeof DocsSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/api/search': {
id: '/api/search'
path: '/api/search'
fullPath: '/api/search'
preLoaderRoute: typeof ApiSearchRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ApiSearchRoute: ApiSearchRoute,
DocsSplatRoute: DocsSplatRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

19
docs/src/router.tsx Normal file
View file

@ -0,0 +1,19 @@
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: "intent",
defaultPreloadStaleTime: 0,
});
return router;
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>;
}
}

119
docs/src/routes/__root.tsx Normal file
View file

@ -0,0 +1,119 @@
import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router";
import { RootProvider } from "fumadocs-ui/provider/tanstack";
import Header from "../components/Header";
import appCss from "../styles.css?url";
const siteUrl = "https://zerobyte.app";
const ogImageUrl = `${siteUrl}/images/og.png`;
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: "utf-8",
},
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
{
title: "Zerobyte | Backup automation for Restic",
},
{
name: "description",
content:
"Zerobyte is a web control plane for Restic backups with scheduling, encrypted repositories, monitoring, and restore workflows.",
},
{
property: "og:title",
content: "Zerobyte | Backup automation for Restic",
},
{
property: "og:description",
content:
"Zerobyte is a web control plane for Restic backups with scheduling, encrypted repositories, monitoring, and restore workflows.",
},
{
property: "og:type",
content: "website",
},
{
property: "og:url",
content: siteUrl,
},
{
property: "og:image",
content: ogImageUrl,
},
{
property: "og:image:width",
content: "2048",
},
{
property: "og:image:height",
content: "1152",
},
{
property: "og:image:alt",
content: "Zerobyte backups dashboard preview",
},
{
name: "twitter:card",
content: "summary_large_image",
},
{
name: "twitter:title",
content: "Zerobyte | Backup automation for Restic",
},
{
name: "twitter:description",
content:
"Zerobyte is a web control plane for Restic backups with scheduling, encrypted repositories, monitoring, and restore workflows.",
},
{
name: "twitter:image",
content: ogImageUrl,
},
],
links: [
{
rel: "stylesheet",
href: appCss,
},
{ rel: "icon", type: "image/png", href: "/images/favicon/favicon-96x96.png", sizes: "96x96" },
{ rel: "icon", type: "image/svg+xml", href: "/images/favicon/favicon.svg" },
{ rel: "shortcut icon", href: "/images/favicon/favicon.ico" },
{ rel: "apple-touch-icon", sizes: "180x180", href: "/images/favicon/apple-touch-icon.png" },
{ rel: "manifest", href: "/images/favicon/site.webmanifest" },
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap",
},
],
}),
shellComponent: RootDocument,
});
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en" data-theme="dark" className="dark">
<head>
<script defer data-domain="zerobyte.app" src="https://assets.foreach.li/js/script.js"></script>
<HeadContent />
</head>
<body className="font-sans antialiased [overflow-wrap:anywhere] selection:bg-strong-accent/24">
<Header />
<RootProvider theme={{ defaultTheme: "dark", enabled: false }}>{children}</RootProvider>
<Scripts />
</body>
</html>
);
}

View file

@ -0,0 +1,16 @@
import { createFileRoute } from "@tanstack/react-router";
import { source } from "@/lib/source";
import { createFromSource } from "fumadocs-core/search/server";
const server = createFromSource(source, {
// https://docs.orama.com/docs/orama-js/supported-languages
language: "english",
});
export const Route = createFileRoute("/api/search")({
server: {
handlers: {
GET: async ({ request }) => server.GET(request),
},
},
});

View file

@ -0,0 +1,91 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import { DocsLayout } from "fumadocs-ui/layouts/docs";
import { createServerFn } from "@tanstack/react-start";
import { source } from "@/lib/source";
import browserCollections from "fumadocs-mdx:collections/browser";
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/layouts/docs/page";
import defaultMdxComponents from "fumadocs-ui/mdx";
import { Accordion, Accordions } from "fumadocs-ui/components/accordion";
import { Step, Steps } from "fumadocs-ui/components/steps";
import { baseOptions } from "@/lib/layout.shared";
import { useFumadocsLoader } from "fumadocs-core/source/client";
import { Suspense } from "react";
import { Card, Cards } from "@/components/DocsCard";
import { DocsMdxLink } from "@/components/DocsMdxLink";
export const Route = createFileRoute("/docs/$")({
component: Page,
loader: async ({ params }) => {
const slugs = params._splat?.split("/") ?? [];
const data = await serverLoader({ data: slugs });
await clientLoader.preload(data.path);
return data;
},
});
const serverLoader = createServerFn({
method: "GET",
})
.inputValidator((slugs: string[]) => slugs)
.handler(async ({ data: slugs }) => {
const page = source.getPage(slugs);
if (!page) throw notFound();
return {
path: page.path,
pageTree: await source.serializePageTree(source.getPageTree()),
};
});
const clientLoader = browserCollections.docs.createClientLoader({
component(
{ toc, frontmatter, default: MDX },
// you can define props for the component
_props: undefined,
) {
return (
<DocsPage
toc={toc}
tableOfContent={{
style: "clerk",
}}
>
<div className="">
<header className="docs-hero">
<DocsTitle className="font-extrabold">{frontmatter.title}</DocsTitle>
<div className="docs-description">
<DocsDescription>{frontmatter.description}</DocsDescription>
</div>
</header>
<div className="docs-prose-wrap">
<DocsBody>
<MDX
components={{
...defaultMdxComponents,
a: DocsMdxLink,
Accordion,
Accordions,
Card,
Cards,
Step,
Steps,
}}
/>
</DocsBody>
</div>
</div>
</DocsPage>
);
},
});
function Page() {
const data = useFumadocsLoader(Route.useLoaderData());
return (
<div data-docs-page className="relative">
<div aria-hidden className="landing-hero-docs-grid pointer-events-none absolute inset-0" />
<div className="relative">
<DocsLayout {...baseOptions()} tree={data.pageTree}>
<Suspense>{clientLoader.useContent(data.path)}</Suspense>
</DocsLayout>
</div>
</div>
);
}

View file

@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import LandingPage from "../components/LandingPage";
export const Route = createFileRoute("/")({ component: App });
function App() {
return <LandingPage />;
}

881
docs/src/styles.css Normal file
View file

@ -0,0 +1,881 @@
@import "tailwindcss";
@import "fumadocs-ui/css/neutral.css";
@import "fumadocs-ui/css/preset.css";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/jetbrains-mono";
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans:
"Geist", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
--font-display: "Geist", ui-sans-serif, system-ui, sans-serif;
}
:root {
--background:
oklch(1 0 0);
--foreground:
oklch(0.145 0 0);
--card:
oklch(1 0 0);
--card-foreground:
oklch(0.145 0 0);
--card-header: #1b1b1b;
--popover:
oklch(1 0 0);
--popover-foreground:
oklch(0.145 0 0);
--primary:
oklch(0.205 0 0);
--primary-foreground:
oklch(0.985 0 0);
--secondary:
oklch(0.97 0 0);
--secondary-foreground:
oklch(0.205 0 0);
--muted:
oklch(0.97 0 0);
--muted-foreground:
oklch(0.556 0 0);
--accent:
oklch(0.97 0 0);
--accent-foreground:
oklch(0.205 0 0);
--destructive:
oklch(0.577 0.245 27.325);
--destructive-foreground: #fff;
--border:
oklch(0.922 0 0);
--input:
oklch(0.922 0 0);
--ring:
oklch(0.708 0 0);
--strong-accent: #ff543a;
--sea-ink: var(--foreground);
--sea-ink-soft: var(--muted-foreground);
--lagoon: var(--strong-accent);
--lagoon-deep: var(--strong-accent);
--palm: oklch(0.556 0 0);
--sand: #0b0b0b;
--foam: var(--card-header);
--surface: rgba(19, 19, 19, 0.8);
--surface-strong: rgba(27, 27, 27, 0.92);
--line: var(--border);
--inset-glint: rgba(255, 255, 255, 0.05);
--kicker: var(--muted-foreground);
--bg-base: var(--background);
--header-bg: var(--background);
--chip-bg: var(--card-header);
--chip-line: var(--border);
--link-bg-hover: rgba(255, 255, 255, 0.07);
--hero-a: color-mix(in oklab, var(--strong-accent) 8%, transparent);
--hero-b: color-mix(in oklab, var(--strong-accent) 4%, transparent);
--site-header-offset: calc(4rem + 1px);
--chart-1:
oklch(0.87 0 0);
--chart-2:
oklch(0.556 0 0);
--chart-3:
oklch(0.439 0 0);
--chart-4:
oklch(0.371 0 0);
--chart-5:
oklch(0.269 0 0);
--radius:
0.625rem;
--sidebar:
oklch(0.985 0 0);
--sidebar-foreground:
oklch(0.145 0 0);
--sidebar-primary:
oklch(0.205 0 0);
--sidebar-primary-foreground:
oklch(0.985 0 0);
--sidebar-accent:
oklch(0.97 0 0);
--sidebar-accent-foreground:
oklch(0.205 0 0);
--sidebar-border:
oklch(0.922 0 0);
--sidebar-ring:
oklch(0.708 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-card-header: var(--card-header);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-strong-accent: var(--strong-accent);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--font-heading:
var(--font-mono);
--font-mono:
'JetBrains Mono Variable', monospace;
--color-sidebar-ring:
var(--sidebar-ring);
--color-sidebar-border:
var(--sidebar-border);
--color-sidebar-accent-foreground:
var(--sidebar-accent-foreground);
--color-sidebar-accent:
var(--sidebar-accent);
--color-sidebar-primary-foreground:
var(--sidebar-primary-foreground);
--color-sidebar-primary:
var(--sidebar-primary);
--color-sidebar-foreground:
var(--sidebar-foreground);
--color-sidebar:
var(--sidebar);
--color-chart-5:
var(--chart-5);
--color-chart-4:
var(--chart-4);
--color-chart-3:
var(--chart-3);
--color-chart-2:
var(--chart-2);
--color-chart-1:
var(--chart-1);
--radius-sm:
calc(var(--radius) * 0.6);
--radius-md:
calc(var(--radius) * 0.8);
--radius-lg:
var(--radius);
--radius-xl:
calc(var(--radius) * 1.4);
--radius-2xl:
calc(var(--radius) * 1.8);
--radius-3xl:
calc(var(--radius) * 2.2);
--radius-4xl:
calc(var(--radius) * 2.6);
}
.dark {
color-scheme: dark;
--color-fd-background: var(--background);
--color-fd-foreground: var(--foreground);
--color-fd-muted: var(--muted);
--color-fd-muted-foreground: var(--muted-foreground);
--color-fd-popover: var(--popover);
--color-fd-popover-foreground: var(--popover-foreground);
--color-fd-card: var(--card);
--color-fd-card-foreground: var(--card-foreground);
--color-fd-border: var(--border);
--color-fd-primary: var(--primary);
--color-fd-primary-foreground: var(--primary-foreground);
--color-fd-secondary: var(--secondary);
--color-fd-secondary-foreground: var(--secondary-foreground);
--color-fd-accent: var(--accent);
--color-fd-accent-foreground: var(--accent-foreground);
--color-fd-ring: var(--ring);
--card:
oklch(0.205 0 0);
--background:
oklch(0.145 0 0);
--foreground:
oklch(0.985 0 0);
--card-foreground:
oklch(0.985 0 0);
--popover:
oklch(0.205 0 0);
--popover-foreground:
oklch(0.985 0 0);
--primary:
oklch(0.922 0 0);
--primary-foreground:
oklch(0.205 0 0);
--secondary:
oklch(0.269 0 0);
--secondary-foreground:
oklch(0.985 0 0);
--muted:
oklch(0.269 0 0);
--muted-foreground:
oklch(0.708 0 0);
--accent:
oklch(0.269 0 0);
--accent-foreground:
oklch(0.985 0 0);
--destructive:
oklch(0.704 0.191 22.216);
--border:
oklch(1 0 0 / 10%);
--input:
oklch(1 0 0 / 15%);
--ring:
oklch(0.556 0 0);
--chart-1:
oklch(0.87 0 0);
--chart-2:
oklch(0.556 0 0);
--chart-3:
oklch(0.439 0 0);
--chart-4:
oklch(0.371 0 0);
--chart-5:
oklch(0.269 0 0);
--sidebar:
oklch(0.205 0 0);
--sidebar-foreground:
oklch(0.985 0 0);
--sidebar-primary:
oklch(0.488 0.243 264.376);
--sidebar-primary-foreground:
oklch(0.985 0 0);
--sidebar-accent:
oklch(0.269 0 0);
--sidebar-accent-foreground:
oklch(0.985 0 0);
--sidebar-border:
oklch(1 0 0 / 10%);
--sidebar-ring:
oklch(0.556 0 0);
}
.dark #nd-sidebar {
--color-fd-background: var(--background);
--color-fd-muted: var(--card-header);
--color-fd-secondary: var(--accent);
--color-fd-muted-foreground: var(--muted-foreground);
--color-fd-border: var(--border);
}
* {
box-sizing: border-box;
}
html,
body,
#app {
min-height: 100%;
}
body {
margin: 0;
color: var(--sea-ink);
font-family: var(--font-sans);
background-color: var(--bg-base);
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
background-image:
linear-gradient(to right, rgba(38, 38, 38, 0.6) 1px, transparent 1px),
linear-gradient(to bottom, rgba(38, 38, 38, 0.6) 1px, transparent 1px);
background-size: 40px 40px;
mask-image: radial-gradient(ellipse at top, black 70%, transparent 100%);
}
body:has([data-landing-page]) {
color: var(--foreground);
background-color: var(--background);
}
body:has([data-landing-page])::before {
display: none;
}
body:has([data-docs-page]) {
color: var(--foreground);
background-color: var(--background);
}
body:has([data-docs-page])::before {
display: none;
}
.landing-hero-docs-grid {
background-image:
linear-gradient(to right, rgba(38, 38, 38, 0.6) 1px, transparent 1px),
linear-gradient(to bottom, rgba(38, 38, 38, 0.6) 1px, transparent 1px);
background-size: 40px 40px;
mask-image: radial-gradient(ellipse at top, black 70%, transparent 100%);
}
.landing-hero-glow {
background: radial-gradient(ellipse at center, var(--hero-a) 0%, var(--hero-b) 28%, transparent 68%);
}
/* Ensure prose body text is readable against the dark background */
.prose {
--tw-prose-body: var(--sea-ink);
--tw-prose-headings: var(--sea-ink);
--tw-prose-lead: var(--sea-ink-soft);
--tw-prose-bold: var(--sea-ink);
--tw-prose-counters: var(--sea-ink-soft);
--tw-prose-bullets: var(--sea-ink-soft);
--tw-prose-code: var(--sea-ink);
--tw-prose-quotes: var(--sea-ink);
}
/* Custom styling to bring some orange flavor to headers/links if desired without ruining active states */
.prose a {
color: var(--lagoon);
text-decoration-color: rgba(255, 84, 58, 0.4);
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
.prose a:hover {
color: var(--lagoon-deep);
}
.prose h1,
.prose h2,
.prose h3,
.prose h4,
.prose h5,
.prose h6 {
color: var(--sea-ink);
font-family: var(--font-display);
}
/* Fix heading links not to be orange */
.prose h1 a,
.prose h2 a,
.prose h3 a,
.prose h4 a,
.prose h5 a,
.prose h6 a {
color: inherit;
text-decoration: none;
}
.page-wrap {
width: min(1320px, calc(100% - 2rem));
margin-inline: auto;
}
.display-title {
font-family: var(--font-display);
}
.marketing-panel {
position: relative;
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(180deg, rgba(16, 18, 22, 0.94), rgba(9, 11, 14, 0.9));
box-shadow:
0 1px 0 var(--inset-glint) inset,
0 28px 80px rgba(0, 0, 0, 0.42),
0 10px 32px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(18px);
}
.marketing-window {
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.015));
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 20px 44px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(14px);
}
.marketing-grid-bg {
background-image:
linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
background-size: 40px 40px;
mask-image: radial-gradient(ellipse at center, black 54%, transparent 100%);
}
.marketing-kicker {
margin: 0;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--sea-ink-soft);
}
.marketing-title,
.marketing-section-title {
font-family: var(--font-display);
letter-spacing: -0.05em;
text-wrap: balance;
}
.marketing-button-primary,
.marketing-button-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
border-radius: 1rem;
padding: 0.92rem 1.3rem;
font-size: 0.95rem;
font-weight: 600;
text-decoration: none;
transition:
transform 180ms ease,
background-color 180ms ease,
border-color 180ms ease,
color 180ms ease,
box-shadow 180ms ease;
}
.marketing-button-primary {
border: 1px solid rgba(255, 84, 58, 0.22);
background: linear-gradient(180deg, rgba(255, 84, 58, 0.98), rgba(229, 64, 38, 0.94));
color: white;
box-shadow: 0 14px 36px rgba(255, 84, 58, 0.2);
}
.marketing-button-primary:hover {
transform: translateY(-1px);
box-shadow: 0 18px 44px rgba(255, 84, 58, 0.24);
}
.marketing-button-secondary {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.035);
color: var(--sea-ink);
}
.marketing-button-secondary:hover {
transform: translateY(-1px);
border-color: rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.06);
}
.marketing-button-primary:active,
.marketing-button-secondary:active {
transform: translateY(1px);
}
.marketing-button-primary:focus-visible,
.marketing-button-secondary:focus-visible,
.marketing-link:focus-visible,
.nav-link:focus-visible {
outline: 2px solid rgba(255, 84, 58, 0.55);
outline-offset: 3px;
}
.marketing-link {
color: var(--sea-ink-soft);
text-decoration: none;
transition: color 160ms ease;
}
.marketing-link:hover {
color: var(--sea-ink);
}
.marketing-stat {
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.012));
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.24);
backdrop-filter: blur(12px);
}
.marketing-check {
display: flex;
align-items: flex-start;
gap: 0.9rem;
}
.marketing-dot {
display: inline-flex;
height: 0.62rem;
width: 0.62rem;
border-radius: 999px;
}
.marketing-keycap {
display: flex;
min-height: 3.4rem;
align-items: center;
justify-content: center;
border-radius: 0.9rem;
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.015));
color: var(--sea-ink-soft);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18);
}
.marketing-keycap.is-active {
border-color: rgba(255, 84, 58, 0.22);
background: linear-gradient(180deg, rgba(255, 84, 58, 0.2), rgba(255, 84, 58, 0.08));
color: var(--sea-ink);
box-shadow: 0 0 0 1px rgba(255, 84, 58, 0.08) inset;
}
.island-shell {
border: 1px solid var(--line);
background: linear-gradient(165deg, var(--surface-strong), var(--surface));
box-shadow:
0 1px 0 var(--inset-glint) inset,
0 22px 44px rgba(0, 0, 0, 0.3),
0 6px 18px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(4px);
}
.feature-card {
background: linear-gradient(165deg, color-mix(in oklab, var(--surface-strong) 93%, black 7%), var(--surface));
box-shadow:
0 1px 0 var(--inset-glint) inset,
0 18px 34px rgba(0, 0, 0, 0.3),
0 4px 14px rgba(0, 0, 0, 0.2);
}
.feature-card:hover {
border-color: color-mix(in oklab, var(--lagoon-deep) 35%, var(--line));
}
button,
.island-shell,
.prose a,
.marketing-panel,
.marketing-window,
.marketing-stat {
transition:
background-color 180ms ease,
color 180ms ease,
border-color 180ms ease,
transform 180ms ease;
}
@media (max-width: 640px) {
.marketing-button-primary,
.marketing-button-secondary {
width: 100%;
}
}
.island-kicker {
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 700;
font-size: 0.69rem;
color: var(--kicker);
}
.nav-link {
position: relative;
display: inline-flex;
align-items: center;
text-decoration: none;
color: var(--sea-ink-soft);
}
.nav-link::after {
content: "";
position: absolute;
left: 0;
bottom: -6px;
width: 100%;
height: 2px;
transform: scaleX(0);
transform-origin: left;
background: var(--lagoon);
transition: transform 170ms ease;
}
.nav-link:hover,
.nav-link.is-active {
color: var(--sea-ink);
}
.nav-link:hover::after,
.nav-link.is-active::after {
transform: scaleX(1);
}
@media (max-width: 640px) {
.nav-link::after {
bottom: -4px;
}
}
.site-footer {
border-top: 1px solid var(--line);
background: color-mix(in oklab, var(--header-bg) 84%, transparent 16%);
}
.rise-in {
animation: rise-in 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/*
Docs refinements
*/
/* --- Scrollbar -------------------------------------------------- */
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.12) transparent;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
/* --- Sidebar ---------------------------------------------------- */
/* Active link: soft orange tint + left accent bar */
.dark #nd-sidebar a[data-active="true"] {
background-color: transparent;
color: var(--sea-ink);
border-radius: 0;
box-shadow: none;
position: relative;
}
.dark #nd-sidebar a[data-active="true"]::after {
content: "";
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 2px;
border-radius: 0;
background: var(--lagoon);
}
/* Hover: subtler than default */
.dark #nd-sidebar a:not([data-active="true"]):hover {
background-color: transparent;
color: var(--sea-ink);
}
/* Sidebar transitions */
.dark #nd-sidebar a {
border-radius: 0;
transition:
background-color 150ms ease,
color 150ms ease;
}
/* Sidebar search trigger — field-like appearance */
.dark #nd-sidebar button[data-search] {
background: var(--card);
border: 1px solid var(--line);
border-radius: 0;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
}
/* --- Table of Contents ------------------------------------------ */
/* "On this page" title: uppercase kicker style, hide the icon */
#nd-toc > h3 {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--kicker);
}
#nd-toc > h3 svg {
display: none;
}
/* TOC: color the floating active indicator bar orange */
#nd-toc .bg-fd-primary {
background-color: var(--lagoon);
}
/* TOC link: active text + hover state */
#nd-toc a {
transition: color 150ms ease;
}
#nd-toc a[data-active="true"] {
color: var(--sea-ink);
}
#nd-toc a:not([data-active="true"]):hover {
color: oklch(0.85 0 0);
}
/* --- Headings --------------------------------------------------- */
.prose h2 {
margin-top: 2.5em;
padding-bottom: 0.5em;
border-bottom: 1px solid var(--line);
}
/* --- Body text -------------------------------------------------- */
.prose {
--tw-prose-body: oklch(0.78 0 0);
}
/* --- List markers ----------------------------------------------- */
.prose :where(ul > li)::marker {
color: rgba(255, 84, 58, 0.5);
}
.prose :where(ol > li)::marker {
color: var(--lagoon);
font-family: var(--font-display);
font-weight: 600;
}
/* --- Cards ------------------------------------------------------ */
[data-card="true"] {
border-radius: 0 !important;
background: color-mix(in oklab, var(--card) 88%, var(--card-header) 12%);
border-color: var(--line);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
transition:
border-color 180ms ease,
transform 180ms ease,
background-color 180ms ease,
box-shadow 180ms ease;
}
a[data-card="true"]:hover {
border-color: color-mix(in oklab, var(--lagoon) 22%, var(--line));
transform: translateY(-1px);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.03),
0 18px 36px -30px rgba(0, 0, 0, 0.9);
}
/* Card icon containers */
[data-card="true"] .not-prose.w-fit {
background: rgba(255, 84, 58, 0.06);
border-color: rgba(255, 84, 58, 0.15);
border-radius: 0 !important;
color: var(--lagoon);
box-shadow: none;
}
/* --- Steps ------------------------------------------------------ */
.fd-step::before {
color: var(--lagoon);
}
/* --- Callouts --------------------------------------------------- */
div[style*="--callout-color"] {
border-radius: 8px;
background: color-mix(in oklab, var(--card) 88%, var(--callout-color) 12%);
border-color: var(--line);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
}
/* --- Tables ----------------------------------------------------- */
.prose table th,
.prose table td {
vertical-align: top;
white-space: normal;
word-break: normal;
overflow-wrap: break-word;
}
/* --- Bottom prev/next nav --------------------------------------- */
#nd-page > div:last-child > a {
border-radius: 0;
background: color-mix(in oklab, var(--card) 88%, var(--card-header) 12%);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
transition:
border-color 180ms ease,
transform 180ms ease,
background-color 180ms ease;
}
#nd-page > div:last-child > a:hover {
border-color: color-mix(in oklab, var(--lagoon) 18%, var(--line));
transform: translateY(-1px);
background: color-mix(in oklab, var(--card-header) 24%, var(--card) 76%);
}
/* --- Code blocks ------------------------------------------------ */
.prose figure.shiki {
border: 1px solid var(--line);
box-shadow: none;
background: #0b0b0b;
}
.prose figure.shiki > [role="region"] {
background: #0b0b0b;
}
.prose figure.shiki pre {
border: 0;
background: transparent;
}
/* --- Inline code ------------------------------------------------ */
.prose :where(code):not(:where(pre *)) {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 5px;
padding: 0.15em 0.35em;
font-size: 0.875em;
}
/* --- Sticky header offset --------------------------------------- */
#nd-docs-layout {
background: var(--background);
/* Treat the global site header as the docs layout banner offset. */
--fd-banner-height: var(--site-header-offset);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-mono;
}
}

29
docs/tsconfig.json Normal file
View file

@ -0,0 +1,29 @@
{
"include": ["**/*.ts", "**/*.tsx"],
"compilerOptions": {
"target": "ES2022",
"jsx": "react-jsx",
"module": "ESNext",
"paths": {
"#/*": ["./src/*"],
"@/*": ["./src/*"],
"fumadocs-mdx:collections/*": ["./.source/*"]
},
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
/* Linting */
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
}
}

28
docs/vite.config.ts Normal file
View file

@ -0,0 +1,28 @@
import { defineConfig } from "vite";
import { devtools } from "@tanstack/devtools-vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import { cloudflare } from "@cloudflare/vite-plugin";
import viteReact from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import mdx from "fumadocs-mdx/vite";
import * as MdxConfig from "./source.config";
const config = defineConfig({
server: {
port: 4000,
},
plugins: [
mdx(MdxConfig),
devtools(),
tsconfigPaths({ projects: ["./tsconfig.json"] }),
tailwindcss(),
cloudflare({ viteEnvironment: { name: "ssr" } }),
tanstackStart(),
viteReact(),
],
});
export default config;

7
docs/wrangler.jsonc Normal file
View file

@ -0,0 +1,7 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "zerobyte-docs",
"compatibility_date": "2025-09-02",
"compatibility_flags": ["nodejs_compat"],
"main": "@tanstack/react-start/server-entry",
}

2
public/robots.txt Normal file
View file

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View file

@ -42,7 +42,7 @@ export default defineConfig({
printWidth: 120, printWidth: 120,
useTabs: true, useTabs: true,
endOfLine: "lf", endOfLine: "lf",
ignorePatterns: ["*.gen.ts"], ignorePatterns: ["*.gen.ts", "**/.source"],
}, },
lint: { lint: {
plugins: ["eslint", "unicorn", "typescript", "oxc", "import", "react", "react-perf", "node", "jsx-a11y"], plugins: ["eslint", "unicorn", "typescript", "oxc", "import", "react", "react-perf", "node", "jsx-a11y"],
@ -67,7 +67,7 @@ export default defineConfig({
env: { env: {
builtin: true, builtin: true,
}, },
ignorePatterns: ["**/api-client/**"], ignorePatterns: ["**/api-client/**", "docs/**"],
overrides: [ overrides: [
{ {
files: ["**/*.test.ts", "**/*.test.tsx"], files: ["**/*.test.ts", "**/*.test.tsx"],