feat(docs): end-user documentation site at docs.futo.cloud (#623)

packages/docs is a SvelteKit site where every page is a +page.md compiled
by @immich/svelte-markdown-preprocess into @immich/ui components, deployed
to Cloudflare Pages the way static-pages deploys the immich.app sites
(tf/pages/docs; main -> docs.futo.cloud, every PR gets a preview).

The content is written for beta users: joining the beta, setting up in
Immich or the standalone container, the recovery key, backups, schedules,
restores, the account dashboard, troubleshooting and support. Internal and
developer documentation stays in docs/ and the per-directory READMEs.
This commit is contained in:
Zack Pollard
2026-09-14 07:41:44 -07:00
committed by GitHub
parent 7e25b14278
commit 5a144405cf
68 changed files with 2427 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
name: Docs preview destroy
on:
# zizmor: ignore[dangerous-triggers]
# Runs the base branch's teardown; never executes PR code.
pull_request_target:
types: [closed]
paths:
- 'packages/docs/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.npmrc'
- '.mise/config.toml'
- '.mise/tasks/docs/**'
- 'tf/pages/**'
- 'tf/op-run.sh'
- '.github/workflows/docs.yml'
workflow_dispatch:
inputs:
pr_number:
description: 'PR number whose preview (stage pr-<number>) to tear down'
required: true
type: string
permissions:
contents: read
jobs:
destroy:
name: Destroy preview
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: write
env:
ENVIRONMENT: dev
TF_VAR_stage: pr-${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.number }}
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_TF_YUCCA_STAGING_ENV }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Mise
uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0
- name: Install 1Password CLI
uses: 1password/install-cli-action@a5215d3a7f75c1629216c465ea9ab3ab399c4b71 # v4.0.0
- run: mise run docs:destroy
- name: Remove preview comment
if: ${{ github.event_name == 'pull_request_target' }}
uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
with:
id: docs-preview
delete: true
+97
View File
@@ -0,0 +1,97 @@
name: Docs
# main deploys docs.futo.cloud; a PR gets a docs.pr-<n>.dev.futo.cloud preview
# (torn down by docs-destroy.yml).
on:
push:
branches: [main]
paths: &paths
- 'packages/docs/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.npmrc'
- '.mise/config.toml'
- '.mise/tasks/docs/**'
- 'tf/pages/**'
- 'tf/op-run.sh'
- '.github/workflows/docs.yml'
pull_request:
paths: *paths
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
ENVIRONMENT: ${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }}
TF_VAR_stage: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || '' }}
jobs:
build:
name: Build
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Mise
uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0
- run: mise run install:frozen
- run: mise run docs:build
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: docs-build
path: packages/docs/build
include-hidden-files: true
if-no-files-found: error
retention-days: 1
deploy:
name: Deploy
needs: [build]
if: ${{ !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
pull-requests: write
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ github.ref == 'refs/heads/main' && secrets.OP_TF_YUCCA_PROD_ENV || secrets.OP_TF_YUCCA_STAGING_ENV }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Mise
uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0
- name: Install 1Password CLI
uses: 1password/install-cli-action@a5215d3a7f75c1629216c465ea9ab3ab399c4b71 # v4.0.0
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: docs-build
path: packages/docs/build
- id: deploy
run: mise run docs:deploy
- name: Preview comment
if: ${{ github.event_name == 'pull_request' }}
uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
with:
id: docs-preview
body: |
<!-- docs-preview -->
### Docs preview ([${{ github.event.pull_request.head.sha }}](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.event.pull_request.head.sha }}))
https://${{ steps.deploy.outputs.hostname }}
+1
View File
@@ -10,6 +10,7 @@ node_modules
# Exception: tf/.env is committed — contains only op:// references to 1P items,
# no literal secrets. Resolved at runtime by `op run --env-file=tf/.env -- ...`.
!tf/.env
!tf/pages/.env
mise.local.toml
+1
View File
@@ -6,6 +6,7 @@ config_roots = ["ansible/ceph", "ansible/talos", "packages/restic-proxy"]
[tools]
node = "25.9.0"
pnpm = "10.28.1"
wrangler = "4.127.0"
go = "1.27.0"
golangci-lint = "2.13.1"
restic = "0.19.1"
+8
View File
@@ -490,3 +490,11 @@ checksum = "sha256:f86836c637333c65bbc7902acc9c49888eef9fbd15dccbc1946b10e30b041
url = "https://github.com/astral-sh/uv/releases/download/0.9.18/uv-x86_64-apple-darwin.tar.gz"
url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/329397657"
provenance = "github-attestations"
[[tools.wrangler]]
version = "4.127.0"
backend = "npm:wrangler"
specifiers = ["4.127.0"]
[tools.wrangler.options]
allow_builds = '["esbuild", "sharp", "workerd"]'
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Build docs"
set -e
NODE_ENV=production pnpm --filter docs build
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run Svelte checks for docs"
set -e
pnpm --filter docs check
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
#MISE description="Publish packages/docs/build to Cloudflare Pages (ENVIRONMENT=prod|dev, TF_VAR_stage=pr-<n> for a preview)"
set -euo pipefail
export ENVIRONMENT="${ENVIRONMENT:-dev}"
export TF_VAR_stage="${TF_VAR_stage:-}"
export OP_ENV_FILE=tf/pages/.env
[ -d packages/docs/build ] || { echo "docs:deploy: packages/docs/build is missing — run mise docs:build first" >&2; exit 1; }
tf/op-run.sh terragrunt run --all apply --non-interactive --parallelism 1 --working-dir tf/pages/docs
outputs="$(op run --env-file="$OP_ENV_FILE" --no-masking -- terragrunt output -json --working-dir tf/pages/docs/site)"
project="$(jq -r .pages_project_name.value <<<"$outputs")"
branch="$(jq -r .pages_branch.value <<<"$outputs")"
hostname="$(jq -r .hostname.value <<<"$outputs")"
tf/op-run.sh wrangler pages deploy packages/docs/build --project-name="$project" --branch="$branch" --commit-dirty=true
echo "deployed https://${hostname}"
if [ -n "${GITHUB_OUTPUT:-}" ]; then
echo "hostname=${hostname}" >> "$GITHUB_OUTPUT"
fi
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
#MISE description="Tear down a docs preview stage (TF_VAR_stage=pr-<n>); the shared Pages project is kept"
set -euo pipefail
export ENVIRONMENT="${ENVIRONMENT:-dev}"
export OP_ENV_FILE=tf/pages/.env
: "${TF_VAR_stage:?set TF_VAR_stage=pr-<n> to name the preview to tear down}"
export TF_VAR_stage
tf/op-run.sh terragrunt run --all destroy --non-interactive --queue-exclude-external --working-dir tf/pages/docs/site -- -refresh=false -lock-timeout=5m
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
#MISE description="Run docs in development mode"
set -e
export DOCS_PORT=${DOCS_PORT:-36034}
pnpm --filter docs dev --host --port $DOCS_PORT
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
#MISE description="Run docs in preview mode"
#MISE depends=["docs:build"]
set -e
pnpm --filter docs preview --host
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run unit tests for docs"
set -e
pnpm --filter docs test:unit "$@"
+7
View File
@@ -52,6 +52,7 @@ Yucca is a multi-tenant **backup service**: OIDC-authenticated users get S3-back
```bash
mise dev # compose-based dev: deps, docker infra (postgres/minio/mock-oidc/victoria-*), all *:dev
mise <pkg>:dev # one service, e.g. mise web:dev, mise yucca-api:dev
mise docs:dev # docs site (packages/docs) on :36034
mise check # lint + format check + svelte-check + unit tests (= the `checks` CI job)
mise fix # autofix lint/format + lingui extract
@@ -118,6 +119,12 @@ Zod-validated `env.ts`, JWT auth guards via `@AuthRoute()`, OTel from `@common/s
**Frontend** (`packages/web`): SvelteKit 5 + Tailwind 4, `@immich/ui`, lingui i18n
(`mise web:lingui:*`; compiled locales are generated, not edited), generated API client.
**Docs** (`packages/docs`, https://docs.futo.cloud): the **end-user** documentation site (beta setup
guides for Immich and the standalone container). SvelteKit + `adapter-static`; every page is a
`src/routes/<section>/<slug>/+page.md` compiled by `@immich/svelte-markdown-preprocess` (front matter
`title`/`description`/`order`; sections in `src/lib/index.ts`). Deployed to Cloudflare Pages
(`tf/pages/docs` + `.github/workflows/docs.yml`; every PR gets a preview). Internal/developer docs stay
in `docs/` and the per-directory READMEs — do not move them onto the site.
**`packages/yucca-sdk/`** (orchestration-api + orchestration-ui) is separately versioned and
added explicitly in `pnpm-workspace.yaml`.
+2
View File
@@ -5,6 +5,8 @@ Application code lives under `packages/`. Infrastructure that operates Yucca
Terraform+1Password) lives at the top level in `ansible/`, `tf/`, and
`kubernetes/`.
End-user documentation is published at https://docs.futo.cloud from `packages/docs`.
## Development Guide (application)
Ensure you have prerequisites installed:
+19
View File
@@ -0,0 +1,19 @@
node_modules
# Output
/.svelte-kit
/build
/dist
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+100
View File
@@ -0,0 +1,100 @@
# docs
The FUTO Backups end-user documentation site, published at https://docs.futo.cloud.
This site is a SvelteKit app, published at [docs.futo.cloud](https://docs.futo.cloud). It is the **end-user** documentation for FUTO Backups; internal and developer documentation stays in `docs/` and the per-directory READMEs. Every page is a markdown file that is compiled to a Svelte component at build time by [@immich/svelte-markdown-preprocess](https://www.npmjs.com/package/@immich/svelte-markdown-preprocess). Markdown elements render as the `Markdown` components from [@immich/ui](https://ui.immich.app), so the pages share the look of the app.
## Running the site
```bash
mise docs:dev # dev server with hot reload on http://localhost:36034
mise docs:check # svelte-check
mise docs:test # unit tests for the page index
mise docs:build # static build into packages/docs/build
```
`mise check` and `mise build` include the docs, so CI builds the site on every pull request.
## Adding a page
Pages live under `src/routes/<section>/<slug>/+page.md`. The section is the folder name and must be one of the sections declared in `src/lib/index.ts`; the slug becomes the URL. The introduction is the one exception and lives at `src/routes/+page.md`.
Every page starts with front matter:
```markdown
---
title: Feature flags
description: Per-user product gating, and why the set of flags is code.
order: 2
---
```
| Field | Required | Purpose |
| ------------- | -------- | -------------------------------------------------------------------- |
| `title` | yes | Page heading, sidebar entry and browser title. |
| `description` | yes | One sentence, no trailing period; shown under the title and in search |
| `order` | no | Position within the section. Pages without one sort last, by title. |
Do not repeat the title as an `# H1` in the body; the layout renders it. Start the body with paragraphs or `##` headings. Level two and three headings appear in the table of contents and get anchor ids derived from their text, so keep inline code out of headings.
The build fails when a page is missing a required field or sits in an unknown section, and the unit tests in `src/lib/index.spec.ts` check that every markdown file is picked up.
## Adding a section
Add an entry to `sections` in `src/lib/index.ts` with an id (the folder name), a title and an icon from `@mdi/js`. Sections appear in the sidebar in the order they are declared.
## Markdown features
Standard [GitHub flavored markdown](https://github.github.com/gfm/) works: headings, lists, tables, task lists, links, images, emphasis, inline code and fenced code blocks with syntax highlighting.
### Alerts
GitHub alerts are supported, as is a `:::` admonition block with an optional custom title:
```markdown
> [!TIP]
> Helpful advice for doing things better.
:::warning Heads up
Content that spans multiple paragraphs.
:::
```
> [!TIP]
> Helpful advice for doing things better.
:::warning Heads up
Content that spans multiple paragraphs.
:::
The variants are `note`, `tip`, `important`, `warning`, `caution`, `info`, `success` and `danger`.
### Images
Reference images with a relative path and they are bundled with the page:
```markdown
![The dashboard after the first backup](./dashboard.webp)
```
Keep images next to the page that uses them. Prefer `webp` for screenshots.
### Svelte in markdown
A page can use Svelte components, which is handy for interactive examples. Import them in a `<script>` block placed at the very top of the file, right after the front matter, then use them in the body like HTML:
```markdown
<Button>Click me</Button>
```
The whole page is compiled as one Svelte component, so a `<script>` block anywhere else is hoisted to the top and the markdown before it is lost.
Because the page is compiled as Svelte, curly braces and tag-like text such as `<name>` have meaning in prose and break the build. Put such text in inline code, which needs no escaping; there is no escape sequence for a literal brace outside of code. A lone `<` followed by a space, as in `1 < 2`, is fine.
## Previews and deployment
The Docs workflow publishes the site to Cloudflare Pages. Every pull request that touches the documentation gets its own preview at `docs.pr-<number>.dev.futo.cloud`, linked from a comment on the pull request and removed when it closes; merging to `main` deploys [docs.futo.cloud](https://docs.futo.cloud). The Pages project and its domains are Terraform under `tf/pages/docs`, and `mise docs:deploy` and `mise docs:destroy` are the tasks behind the workflow.
## Search
The build writes `/data/search.json` with the plain text of every page. The command palette (press <kbd>Ctrl</kbd> + <kbd>K</kbd>) searches it, so there is nothing to configure when adding a page.
+39
View File
@@ -0,0 +1,39 @@
{
"name": "docs",
"private": true,
"version": "0.38.0",
"type": "module",
"files": [
"build"
],
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:unit": "vitest run",
"test": "npm run test:unit -- --run"
},
"devDependencies": {
"@sveltejs/adapter-static": "catalog:",
"@sveltejs/kit": "catalog:",
"@sveltejs/vite-plugin-svelte": "catalog:",
"@tailwindcss/vite": "catalog:",
"@types/node": "catalog:",
"svelte": "catalog:",
"svelte-check": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:"
},
"dependencies": {
"@immich/svelte-markdown-preprocess": "catalog:",
"@immich/ui": "catalog:",
"@mdi/js": "catalog:",
"front-matter": "catalog:",
"marked": "catalog:"
}
}
+13
View File
@@ -0,0 +1,13 @@
@import 'tailwindcss';
@import '@immich/ui/theme/default.css';
@source '../node_modules/@immich/ui';
article td {
display: table-cell;
overflow: visible;
-webkit-line-clamp: none;
text-align: start;
text-overflow: clip;
word-break: normal;
overflow-wrap: anywhere;
}
+9
View File
@@ -0,0 +1,9 @@
import type { DocsPageMeta } from '$lib';
declare global {
namespace App {
interface PageData {
pages: DocsPageMeta[];
}
}
}
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="currentColor" />
<script>
try {
const preference = JSON.parse(localStorage.getItem('immich-ui-theme'));
const prefersDark = globalThis.matchMedia('(prefers-color-scheme: dark)').matches;
if (preference === 'light' || (preference !== 'dark' && !prefersDark)) {
document.documentElement.classList.remove('dark');
document.documentElement.classList.add('light');
}
} catch {}
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="bg-light text-dark">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
@@ -0,0 +1,53 @@
<script lang="ts">
import { page } from '$app/state';
import { getEditUrl, getPage, getSection, siteMetadata } from '$lib';
import PageNavigation from '$lib/components/PageNavigation.svelte';
import TableOfContents from '$lib/components/TableOfContents.svelte';
import { Breadcrumbs, Heading, Icon, Link, SiteMetadata, Text } from '@immich/ui';
import { mdiPencilOutline } from '@mdi/js';
import type { Snippet } from 'svelte';
type Props = {
attributes: { title: string; description: string };
children?: Snippet;
};
const { attributes, children }: Props = $props();
const doc = $derived(getPage(page.data.pages, page.route.id));
const section = $derived(doc && getSection(doc));
const breadcrumbs = $derived(
doc?.url === '/'
? []
: [{ title: 'Docs', href: '/' }, ...(section ? [{ title: section.title }] : []), { title: attributes.title }],
);
</script>
<SiteMetadata site={siteMetadata} page={{ title: attributes.title, description: attributes.description }} />
<div class="flex gap-12">
<article class="min-w-0 grow">
{#if breadcrumbs.length > 0}
<Breadcrumbs items={breadcrumbs} class="text-muted text-sm" />
{/if}
<Heading tag="h1" size="giant" class="mt-4">{attributes.title}</Heading>
<Text color="muted" size="large" class="mt-2">{attributes.description}</Text>
<div class="mt-8">
{@render children?.()}
</div>
{#if doc}
<div class="mt-12 flex items-center gap-1 text-sm">
<Icon icon={mdiPencilOutline} size="1rem" />
<Link href={getEditUrl(doc.path)}>Edit this page on GitHub</Link>
</div>
<PageNavigation pages={page.data.pages} {doc} />
{/if}
</article>
{#if doc && doc.headings.length > 1}
<TableOfContents headings={doc.headings} />
{/if}
</div>
@@ -0,0 +1,43 @@
<script lang="ts">
import { getNeighbours, type DocsPageMeta } from '$lib';
import { Icon, Text } from '@immich/ui';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
type Props = {
pages: DocsPageMeta[];
doc: DocsPageMeta;
};
const { pages, doc }: Props = $props();
const { previous, next } = $derived(getNeighbours(pages, doc));
</script>
<nav class="mt-8 grid gap-4 border-t pt-8 sm:grid-cols-2" aria-label="Page navigation">
{#if previous}
<a
href={previous.url}
class="hover:border-primary hover:text-primary flex items-center gap-2 rounded-xl border p-4 transition-colors"
>
<Icon icon={mdiChevronLeft} size="1.5rem" />
<div class="min-w-0">
<Text color="muted" size="tiny">Previous</Text>
<Text fontWeight="semi-bold" class="truncate">{previous.title}</Text>
</div>
</a>
{:else}
<div></div>
{/if}
{#if next}
<a
href={next.url}
class="hover:border-primary hover:text-primary flex items-center justify-end gap-2 rounded-xl border p-4 text-end transition-colors"
>
<div class="min-w-0">
<Text color="muted" size="tiny">Next</Text>
<Text fontWeight="semi-bold" class="truncate">{next.title}</Text>
</div>
<Icon icon={mdiChevronRight} size="1.5rem" />
</a>
{/if}
</nav>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { Heading } from '$lib';
import { Text } from '@immich/ui';
type Props = {
headings: Heading[];
};
const { headings }: Props = $props();
</script>
<aside class="hidden w-56 shrink-0 xl:block" aria-label="On this page">
<div class="sticky top-24">
<Text color="muted" size="small" fontWeight="semi-bold" class="uppercase">On this page</Text>
<ul class="mt-3 flex flex-col gap-2 border-s text-sm">
{#each headings as heading (heading.id)}
<li class={heading.level === 3 ? 'ps-6' : 'ps-3'}>
<a href="#{heading.id}" class="text-muted hover:text-primary block transition-colors">{heading.text}</a>
</li>
{/each}
</ul>
</div>
</aside>
+12
View File
@@ -0,0 +1,12 @@
export const siteMetadata = {
title: 'FUTO Backups Docs',
description: 'Set up FUTO Backups in Immich or the standalone container, back up your data, and get it back.',
};
export const Links = {
App: 'https://backups.futo.cloud',
Repository: 'https://github.com/immich-app/yucca',
Futo: 'https://futo.org',
};
export const getEditUrl = (path: string) => `${Links.Repository}/edit/main/packages/docs/${path}`;
+44
View File
@@ -0,0 +1,44 @@
import { getNeighbours, getPage, getPagesForSection, getSection, sections, type DocsPageMeta } from '$lib';
import { describe, expect, test } from 'vitest';
const meta = (url: string, sectionId?: string): DocsPageMeta => ({
title: url,
description: url,
order: 1,
url,
sectionId,
path: `src/routes${url}/+page.md`,
headings: [],
});
const pages = [meta('/'), meta('/getting-started/a', 'getting-started'), meta('/development/b', 'development')];
describe('sections', () => {
test('have unique ids', () => {
expect(new Set(sections.map((section) => section.id)).size).toBe(sections.length);
});
test('resolve from a page', () => {
expect(getSection(pages[1])?.id).toBe('getting-started');
expect(getSection(pages[0])).toBeUndefined();
});
});
describe('pages', () => {
test('filter by section', () => {
expect(getPagesForSection(pages, sections[0])).toEqual([pages[1]]);
expect(getPagesForSection(pages, sections[2])).toEqual([]);
});
test('find a page by route id', () => {
expect(getPage(pages, '/')).toBe(pages[0]);
expect(getPage(pages, '/nope')).toBeUndefined();
expect(getPage(pages, null)).toBeUndefined();
});
test('link neighbours in reading order', () => {
expect(getNeighbours(pages, pages[0])).toEqual({ previous: undefined, next: pages[1] });
expect(getNeighbours(pages, pages[1])).toEqual({ previous: pages[0], next: pages[2] });
expect(getNeighbours(pages, { ...pages[2] }).next).toBeUndefined();
});
});
+44
View File
@@ -0,0 +1,44 @@
import { mdiBookOpenPageVariantOutline, mdiLifebuoy, mdiRocketLaunchOutline } from '@mdi/js';
export { getEditUrl, Links, siteMetadata } from '$lib/constants';
export type Section = {
id: string;
title: string;
icon: string;
};
export const sections: Section[] = [
{ id: 'getting-started', title: 'Getting started', icon: mdiRocketLaunchOutline },
{ id: 'guides', title: 'Guides', icon: mdiBookOpenPageVariantOutline },
{ id: 'help', title: 'Help', icon: mdiLifebuoy },
];
export type Heading = {
id: string;
text: string;
level: number;
};
export type DocsPageMeta = {
title: string;
description: string;
order: number;
url: string;
sectionId?: string;
path: string;
headings: Heading[];
};
export const getSection = (page: DocsPageMeta) => sections.find(({ id }) => id === page.sectionId);
export const getPagesForSection = (pages: DocsPageMeta[], section: Section) =>
pages.filter((page) => page.sectionId === section.id);
export const getPage = (pages: DocsPageMeta[], url: string | null | undefined) =>
pages.find((page) => page.url === url);
export const getNeighbours = (pages: DocsPageMeta[], page: DocsPageMeta) => {
const index = pages.findIndex((item) => item.url === page.url);
return { previous: pages[index - 1], next: pages[index + 1] };
};
+22
View File
@@ -0,0 +1,22 @@
import { defaultProvider, navigateTo } from '@immich/ui';
export type SearchDoc = {
title: string;
description: string;
tags: string[];
url: string;
text: string;
};
export const getSearchProvider = (docs: SearchDoc[]) =>
defaultProvider({
name: 'Docs',
types: ['docs', 'page', 'pages'],
actions: docs.map((doc) => ({
title: doc.title,
description: doc.description,
tags: doc.tags,
text: doc.text,
onAction: () => navigateTo(doc.url),
})),
});
+166
View File
@@ -0,0 +1,166 @@
import { sections } from '$lib';
import { getHeadings, getSearchText, pages, parsePage, toMeta } from '$lib/server/docs';
import { readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, test } from 'vitest';
const routes = fileURLToPath(new URL('../../routes', import.meta.url));
const markdownPages = readdirSync(routes, { recursive: true, encoding: 'utf8' })
.map((entry) => entry.split('/'))
.filter((parts) => parts.at(-1) === '+page.md')
.map((parts) => (parts.length === 1 ? '/' : `/${parts.slice(0, -1).join('/')}`));
const withFrontMatter = (body: string, attributes = 'title: Title\ndescription: Description') =>
`---\n${attributes}\n---\n\n${body}`;
describe('pages', () => {
test('scans every markdown page in the routes folder', () => {
expect(markdownPages.length).toBeGreaterThan(0);
expect(pages).toHaveLength(markdownPages.length);
});
test('derives the url from the page folder', () => {
const urls = pages.map((page) => page.url).toSorted();
expect(urls).toEqual(markdownPages.toSorted());
});
test('starts with the introduction', () => {
expect(pages[0].url).toBe('/');
expect(pages[0].sectionId).toBeUndefined();
});
test('puts every other page in a known section', () => {
const ids = sections.map((section) => section.id);
for (const page of pages.slice(1)) {
expect(ids, page.url).toContain(page.sectionId);
expect(page.url, page.url).toMatch(new RegExp(`^/${page.sectionId}/[a-z0-9-]+$`));
}
});
test('parses the front matter of every page', () => {
for (const page of pages) {
expect(page.title, page.url).toEqual(expect.any(String));
expect(page.description, page.url).toEqual(expect.any(String));
expect(page.description, page.url).not.toMatch(/\.$/);
expect(page.path, page.url).toMatch(/^src\/routes\/.*\+page\.md$/);
expect(page.text.length, page.url).toBeGreaterThan(50);
}
});
test('orders pages by section, then order, then title', () => {
const expected = sections.flatMap((section) =>
pages
.filter((page) => page.sectionId === section.id)
.toSorted((a, b) => a.order - b.order || a.title.localeCompare(b.title)),
);
expect(pages.slice(1)).toEqual(expected);
});
test('has a unique url per page', () => {
const urls = new Set(pages.map((page) => page.url));
expect(urls.size).toBe(pages.length);
});
test('extracts unique heading anchors per page', () => {
for (const page of pages) {
const ids = page.headings.map((heading) => heading.id);
expect(new Set(ids).size, page.url).toBe(ids.length);
for (const heading of page.headings) {
expect(heading.id, `${page.url} ${heading.text}`).toMatch(/^[a-z0-9-]+$/);
expect([2, 3], page.url).toContain(heading.level);
}
}
});
test('strips the search text from the page metadata', () => {
expect(toMeta(pages[0])).not.toHaveProperty('text');
expect(toMeta(pages[0]).title).toBe(pages[0].title);
});
});
describe('parsePage', () => {
test('derives the url and repository path from the module path', () => {
const page = parsePage('../../routes/getting-started/example/+page.md', withFrontMatter('Body'));
expect(page.url).toBe('/getting-started/example');
expect(page.sectionId).toBe('getting-started');
expect(page.path).toBe('src/routes/getting-started/example/+page.md');
expect(page.order).toBe(Number.MAX_SAFE_INTEGER);
});
test('treats routes/+page.md as the introduction', () => {
const page = parsePage('../../routes/+page.md', withFrontMatter('Body', 'title: Intro\ndescription: D\norder: 3'));
expect(page.url).toBe('/');
expect(page.sectionId).toBeUndefined();
expect(page.order).toBe(3);
});
test('rejects pages outside the section/slug layout', () => {
expect(() => parsePage('../../routes/too/deep/nested/+page.md', withFrontMatter('Body'))).toThrow(
'not a valid docs page path',
);
});
test('rejects pages in an unknown section', () => {
expect(() => parsePage('../../routes/nope/example/+page.md', withFrontMatter('Body'))).toThrow(
'unknown section - found nope',
);
});
test('rejects pages without a title or description and shows an example', () => {
expect(() => parsePage('../../routes/development/example/+page.md', withFrontMatter('Body', 'title: T'))).toThrow(
/missing description\.\n---\ndescription: .*\n---/,
);
});
});
describe('getHeadings', () => {
test('keeps level two and three headings with anchors matching the rendered ids', () => {
const headings = getHeadings('# Title\n\n## Using `restic`\n\nText\n\n### Sub heading\n\n#### Deep\n');
expect(headings).toEqual([
{ id: 'using', text: 'Using restic', level: 2 },
{ id: 'sub-heading', text: 'Sub heading', level: 3 },
]);
});
});
describe('getSearchText', () => {
test('includes lists, tables, code blocks and alerts as plain text', () => {
const text = getSearchText(
[
'Intro paragraph.',
'',
'- first item',
'- second `item`',
'',
'| Variable | Purpose |',
'| --- | --- |',
'| `WEB_URL` | Links in emails |',
'',
'```bash',
'docker compose up -d',
'```',
'',
'> [!NOTE]',
'> Take care when exposing ports.',
'',
':::tip Heads up',
'Admonition body.',
':::',
].join('\n'),
);
for (const expected of [
'Intro paragraph.',
'first item',
'second item',
'WEB_URL',
'Links in emails',
'docker compose up -d',
'Take care when exposing ports.',
'Admonition body.',
]) {
expect(text).toContain(expected);
}
expect(text).not.toMatch(/[<>|]/);
});
});
+152
View File
@@ -0,0 +1,152 @@
import { sections, type DocsPageMeta, type Heading } from '$lib';
import { getIdFromText, markedSvelte } from '@immich/svelte-markdown-preprocess';
import fm from 'front-matter';
import { Marked, Parser, type Token, type Tokens } from 'marked';
export type DocsPage = DocsPageMeta & {
text: string;
};
type PageFrontMatter = {
title: string;
description: string;
order?: number;
};
const PAGE_PATH = /\/routes\/(?:(?<section>[^/]+)\/(?<slug>[^/]+)\/)?\+page\.md$/;
const REQUIRED_ATTRIBUTES = ['title', 'description'];
const md = new Marked().use(markedSvelte());
const inlineText = (tokens: Token[]): string =>
tokens
.map((token) => {
if ('tokens' in token && token.tokens) {
return inlineText(token.tokens);
}
return 'text' in token ? token.text : '';
})
.join('');
const blockText = (tokens: Token[]): string =>
tokens
.map((token) => {
switch (token.type) {
case 'code': {
return (token as Tokens.Code).text;
}
case 'table': {
const { header, rows } = token as Tokens.Table;
return [...header, ...rows.flat()].map((cell) => inlineText(cell.tokens)).join(' ');
}
case 'list': {
return (token as Tokens.List).items.map((item) => blockText(item.tokens)).join(' ');
}
case 'html': {
return /^\s*<script[\s>]/i.test(token.text) ? '' : token.text;
}
default: {
if ('tokens' in token && token.tokens) {
return blockText(token.tokens);
}
return 'text' in token ? token.text : '';
}
}
})
.join(' ');
export const getSearchText = (body: string) =>
blockText(md.lexer(body))
.replaceAll(/<[^>]*>/g, ' ')
.replaceAll(/\s+/g, ' ')
.trim();
export const getHeadings = (body: string): Heading[] => {
const headings: Heading[] = [];
for (const token of md.lexer(body)) {
if (token.type !== 'heading') {
continue;
}
const { depth, tokens } = token as Tokens.Heading;
if (depth < 2 || depth > 3) {
continue;
}
headings.push({
id: getIdFromText(Parser.parseInline(tokens, md.defaults)),
text: inlineText(tokens),
level: depth,
});
}
return headings;
};
const getFrontMatterExample = (missingAttributes: string[]) =>
[
'---',
...Object.entries({
title: 'Your page title',
description: 'A one sentence summary shown under the title and in search results',
})
.filter(([key]) => missingAttributes.includes(key))
.map(([key, value]) => `${key}: ${value}`),
'---',
].join('\n');
export const parsePage = (path: string, content: string): DocsPage => {
const match = PAGE_PATH.exec(path);
if (!match?.groups) {
throw new Error(`${path} is not a valid docs page path - expected routes/<section>/<slug>/+page.md`);
}
const { attributes, body } = fm<PageFrontMatter>(content);
const { section: sectionId, slug } = match.groups;
const url = sectionId ? `/${sectionId}/${slug}` : '/';
const missingAttributes = REQUIRED_ATTRIBUTES.filter((attribute) => !Object.hasOwn(attributes, attribute));
if (missingAttributes.length > 0) {
throw new Error(`${url} is missing ${missingAttributes.join(', ')}.\n${getFrontMatterExample(missingAttributes)}`);
}
if (sectionId && !sections.some(({ id }) => id === sectionId)) {
throw new Error(
`${url} is in an unknown section - found ${sectionId}, but expected one of ${sections.map(({ id }) => id).join(', ')}`,
);
}
return {
title: attributes.title,
description: attributes.description,
order: attributes.order ?? Number.MAX_SAFE_INTEGER,
url,
sectionId,
path: path.replace(/^(\.\.\/)+/, 'src/'),
headings: getHeadings(body),
text: getSearchText(body),
};
};
const byOrderThenTitle = (a: DocsPage, b: DocsPage) => a.order - b.order || a.title.localeCompare(b.title);
const getPages = () => {
const modules = import.meta.glob<{ default: string }>('../../routes/**/+page.md', { query: '?raw', eager: true });
const pages = Object.entries(modules).map(([path, { default: content }]) => parsePage(path, content));
const root = pages.find((page) => page.url === '/');
if (!root) {
throw new Error('The docs need an introduction page at routes/+page.md');
}
return [
root,
...sections.flatMap((section) => pages.filter((page) => page.sectionId === section.id).toSorted(byOrderThenTitle)),
];
};
export const pages: DocsPage[] = getPages();
export const toMeta = ({ text: _text, ...meta }: DocsPage): DocsPageMeta => meta;
@@ -0,0 +1,4 @@
import { pages, toMeta } from '$lib/server/docs';
import type { LayoutServerLoad } from './$types';
export const load = (() => ({ pages: pages.map((page) => toMeta(page)) })) satisfies LayoutServerLoad;
+126
View File
@@ -0,0 +1,126 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import '../app.css';
import { getPagesForSection, Links, sections } from '$lib';
import { getSearchProvider } from '$lib/search';
import {
AppShell,
AppShellHeader,
AppShellSidebar,
Button,
CommandPaletteButton,
commandPaletteManager,
CommandPaletteProvider,
Container,
ControlBar,
ControlBarHeader,
ControlBarOverflow,
IconButton,
Link,
NavbarGroup,
NavbarItem,
Text,
ThemeSwitcher,
TooltipProvider,
} from '@immich/ui';
import { mdiBookOpenPageVariantOutline, mdiGithub, mdiMenu, mdiOpenInNew } from '@mdi/js';
import type { Snippet } from 'svelte';
import { MediaQuery } from 'svelte/reactivity';
import type { LayoutData } from './$types';
type Props = {
children?: Snippet;
data: LayoutData;
};
const { children, data }: Props = $props();
const desktop = new MediaQuery('min-width: 48rem');
let open = $derived(desktop.current);
beforeNavigate(() => {
if (!desktop.current) {
open = false;
}
});
commandPaletteManager.enable();
commandPaletteManager.setTranslations({
command_palette_prompt_default: 'Search the documentation',
});
const isCurrent = (url: string) => () => page.url.pathname === url;
</script>
<CommandPaletteProvider providers={[getSearchProvider(data.docs)]} />
<TooltipProvider>
<AppShell>
<AppShellHeader>
<ControlBar static variant="ghost">
<ControlBarHeader class="flex-row items-center gap-1">
<IconButton
shape="round"
color="secondary"
variant="ghost"
size="medium"
aria-label="Main menu"
icon={mdiMenu}
onclick={() => (open = !open)}
class="md:hidden"
/>
<a href="/" class="flex items-center gap-2 px-2">
<img src="/favicon.svg" alt="" class="size-7" />
<Text fontWeight="bold" size="large">FUTO Backups</Text>
<Text color="muted" size="large" class="hidden sm:block">Docs</Text>
</a>
</ControlBarHeader>
<ControlBarOverflow>
<Button href={Links.App} color="secondary" variant="ghost" trailingIcon={mdiOpenInNew} class="hidden sm:flex">
Open app
</Button>
<IconButton
href={Links.Repository}
icon={mdiGithub}
aria-label="GitHub repository"
color="secondary"
variant="ghost"
size="medium"
/>
<CommandPaletteButton />
<ThemeSwitcher size="medium" />
</ControlBarOverflow>
</ControlBar>
</AppShellHeader>
<AppShellSidebar bind:open>
<nav aria-label="Documentation" class="mt-4 me-4 mb-24">
<NavbarItem
href="/"
title={data.pages[0].title}
icon={mdiBookOpenPageVariantOutline}
isActive={isCurrent('/')}
/>
{#each sections as section (section.id)}
<NavbarGroup title={section.title} />
{#each getPagesForSection(data.pages, section) as doc (doc.url)}
<NavbarItem href={doc.url} title={doc.title} icon={section.icon} isActive={isCurrent(doc.url)} variant="compact" />
{/each}
{/each}
</nav>
</AppShellSidebar>
<div class="flex h-full flex-col">
<main class="w-full grow">
<Container size="large" center class="w-full p-4 lg:p-8">
{@render children?.()}
</Container>
</main>
<footer class="text-muted mt-16 flex flex-wrap justify-center gap-4 border-t p-6 text-sm">
<Link href={Links.Futo}>FUTO</Link>
<Link href={Links.Repository}>Source code</Link>
</footer>
</div>
</AppShell>
</TooltipProvider>
+19
View File
@@ -0,0 +1,19 @@
import { browser } from '$app/environment';
import type { SearchDoc } from '$lib/search';
import type { LayoutLoad } from './$types';
export const prerender = true;
export const load = (async ({ data, fetch }) => {
if (!browser) {
return { ...data, docs: [] };
}
try {
const response = await fetch('/data/search.json');
const docs = (await response.json()) as SearchDoc[];
return { ...data, docs };
} catch {
return { ...data, docs: [] };
}
}) satisfies LayoutLoad;
+35
View File
@@ -0,0 +1,35 @@
---
title: FUTO Backups
description: Encrypted, off-site backups for your Immich library or any machine you run, hosted by FUTO
---
FUTO Backups keeps a copy of your data on storage that FUTO runs, so it survives the disk, the machine and the building it normally lives in.
Everything is encrypted on your own machine before it is uploaded, with a key that never leaves you. We store your backups. We cannot read them.
## Two ways to use it
- **In Immich.** Back up your photo library, its database and, if you want, its thumbnails and encoded videos. Managed from the Immich admin interface. See [set up in Immich](/getting-started/immich).
- **As a standalone container.** Back up any folders on any machine that runs Docker, with or without Immich. See [set up the standalone container](/getting-started/standalone).
You can use both against the same account.
## What to expect from the beta
FUTO Backups is in a closed, invite-only beta, and it is free while it is.
Backups and restores work, and are the parts we most want tested. Around them you will find unfinished corners: placeholder figures on the dashboard, wording that mentions Immich when it should not, and settings that are not connected yet. [Troubleshooting](/help/troubleshooting) lists the ones we know about.
Please tell us what you find. [Getting help](/help/support) explains how to open a ticket.
> [!IMPORTANT]
> During setup you are given a recovery key. It is the only thing that can decrypt your backups, and FUTO does not have a copy. Save it somewhere safe before you go any further. See [your recovery key](/guides/recovery-key).
## Start here
1. [Join the beta](/getting-started/beta) and sign in to your account.
2. Set up [Immich](/getting-started/immich) or the [standalone container](/getting-started/standalone).
3. Follow [your first backup](/getting-started/first-backup) from end to end.
4. Set a [schedule](/guides/schedules) so it keeps happening.
Then, before you ever need it, read [restoring files](/guides/restore).
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import { siteMetadata } from '$lib';
import { Button, Heading, SiteMetadata, Text } from '@immich/ui';
</script>
<SiteMetadata site={siteMetadata} page={{ title: 'Page not found', description: 'This page does not exist' }} />
<div class="flex flex-col items-start gap-4 py-16">
<Heading tag="h1" size="giant">Page not found</Heading>
<Text color="muted" size="large">The page you are looking for does not exist or has moved.</Text>
<Button href="/">Back to the introduction</Button>
</div>
@@ -0,0 +1,19 @@
import { getSection } from '$lib';
import type { SearchDoc } from '$lib/search';
import { pages, type DocsPage } from '$lib/server/docs';
import { json } from '@sveltejs/kit';
export const prerender = true;
const fromPage = (page: DocsPage): SearchDoc => {
const section = getSection(page);
return {
title: page.title,
description: page.description,
url: page.url,
tags: section ? [section.title] : [],
text: page.text,
};
};
export const GET = () => json(pages.map((page) => fromPage(page)));
@@ -0,0 +1,40 @@
---
title: Join the beta
description: What you need before you start, and how to get a FUTO Backups account during the closed beta
order: 1
---
FUTO Backups is in a closed beta. Accounts are invite-only, so the first step is getting an invite and signing in at [backups.futo.cloud](https://backups.futo.cloud).
## What you need
- **An invite.** Invites are handed out in the FUTO Backups channels on the [Immich Discord](https://discord.immich.app). See below.
- **Somewhere to run the backup app.** Either an [Immich](/getting-started/immich) server, or any machine that runs Docker for the [standalone container](/getting-started/standalone). You can use both against the same account.
- **A few minutes for setup.** The app walks you through connecting your account, saving a recovery key and running a first backup.
## Getting an invite
Invites are given out in Discord, in two ways:
- **A personal invite.** The bot sends you a direct message with your invite link.
- **A claim button.** A post in one of the FUTO Backups channels carries a **Claim your invite** button. Click it and the bot replies with your own link. Once the batch runs out the button changes to *All invites claimed*.
Either way you end up with a link to `backups.futo.cloud`. Open it and you will see a message like "you're invited to the FUTO Backups beta. Sign in to join", with a **Join the beta** button. Follow it and sign in.
> [!NOTE]
> Invite links are single-use and expire after 10 minutes. If yours has expired, the page tells you so. Go back to Discord and claim a new one.
If you were given an invite **code** instead of a link, go to [backups.futo.cloud](https://backups.futo.cloud), enter the code when asked and select **Continue**.
## Signing in
Signing in uses your FUTO account. Once you are in, you land on your dashboard, which lists the machines connected to your account and how much storage you are using.
If you try to sign in without an invite you will see "Your email isn't part of the beta yet." Claim an invite first.
## Next
Install the backup app where your data lives:
- [Set up in Immich](/getting-started/immich) if you want to back up an Immich library.
- [Set up the standalone container](/getting-started/standalone) to back up folders on any machine that runs Docker.
@@ -0,0 +1,69 @@
---
title: Your first backup
description: Connect your account, save your recovery key and get one backup finished from end to end
order: 4
---
Once the app is installed, setup takes three things: connecting your account, saving your recovery key, and creating a backup. This page covers all three.
Immich asks you to connect your account first and shows the recovery key after. The standalone app does it the other way round. Either way you do both.
## Connect your account
The **Connect your FUTO account** step links the machine to the account you signed up with.
1. Select **Connect account**. The app shows a short code, above the words "You may be asked or shown the following code".
2. Select **Continue to login**. A popup window opens on the FUTO sign-in page.
3. Sign in, check that the code shown matches the one in the app, and approve.
4. The app shows "Waiting for you to confirm login" until it picks up the approval, then carries on by itself.
If the popup is blocked, allow popups for the app and select **Try again**. There is a copy button next to the code if you need it.
> [!NOTE]
> The standalone app also offers **Use local storage**, which stores backups on a disk you mount into the container instead of on FUTO's storage. You can add that later as well. See [where backups are stored](/guides/backups#where-backups-are-stored).
## Save your recovery key
You are shown a 64-character key and asked to confirm "I saved my recovery key somewhere safe" before you can continue.
**Do not skip past this.** Your backups are encrypted with this key before they leave your machine, and FUTO does not have a copy. Without it, your backups cannot be read by anyone, including you. Use the **Copy recovery key**, **Download** or **Print** buttons and put it somewhere you will still have it if this machine dies.
[Your recovery key](/guides/recovery-key) explains what it protects and how to use it later.
## Create a backup
In Immich this is done for you: setup finishes with a first backup of your library, and you can adjust what it includes afterwards under [Backup Contents](/getting-started/immich).
In the standalone app, create one yourself:
1. Go to **Backups** and select **Create new backup**.
2. Give it a **Name**. This is just a label, so use something you will recognise later, like "Documents" or "Photos".
3. Leave **Write once (WORM)** off unless you want it. It stops anything ever being deleted from this backup, including by you, and it cannot simply be switched off again afterwards.
4. Select create. The app immediately opens **Configure**, where you choose what to back up.
5. Under **Backup Paths**, select **Add first path** and browse to the folders you mounted, usually under `/target`. Add as many as you want.
6. Save.
## Run it
Open the backup's menu and select **Back up now**.
A window shows the progress: "Preparing backup", then "Backing up" with a percentage, then "Finalizing backup". You can close it and the backup carries on in the background.
When it finishes you get one of:
- **Your library was backed up successfully**
- **Your library was backed up, with warnings**, with the warnings listed
- **Your library could not be backed up**, with a **Try again** button. Nothing is changed in your existing backups when a backup fails
The first backup uploads everything and can take a long time. Later backups only send what changed, so they are much faster.
## Check it worked
The dashboard shows **Your Backups** with a health bar, and **Recent backups** listing what has run. Open a backup to see its **Snapshots**, which is one entry per successful run, and **Recent backup attempts** for its history.
You can also sign in at [backups.futo.cloud](https://backups.futo.cloud) and see the machine listed against your account.
## Next
- [Set a schedule](/guides/schedules) so backups keep running without you.
- [Restoring files](/guides/restore), worth reading once before you need it.
@@ -0,0 +1,83 @@
---
title: Set up in Immich
description: Switch your Immich server to the beta build, turn on backups and choose what gets backed up
order: 2
---
Immich has FUTO Backups built in. It backs up your photos and videos, your database and, if you want them, your thumbnails and encoded videos, straight to storage FUTO runs. You need to be an **administrator** of the Immich server.
> [!CAUTION]
> The Immich integration is not in a stable Immich release yet. Following these steps moves your server onto a development build of Immich, which can contain changes that are not finished. Take a backup of your existing Immich data before you start, and do not do this on a server you cannot afford to break.
Before you begin, make sure you have [joined the beta](/getting-started/beta) and can sign in at [backups.futo.cloud](https://backups.futo.cloud).
## 1. Switch to the beta build
Set the Immich version to the backups feature branch in your Immich deployment's environment file:
```bash
# .env
IMMICH_VERSION=pr-27817
```
Then pull and restart:
```bash
docker compose pull
docker compose up -d
```
Run both commands again whenever you want to pick up newer changes to the branch.
> [!NOTE]
> This image is built from an open pull request rather than a release, and it also carries recent changes from Immich's development branch. A new image appears only when that branch builds successfully, so it can lag behind the latest work on the pull request.
## 2. Turn on backups
The feature is off by default and there is no switch for it in the admin settings. Open this address on your Immich server, signed in as an administrator, replacing the host with your own:
```
https://immich.example.com/link?target=backups
```
That turns the feature on and takes you to the backups page. From then on the page lives at `/admin/backups`, and you can also reach it from the button in the admin sidebar.
## 3. Run through setup
The first time you open the page you get an introduction to FUTO Backups with a **Get Started** button. After that the setup runs in a few short steps:
1. **Telemetry.** The closed beta requires diagnostic data, so this step has no decline option and cannot be turned off later. Your photos, files and recovery key are never collected.
2. **Connect FUTO account.** This links the Immich server to the account you signed up with.
3. **Save your recovery key.** Your backups are encrypted with this key and FUTO cannot recover it for you. Read [your recovery key](/guides/recovery-key) before you click past this screen.
4. **Start your first backup.**
## 4. Choose what gets backed up
Open **Backup Contents** on the backups page to pick what each backup includes.
| Content | What it is | Notes |
| --- | --- | --- |
| Photos and videos | Your media uploaded directly to Immich | Strongly recommended |
| Database and metadata | Albums, people, tags, favourites and other library details | Always included, required to restore |
| Thumbnails and previews | Generated photo previews | Can be left out and regenerated after a restore |
| Encoded videos | Generated video previews | Can be left out and regenerated after a restore |
| External libraries | Libraries stored outside Immich | All, none, or specific libraries |
Leaving thumbnails or encoded videos out makes backups smaller and faster. After a restore you will see broken previews until you regenerate them from the admin panel.
There is also a **Backup configuration** switch, which includes these backup settings themselves so they survive a restore.
## 5. Set a schedule
Open **Schedule** and turn on **Run backups automatically**. You choose a frequency of daily, weekly or monthly and a start time on the hour. The default is daily at 03:00.
## 6. Choose how long backups are kept
Open **Storage** to set **Delete old backups**. The default keeps 60 days. You can choose 15, 30, 60 or 90 days, keep only the latest two backups, or keep everything forever.
Old backups are removed automatically after each run. If you turned on write-once protection for the backup, nothing can be deleted and this setting is fixed to never.
## Next
- [Your recovery key](/guides/recovery-key), which is the one thing you must not lose.
- [Restoring files](/guides/restore), including rolling your whole Immich instance back to a snapshot.
@@ -0,0 +1,104 @@
---
title: Set up the standalone container
description: Run FUTO Backups as a single Docker container and back up folders from any machine
order: 3
---
The standalone app is a single container that backs up folders from the machine it runs on. Use it for a homelab server, a NAS or a desktop, with or without Immich.
> [!NOTE]
> The interface for the standalone app is unpolished and has some rough edges. You are seeing it early because the beta is early.
Before you begin, make sure you have [joined the beta](/getting-started/beta) and can sign in at [backups.futo.cloud](https://backups.futo.cloud).
## Run it
```bash
docker run -d --name futo-backups \
--restart always \
-p 127.0.0.1:22676:22676 \
-v "$HOME/.yucca:/data" \
-v /my/important/data:/target/important-data:ro \
ghcr.io/immich-app/futo-backups-standalone:v0
```
Then open http://localhost:22676.
Or with Compose:
```yaml
# compose.yml
name: futo-backups
services:
backups:
image: ghcr.io/immich-app/futo-backups-standalone:v0
ports:
- 127.0.0.1:22676:22676
volumes:
- data:/data
# add additional mounts for the data you want to backup
- /my/important/data:/target/important-data:ro
restart: always
volumes:
data:
name: futo-backups-data
```
```bash
docker compose pull
docker compose up -d
```
## What to mount
| Mount | Purpose |
| --- | --- |
| `/data` | The app's own state: its database, settings and your encryption key. Never delete this volume |
| `/target/...` | The data you want backed up. Add one mount per folder |
| `/backends/...` | Optional. A local disk to store backups on, such as an external drive |
The app can only see what you mount into it, so add a `-v` line for every folder you want to back up. The names under `/target` are yours to choose. When you pick folders in the app you will browse to them there.
> [!WARNING]
> The example mounts your data read-only with `:ro`. That is the safe default and backups work fine, but restoring files back to their original location will fail. Drop `:ro` if you want to restore in place. You can always restore to a different folder instead.
If you would rather keep the app's state in a named volume than a folder in your home directory, use `-v futo-backups-data:/data`.
> [!CAUTION]
> Deleting the `/data` volume does not just reset your settings. Your encryption key lives there, and without it your existing backups can never be read again, wherever they are stored. Save your [recovery key](/guides/recovery-key) somewhere outside the container as soon as setup gives it to you.
## Ports and access
The container listens on port 22676. The examples bind it to `127.0.0.1`, so it is reachable only from the machine it runs on. That is deliberate: **the app has no password of its own until you connect a FUTO Backups account**, and anyone who can reach the port before then can configure it.
Once an account is connected, the app requires you to be signed in. If you need to turn that off, set `YUCCA_DISABLE_AUTH=true`, but then do not expose the port beyond localhost.
To reach the app from another machine, put it behind something that provides authentication, such as a reverse proxy or a VPN, rather than publishing the port directly.
## First run
Open the app and it walks you through setup. Some of the wording mentions Immich and a subscription even when you are not using either. That is a known rough edge; the steps still apply.
1. **Telemetry.** The closed beta requires diagnostic data, so this step has no decline option and cannot be turned off later. Your files and your recovery key are never collected.
2. **Save your recovery key.** Your backups are encrypted with it and FUTO cannot recover it for you. Read [your recovery key](/guides/recovery-key) before continuing.
3. **Connect your FUTO account.** This is where your backups will be stored.
After that you land on the dashboard, with **Backups**, **Schedules** and **Configure** in the sidebar.
## Updating
```bash
docker compose pull
docker compose up -d
```
Run both again each time you want to update. With plain `docker run`, pull the image and recreate the container.
## Next
- [Your first backup](/getting-started/first-backup) walks through creating one and running it.
- [Your recovery key](/guides/recovery-key), which is the one thing you must not lose.
@@ -0,0 +1,50 @@
---
title: Your account and storage
description: What the web dashboard shows, how connections work, and what storage costs during the beta
order: 5
---
[backups.futo.cloud](https://backups.futo.cloud) is where you see everything backing up to your account. The backups themselves are set up and run on your own machines, not here.
## Dashboard
**Your Backups** summarises how your backups are doing, with a bar splitting them into successful, with warnings, failed and never run. **Recent backups** lists the last few runs.
The four figures across the middle are:
| Figure | What it means |
| --- | --- |
| Avg. Backup Time | The average duration of the most recent run of each backup |
| Daily Backup Time | Those same durations added together |
| Total Stored | How much storage your backups are using, measured on our side |
| Current Usage | A placeholder. It is not showing you a real number yet |
Where a backup has not been measured on our side yet, its size is shown as **Estimated**, taken from what your machine reported. The measured figure updates every few minutes.
## Backups
**Backups** lists every backup on your account with its size and last result.
This page is deliberately read-only apart from deleting. Creating backups, choosing folders, running them and restoring all happen in the app on the machine itself, because that is where your files and your encryption key are.
## Connections
A **connection** is one thing that backs up to your account. An Immich server is a connection. A machine running the standalone container is another. One account can have as many as you like, and each is listed with how many backups it holds and how much storage it uses.
Connections appear on their own when you connect a machine, named after that machine's hostname. Connecting the same machine again reuses the connection rather than making a second one. There is nothing to create or configure here.
## What it costs
FUTO Backups is free while it is in the closed beta. There is no plan, no quota and no charge, and nothing in the app will ask you for payment.
The dashboard still shows what your usage would be billed as, so the figures mean something later. One detail worth knowing: for the standalone app, each stored object counts as at least 1 MiB. Immich backups are counted at their real size. Very small files therefore look larger in the billed figure than they are on disk.
## Where your data is stored
Backups are stored on FUTO's own hardware in Falkenstein, Germany. There is no region to choose.
Your files are encrypted on your machine before they are uploaded, using a key that stays with you. See [your recovery key](/guides/recovery-key).
## Signing out
**Logout** is in the top right. Signing out of the website does not affect your machines; they stay connected and keep backing up.
@@ -0,0 +1,63 @@
---
title: Managing backups
description: Change what a backup covers, see its snapshots, add storage, and import backups made by another machine
order: 2
---
A backup is a named set of folders, a place to store them, and the history of everything that has been sent there. You can have several on one machine, for example one for documents and one for photos.
In the standalone app they live under **Backups**. In Immich there is a single backup of your library, managed from the backups page.
## Changing what is backed up
Open the backup's menu and select **Configure**. You can rename it, and under **Backup Paths** add or remove folders. Changes apply to the next run.
Only paths you mounted into the container are visible. If a folder you want is not there, add another `-v` mount and restart the container. See [setting up the standalone container](/getting-started/standalone#what-to-mount).
There is no exclude or ignore list yet, so a backup covers everything under the paths you pick.
In Immich you choose categories rather than paths, under **Backup Contents**. See [setting up in Immich](/getting-started/immich#4-choose-what-gets-backed-up).
## Snapshots
Each successful run adds a **snapshot**, a point-in-time copy you can restore from. Open a backup to see the list, newest first, with its size and the folders it covers.
Runs only upload what changed since last time, so snapshots share storage rather than each costing a full copy.
Below the snapshots, **Recent backup attempts** lists every run, successful or not, and **View Log** on any of them reopens its progress and errors.
## Where backups are stored
Open **Configure** in the sidebar to see **Storage backends**, the places this machine can send backups. There are two:
- **FUTO Backups**, hosted storage on your account. Add it with **Login with FUTO Backups**, which uses the same approve-in-a-browser flow as first setup. No settings to fill in.
- **Local Storage**, a folder on this machine. Add it with **New local storage** and pick a path. Mount the disk into the container first, by convention under `/backends`.
New backups use the first backend configured. Each backup shows the storage it uses, with a status of Online, Active, Offline or Missing on service.
> [!NOTE]
> Storing backups only on a local disk in the same machine protects you from mistakes and corruption, but not from fire, theft or that disk failing. Treat it as a complement to hosted storage rather than a replacement.
## Write once backups
**Write once (WORM)** stops anything ever being removed from a backup, including by you and including automatic clean-up of old snapshots. It is set when the backup is created.
Turning it off later is deliberately awkward: the app sends you to [backups.futo.cloud](https://backups.futo.cloud) to confirm, on a page headed **Confirm Action**, because it weakens a protection you chose on purpose.
## Backups made by another machine
If a backup already exists on your storage but not on this machine, it appears under **Backups found elsewhere**. This happens after a rebuild, or when you point a second machine at the same account.
Select **Import** on it. The app checks it can read the backup first and shows either "Repository is readable and accessible!" or "Can't read repository." If it cannot be read, the machine almost certainly has the wrong [recovery key](/guides/recovery-key). After importing, the Configure dialog opens so you can check the name and paths.
Backups stored on a local folder show as **Unknown** until they are imported, because the name is only stored inside the app.
## Deleting a backup
**Delete repository** in the backup's menu opens [backups.futo.cloud](https://backups.futo.cloud) to confirm, since it removes the stored data permanently. Individual snapshots can be deleted from the snapshot list instead, which is also permanent.
Deleting is only offered for backups stored on FUTO Backups.
## Sizes
Backups stored on FUTO Backups report a measured size. Backups on a local folder show an **Estimated** size worked out on the machine instead, because local storage does not report usage back.
@@ -0,0 +1,63 @@
---
title: Your recovery key
description: What the recovery key protects, where to keep it, and how to use it when you rebuild a machine
order: 1
---
Your backups are encrypted on your own machine before anything is uploaded. The recovery key is what that encryption is based on. FUTO stores your backup data but not your key, so nobody at FUTO can read your backups, and nobody at FUTO can get them back for you if you lose the key.
> [!CAUTION]
> If you lose the recovery key and lose the machine, your backups cannot be recovered. Not by you, not by FUTO, not by anyone. Save it somewhere separate from the machine you are backing up.
## What it looks like
A recovery key is 64 hexadecimal characters. The app shows it in blocks of four to make it easier to read or write down, like this:
```
1F4A 9C02 7B31 D8E6
5A0F 3C7D 9E14 82BB
6D50 A3F9 04C7 1E88
B27A 5F63 CD09 4E12
```
Every backup on the machine gets its own encryption key, worked out from this one key. That is why a single recovery key is enough to unlock all of them, and why losing it loses all of them at once. It also means the recovery key is not itself a password you can hand to the `restic` command line; only the app can turn it back into the per-backup keys.
## Saving it
When the app shows you the key during setup, it gives you three ways to keep it, and will not let you continue until you tick **I saved my recovery key somewhere safe**:
- **Copy recovery key**, to paste into a password manager. This is the option most people should use.
- **Download**, which saves it as `backups-recovery-code.txt`.
- **Print**, for a paper copy.
Good places to keep it: a password manager, a printed copy somewhere safe, or a file on a different machine. A bad place: only on the machine you are backing up, because that is exactly the machine you are protecting against losing.
> [!NOTE]
> Your backups do contain a copy of the key, but you need the key to read them, so they are not a substitute for saving it yourself.
## Seeing it again later
In **Immich**, open the backups page and select **View recovery key**.
In the **standalone app** there is no button for this yet. If you are signed in to the app, you can still read it from the API in your browser:
```
http://localhost:22676/api/yucca/onboarding/recovery-key
```
If neither is available to you any more, and you did not save the key, treat the existing backups as lost and start fresh with a new one.
## Using it on a new machine
The key is how a rebuilt machine gets back to your existing backups. When you set up the app again, choose **Import key** on the first screen instead of generating a new one, paste your key into the **Recovery Key** field, and save. Spacing and capitalisation do not matter.
With the right key imported, your existing backups become readable and you can restore from them. See [restoring files](/guides/restore) for the full walkthrough.
If you import the wrong key, nothing is deleted, but the app cannot read those backups. You will see "Can't access, is your recovery key correct?" next to them, or "Can't read repository" when importing. Import the correct key and they become readable again.
> [!WARNING]
> Importing a key replaces the one the machine is already using, with no confirmation and no undo. On a machine that has been backing up for a while, that makes its own existing backups unreadable until you import the original key back. Only import on a fresh setup, or when you are deliberately reconnecting to older backups.
## If you generate a new key instead
Setting up with a fresh key does not delete anything, but the new key cannot read backups made with the old one. You end up with two sets: the old backups, still encrypted with a key you no longer have, and the new ones. If you are rebuilding a machine and want your history, import the old key.
@@ -0,0 +1,52 @@
---
title: Restoring files
description: Get files back from a snapshot, roll an Immich server back, or rebuild a machine from scratch
order: 4
---
There are three ways to get data back, depending on how much you lost.
## Restoring some files
Open the backup, find the snapshot you want in **Snapshots**, and choose **Restore files** from its menu.
In the **Restore Backup** window:
- **Files to restore.** Leave it empty to restore everything in the snapshot, or select **Select files instead** and pick individual files and folders by browsing inside the snapshot.
- **In-place restore** is on by default and puts files back exactly where they came from. Turn it off to choose a **Target** folder instead.
Select **Restore** and a progress window opens, the same as for a backup.
> [!WARNING]
> An in-place restore overwrites the current version of those files. If you are not certain, restore to a different folder and compare before replacing anything.
When you restore to a target folder, the original directory structure is recreated underneath it. A file backed up from `/target/photos/holiday.jpg` restored into `/target/scratch` lands at `/target/scratch/target/photos/holiday.jpg`.
### If restore fails with a permissions error
If you mounted your data read-only with `:ro`, an in-place restore cannot write to it and will fail. Either restore to a writable folder instead, or remove `:ro` from that mount and restart the container.
## Rolling an Immich server back
Immich can go back to how it was at a chosen snapshot. On the snapshot, choose **Rollback snapshot**.
This restores your files **and** the Immich database, and restarts the server as part of the process. Immich goes into maintenance mode while it runs. Anything added after that snapshot was taken is gone once it completes, so treat it as a last resort rather than a way to undo one deletion.
## Rebuilding a machine
If the machine is gone, the backups are not. They are on your account, encrypted with your [recovery key](/guides/recovery-key).
1. Install the app again, following [Immich](/getting-started/immich) or [the standalone container](/getting-started/standalone).
2. When setup offers to create a key, choose **Import key** and enter your existing recovery key instead.
3. Connect the same FUTO Backups account.
4. Your existing backups appear. If one shows "Can't access, is your recovery key correct?", the imported key is not the one that made it.
5. Pick the backup, then the snapshot you want, and confirm.
The confirmation screen lists what the snapshot contains and lets you untick parts of it. It also offers to restore the **backup configuration** itself, which brings back the backup definitions and schedules that machine had, so you do not have to set them up again. Files are restored to their original paths.
## Things restore does not do yet
- There is no preview of what will be overwritten before you start.
- There is no way to download a single file from the web dashboard. Restoring into a folder is the way to pull one file out.
- A restore in progress cannot be cancelled from the interface.
- Snapshot deletion is immediate and permanent, with no recycle bin.
@@ -0,0 +1,58 @@
---
title: Schedules
description: Run backups automatically, and control how long old snapshots are kept
order: 3
---
A backup only protects you if it keeps running. Schedules do that.
## In Immich
Open the backups page, select **Configure**, then **Schedule**, and turn on **Run backups automatically**.
Pick a **Frequency** of daily, weekly or monthly and a **Start time** on the hour. The default is daily at 03:00. The page tells you in plain words when backups will run, then select **Save**.
## In the standalone app
Schedules are separate from backups, so one schedule can run several backups in order.
1. Go to **Schedules** and select **Create new schedule**.
2. Give it a **Name**.
3. Set **Schedule**, which takes a cron expression. The box starts at `*/15 * * * *`, every fifteen minutes, which is almost certainly not what you want.
4. Add the backups it should run, in the order you want them.
5. Save.
Common expressions:
| Expression | When it runs |
| --- | --- |
| `0 3 * * *` | Every day at 03:00 |
| `0 3 * * 0` | Every Sunday at 03:00 |
| `0 */6 * * *` | Every six hours |
| `30 2 1 * *` | The first of the month at 02:30 |
The five fields are minute, hour, day of month, month and day of week.
> [!NOTE]
> Schedules in the standalone container run in **UTC**. Setting a timezone on the container does not change this today, so convert your intended local time to UTC when writing the expression.
A schedule shows its expression, when it last ran, and whether it is paused. Its menu has **Pause**, **Resume**, **Configure** and **Delete**.
## What happens when a schedule fires
The backups in a schedule run one after another, in the order you arranged them.
- If the machine is off or the container is not running when a schedule is due, that run is simply missed. There is no catch-up run afterwards.
- If a backup from the previous run is somehow still going, that backup is recorded as failed for this run rather than starting twice.
- The "last ran" time is when the schedule started, not when it finished.
## How long snapshots are kept
After each run, snapshots older than the retention period are removed and the space is reclaimed. The default keeps **60 days**.
In Immich you can change this under **Configure**, then **Storage**, in **Delete old backups**: 15, 30, 60 or 90 days, only the latest two backups, or keep everything.
In the standalone app the 60 day default applies and there is no setting for it yet. If you need to keep snapshots for longer than that today, use a [write-once backup](/guides/backups#write-once-backups), which never deletes anything.
> [!NOTE]
> Retention counts from when each snapshot was taken, not from when the file was last changed. A file that never changes stays in your backups for as long as you keep taking snapshots of it.
@@ -0,0 +1,46 @@
---
title: Getting help
description: How to reach the FUTO Backups team during the beta, and what to include when you report a problem
order: 2
---
Support for the beta runs through Discord, in the **FUTO Backups** channels on the [Immich Discord](https://discord.immich.app).
## Channels
| Channel | What it is for |
| --- | --- |
| `#support` | Opening a support ticket. The pinned message carries the button that starts one |
| `#general` | General FUTO Backups chat |
| `#customer` | Chat for people with a FUTO Backups account |
## Opening a ticket
Support tickets are private threads, so you can share details without posting them to the whole server.
1. Go to `#support` and select **Get support** on the pinned message.
2. The first time you do this, the bot sends you a one-time link to connect your Discord account to your FUTO Backups account. Follow it, sign in and confirm. You only do this once.
3. Describe your issue in the box the bot shows you.
4. The bot opens a private thread with you and the support team, seeded with your description.
Keep the conversation in that thread. It stays private to you and the team.
## What to include
The more of this you can give, the faster a ticket moves:
- Whether you are using **Immich** or the **standalone container**, and which version or image tag.
- What you expected to happen and what happened instead.
- The name of the backup involved, and roughly when it ran.
- Anything from the backup's own log. In the app, open the backup and look under **Recent backup attempts** for the failed run.
> [!TIP]
> If a backup failed, say whether it has ever succeeded. "It has never worked" and "it worked until Tuesday" lead to very different first questions.
## Reporting a bug
If you are confident something is a bug rather than a configuration problem, an issue on the [yucca repository](https://github.com/immich-app/yucca/issues) is welcome too. Support tickets are still the fastest route during the beta, because the team can look at your account alongside the report.
## Before you write in
The [troubleshooting page](/help/troubleshooting) covers the problems that come up most often, including a backup that will not start, a machine that will not connect to your account and what to do about a lost recovery key.
@@ -0,0 +1,68 @@
---
title: Troubleshooting
description: What the common errors mean and what to do about them
order: 1
---
## Setup and sign-in
**"Login was cancelled or timed out."**
The usual cause is closing the popup or letting it sit too long. Select **Try again**. If it keeps happening, note that this message is also what you get when your account is not part of the beta yet, so check you can sign in at [backups.futo.cloud](https://backups.futo.cloud) first.
**"That account does not own this instance. Log in with the account it was connected with."**
This machine was connected using a different account. Sign in with that one.
**"This instance is not connected to a FUTO Backups account yet."**
Setup did not finish. Run through **Connect account** again.
**"Could not reach FUTO Backups. Check your connection."**
The machine cannot reach our servers. Check its internet connection, DNS and any firewall or proxy in the way.
**"Your email isn't part of the beta yet."**
You signed in with an address that has no invite. Claim an invite in Discord and use the link it gives you. See [join the beta](/getting-started/beta).
**"Something went wrong. We ran into an error setting up backups"**
The app failed to start properly. Check the container logs. Note that Docker may still report the container as healthy, because the health check only tests that the web server answers.
## Backups
**A backup fails immediately with "Task already running!"**
One backup at a time per backup job. Wait for the running one to finish. This also happens when a scheduled run starts while the previous one is still going, in which case that run is recorded as failed and the next one will be fine.
**A scheduled backup did not run.**
Schedules have no catch-up. If the machine was off or the container was not running when the schedule was due, that run is skipped rather than run late. Also check the schedule is not paused, and remember that the standalone app runs schedules in [UTC](/guides/schedules).
**The folder I want is not in the file picker.**
The app can only see paths mounted into the container. Add another `-v` mount and restart it. See [what to mount](/getting-started/standalone#what-to-mount).
**A backup is listed as "Unknown".**
That is a backup found on storage that this machine has not imported yet. Use **Import** on it. See [backups made by another machine](/guides/backups#backups-made-by-another-machine).
**The size says "Estimated".**
The size your machine calculated, shown because we have not measured it on our side yet. Measured sizes update every few minutes, and only for backups stored on FUTO Backups.
**"Can't access, is your recovery key correct?" or "Can't read repository."**
The app cannot decrypt that backup, which nearly always means the recovery key on this machine is not the one that created it. Import the correct key. See [your recovery key](/guides/recovery-key).
## Restoring
**A restore fails with a permissions error.**
If you mounted the folder read-only with `:ro`, files cannot be written back to it. Restore to a different folder, or remove `:ro` from the mount and restart the container.
**I want one file back, not all of them.**
In the restore window, choose **Select files instead** and pick just what you need. There is no per-file download from the website.
**I started a restore by mistake.**
There is no cancel button for a running restore yet. Let it finish, then restore the correct version over the top.
## Things that look broken but are not finished
The beta ships some interface that is not wired up yet. If you find these, they are known:
- **Current Usage** on the dashboard always shows a dash rather than a figure.
- **Setup on Immich** on the dashboard does nothing when clicked.
- Setup wording sometimes mentions Immich and a subscription even in the standalone app.
## Still stuck
Open a support ticket in Discord. [Getting help](/help/support) explains how and what to include.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+24
View File
@@ -0,0 +1,24 @@
import { createAttributes, escapeSvelteCode, SvelteMarkdownPreprocess } from '@immich/svelte-markdown-preprocess';
// Upstream escapes only braces and backticks, so `\` and `${` in code would be interpreted by JavaScript.
const escapeTemplateLiteral = (text) => escapeSvelteCode(text.replaceAll('\\', '\\\\')).replaceAll('$', '\\$');
class DocsMarkdownPreprocess extends SvelteMarkdownPreprocess {
configure(md) {
return super.configure(md).use({
renderer: {
code({ text, lang }) {
return `<Markdown.Code${createAttributes({ lang })} code={\`${escapeTemplateLiteral(text)}\`} multiline />\n`;
},
codespan({ text }) {
return `<Markdown.Code code={\`${escapeTemplateLiteral(text)}\`} />`;
},
},
});
}
}
export const docsMarkdownPreprocess = (options) => {
const plugin = new DocsMarkdownPreprocess(options);
return { name: plugin.name, markup: (item) => plugin.markup(item) };
};
+21
View File
@@ -0,0 +1,21 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import { docsMarkdownPreprocess } from './svelte-markdown.js';
/** @type {import('@sveltejs/kit').Config} */
const config = {
extensions: ['.svelte', '.md'],
preprocess: [
docsMarkdownPreprocess({
layouts: {
default: '$lib/components/DocsPage.svelte',
},
}),
vitePreprocess(),
],
kit: {
adapter: adapter(),
},
};
export default config;
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"noImplicitAny": true
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
+13
View File
@@ -0,0 +1,13 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
test: {
expect: { requireAssertions: true },
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
},
});
+123
View File
@@ -30,6 +30,9 @@ catalogs:
'@immich/sql-tools':
specifier: ^0.3.2
version: 0.3.2
'@immich/svelte-markdown-preprocess':
specifier: ^0.7.0
version: 0.7.0
'@immich/ui':
specifier: ^0.85.0
version: 0.85.0
@@ -228,6 +231,9 @@ catalogs:
express:
specifier: ^5.2.1
version: 5.2.1
front-matter:
specifier: ^4.0.2
version: 4.0.2
globals:
specifier: ^16.0.0
version: 16.5.0
@@ -252,6 +258,9 @@ catalogs:
luxon:
specifier: ^3.7.2
version: 3.7.2
marked:
specifier: ^18.0.0
version: 18.0.11
nestjs-kysely:
specifier: ^3.1.2
version: 3.1.2
@@ -466,6 +475,58 @@ importers:
specifier: 'catalog:'
version: 25.2.1
packages/docs:
dependencies:
'@immich/svelte-markdown-preprocess':
specifier: 'catalog:'
version: 0.7.0(svelte@5.55.7(@typescript-eslint/types@8.52.0))
'@immich/ui':
specifier: 'catalog:'
version: 0.85.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.7(@typescript-eslint/types@8.52.0))(vite@7.3.5(@types/node@25.2.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.7(@typescript-eslint/types@8.52.0))(typescript@5.9.3)(vite@7.3.5(@types/node@25.2.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.7(@typescript-eslint/types@8.52.0))(tailwindcss@4.1.18)
'@mdi/js':
specifier: 'catalog:'
version: 7.4.47
front-matter:
specifier: 'catalog:'
version: 4.0.2
marked:
specifier: 'catalog:'
version: 18.0.11
devDependencies:
'@sveltejs/adapter-static':
specifier: 'catalog:'
version: 3.0.10(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.7(@typescript-eslint/types@8.52.0))(vite@7.3.5(@types/node@25.2.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.7(@typescript-eslint/types@8.52.0))(typescript@5.9.3)(vite@7.3.5(@types/node@25.2.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))
'@sveltejs/kit':
specifier: 'catalog:'
version: 2.60.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.7(@typescript-eslint/types@8.52.0))(vite@7.3.5(@types/node@25.2.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.7(@typescript-eslint/types@8.52.0))(typescript@5.9.3)(vite@7.3.5(@types/node@25.2.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte':
specifier: 'catalog:'
version: 6.2.4(svelte@5.55.7(@typescript-eslint/types@8.52.0))(vite@7.3.5(@types/node@25.2.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@tailwindcss/vite':
specifier: 'catalog:'
version: 4.1.18(vite@7.3.5(@types/node@25.2.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@types/node':
specifier: 'catalog:'
version: 25.2.1
svelte:
specifier: 'catalog:'
version: 5.55.7(@typescript-eslint/types@8.52.0)
svelte-check:
specifier: 'catalog:'
version: 4.3.5(picomatch@4.0.5)(svelte@5.55.7(@typescript-eslint/types@8.52.0))(typescript@5.9.3)
tailwindcss:
specifier: 'catalog:'
version: 4.1.18
typescript:
specifier: 'catalog:'
version: 5.9.3
vite:
specifier: 'catalog:'
version: 7.3.5(@types/node@25.2.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest:
specifier: 'catalog:'
version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.2.1)(@vitest/browser-playwright@4.0.18)(vite@7.3.5(@types/node@25.2.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
packages/e2e:
devDependencies:
'@common/server':
@@ -2339,6 +2400,11 @@ packages:
resolution: {integrity: sha512-UWhy/+Lf8C1dJip5wPfFytI3Vq/9UyDKQE1ROjXwVhT6E/CPgBkRLwHPetjYGPJ4o1JVVpRLnEEJCXdvzqVpGw==}
hasBin: true
'@immich/svelte-markdown-preprocess@0.7.0':
resolution: {integrity: sha512-q3wBn5PMApd5SrAkxc7D+Omr8h0dIztlZcxA8DGS2WJB4Fh9y6is2vtdJcp2jIO1YbM/qmZeCtK4NnmhGTNeNg==}
peerDependencies:
svelte: ^5.0.0
'@immich/ui@0.85.0':
resolution: {integrity: sha512-KCAEtVGexZ1G+nuT25bv1QS6Rc2wOlLymp4qDBZVqMxu3o3RWpUzafRHvZt5g4FnlAgfHZdXAIHVVrhJzkrlDA==}
peerDependencies:
@@ -3486,6 +3552,10 @@ packages:
'@sinclair/typebox@0.34.47':
resolution: {integrity: sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw==}
'@sindresorhus/is@4.6.0':
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
engines: {node: '>=10'}
'@sinonjs/commons@3.0.1':
resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==}
@@ -5248,6 +5318,9 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
emojilib@2.4.0:
resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==}
encodeurl@2.0.0:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
@@ -5612,6 +5685,9 @@ packages:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
front-matter@4.0.2:
resolution: {integrity: sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
@@ -6532,6 +6608,11 @@ packages:
makeerror@1.0.12:
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
marked@18.0.11:
resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==}
engines: {node: '>= 20'}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -6733,6 +6814,10 @@ packages:
node-emoji@1.11.0:
resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==}
node-emoji@2.2.0:
resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==}
engines: {node: '>=18'}
node-int64@0.4.0:
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
@@ -7407,6 +7492,10 @@ packages:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'}
skin-tone@2.0.0:
resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==}
engines: {node: '>=8'}
slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
@@ -7924,6 +8013,10 @@ packages:
resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==}
engines: {node: '>=18.17'}
unicode-emoji-modifier-base@1.0.0:
resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==}
engines: {node: '>=4'}
unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
@@ -9446,6 +9539,13 @@ snapshots:
pg-connection-string: 2.11.0
postgres: 3.4.8
'@immich/svelte-markdown-preprocess@0.7.0(svelte@5.55.7(@typescript-eslint/types@8.52.0))':
dependencies:
front-matter: 4.0.2
marked: 18.0.11
node-emoji: 2.2.0
svelte: 5.55.7(@typescript-eslint/types@8.52.0)
'@immich/ui@0.85.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.7(@typescript-eslint/types@8.52.0))(vite@7.3.5(@types/node@25.2.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.7(@typescript-eslint/types@8.52.0))(typescript@5.9.3)(vite@7.3.5(@types/node@25.2.1)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.7(@typescript-eslint/types@8.52.0))(tailwindcss@4.1.18)':
dependencies:
'@internationalized/date': 3.10.1
@@ -10781,6 +10881,8 @@ snapshots:
'@sinclair/typebox@0.34.47': {}
'@sindresorhus/is@4.6.0': {}
'@sinonjs/commons@3.0.1':
dependencies:
type-detect: 4.0.8
@@ -12756,6 +12858,8 @@ snapshots:
emoji-regex@9.2.2: {}
emojilib@2.4.0: {}
encodeurl@2.0.0: {}
end-of-stream@1.4.5:
@@ -13249,6 +13353,10 @@ snapshots:
fresh@2.0.0: {}
front-matter@4.0.2:
dependencies:
js-yaml: 3.14.2
fs-constants@1.0.0: {}
fs-extra@10.1.0:
@@ -14285,6 +14393,8 @@ snapshots:
dependencies:
tmpl: 1.0.5
marked@18.0.11: {}
math-intrinsics@1.1.0: {}
mdast-util-to-hast@13.2.1:
@@ -14448,6 +14558,13 @@ snapshots:
dependencies:
lodash: 4.17.21
node-emoji@2.2.0:
dependencies:
'@sindresorhus/is': 4.6.0
char-regex: 1.0.2
emojilib: 2.4.0
skin-tone: 2.0.0
node-int64@0.4.0: {}
node-releases@2.0.27: {}
@@ -15203,6 +15320,10 @@ snapshots:
mrmime: 2.0.1
totalist: 3.0.1
skin-tone@2.0.0:
dependencies:
unicode-emoji-modifier-base: 1.0.0
slash@3.0.0: {}
socket.io-adapter@2.5.6:
@@ -15772,6 +15893,8 @@ snapshots:
undici@6.28.0: {}
unicode-emoji-modifier-base@1.0.0: {}
unist-util-is@6.0.1:
dependencies:
'@types/unist': 3.0.3
+3
View File
@@ -19,6 +19,7 @@ catalog:
'@eslint/js': ^9.39.2
'@futo-org/restic-wrapper': 1.4.2
'@immich/sql-tools': ^0.3.2
'@immich/svelte-markdown-preprocess': ^0.7.0
'@immich/ui': ^0.85.0
'@jest/globals': ^30.2.0
'@lingui/cli': ^5.8.0
@@ -85,6 +86,7 @@ catalog:
event-iterator: ^2.0.0
eventsource-client: ^1.2.0
express: ^5.2.1
front-matter: ^4.0.2
globals: ^16.0.0
jest: ^30.2.0
jest-extended: ^7.0.0
@@ -93,6 +95,7 @@ catalog:
kysely-postgres-js: ^3.0.0
lodash.debounce: ^4.0.8
luxon: ^3.7.2
marked: ^18.0.0
nestjs-kysely: ^3.1.2
nestjs-otel: ^8.0.2
nestjs-zod: ^5.5.0
+5
View File
@@ -47,6 +47,11 @@
"path": "packages/web/package.json",
"jsonpath": "$.version"
},
{
"type": "json",
"path": "packages/docs/package.json",
"jsonpath": "$.version"
},
{
"type": "json",
"path": "packages/yucca-admin-api/package.json",
+14
View File
@@ -0,0 +1,14 @@
# op:// references only, resolved by the docs:deploy / docs:destroy mise tasks
# and .github/workflows/docs*.yml. Every item is readable by every env SA.
export AWS_ACCESS_KEY_ID="op://yucca_tf/TF_STATE_S3_ACCESS_KEY/password"
export AWS_SECRET_ACCESS_KEY="op://yucca_tf/TF_STATE_S3_SECRET_KEY/password"
# Minted by core-infra-tf cloudflare/futo-account-api-keys (futo_cloud_pages).
export CLOUDFLARE_API_TOKEN="op://shared_tf/FUTO_CLOUD_PAGES_CLOUDFLARE_API_TOKEN/password"
export CLOUDFLARE_ACCOUNT_ID="op://shared_tf/CLOUDFLARE_ACCOUNT_ID/password"
export TF_VAR_cloudflare_api_token="op://shared_tf/FUTO_CLOUD_PAGES_CLOUDFLARE_API_TOKEN/password"
export TF_VAR_cloudflare_account_id="op://shared_tf/CLOUDFLARE_ACCOUNT_ID/password"
# prod on main, dev for pull-request previews.
export TF_VAR_env=$ENVIRONMENT
+53
View File
@@ -0,0 +1,53 @@
# yucca/tf/pages
Cloudflare Pages sites, the way [static-pages](https://github.com/immich-app/static-pages)
deploys the immich.app sites: a shared Pages project per environment, a custom
domain (plus DNS) per stage, and a `wrangler pages deploy` of the prerendered
build. Kept outside `tf/deployment` because the infra workflow discovers and
applies everything under there; these stacks are applied by each site's own
workflow instead.
| Stack | State key | Creates |
|---|---|---|
| `docs/project` | `yucca/pages/docs/project/<env>/` | The `docs-futo-cloud-<env>` Pages project (+ web analytics site). Shared by every stage of the env. |
| `docs/site` | `yucca/pages/docs/site/<env>/<stage>/` | The custom domain and CNAME for one stage: `docs.futo.cloud` (prod, stage `main`) or `docs.pr-<n>.dev.futo.cloud` (dev preview). |
`ENVIRONMENT` (→ `TF_VAR_env`) is `prod` on main and `dev` for pull-request
previews; `TF_VAR_stage` is empty on main and `pr-<n>` for a preview.
## Workflows
- `.github/workflows/docs.yml` — on every push to main and every pull request
that touches the docs surface: builds `packages/docs`, applies both stacks,
uploads the build, and (on a PR) posts a sticky comment with the preview URL.
- `.github/workflows/docs-destroy.yml` — when a PR closes: destroys that PR's
`docs/site` stage (custom domain + CNAME). The Pages project and the uploaded
preview deployment itself are left in place.
Both run `mise run docs:deploy` / `mise run docs:destroy`, so an operator can
do the same locally.
## Prerequisites
Everything comes from 1Password items that other terraform publishes; nothing
is created by hand:
- `shared_tf/FUTO_CLOUD_PAGES_CLOUDFLARE_API_TOKEN` — minted by core-infra-tf's
`cloudflare/futo-account-api-keys` unit (`futo_cloud_pages`): Pages Write and
Account Settings Write on the FUTO account (projects, custom domains, the
project's web analytics site), Zone Read and DNS Write on futo.cloud only.
- `shared_tf/CLOUDFLARE_ACCOUNT_ID` — the FUTO account id (shared manual item).
- `yucca_tf/TF_STATE_S3_*` — the state bucket credentials, as for tf/deployment.
CI uses the existing `OP_TF_YUCCA_PROD_ENV` (main) and `OP_TF_YUCCA_STAGING_ENV`
(previews) service accounts.
## Running locally
```bash
mise docs:build
ENVIRONMENT=dev TF_VAR_stage=pr-0 mise docs:deploy # https://docs.pr-0.dev.futo.cloud
ENVIRONMENT=dev TF_VAR_stage=pr-0 mise docs:destroy
OP_ENV_FILE=tf/pages/.env ENVIRONMENT=prod tf/op-run.sh terragrunt run --all plan --working-dir tf/pages/docs
```
+24
View File
@@ -0,0 +1,24 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.8"
constraints = "~> 4.46, 4.52.8"
hashes = [
"h1:BGRNOzo8NUgbx5RwpWkWmr38f/s3txb7mzQhqfM5blI=",
"zh:08b305329a680a9213b2d8e642fbce7e4d97a524b1d2cef59e190ba9d678c477",
"zh:47975bd711ee18a46e589822171fa87474a552b332bfc8dea8fd1a64504eed8d",
"zh:5640d0d226bbafff3395542456c29feb942ca9c55ac01b4245a34c0590a33363",
"zh:5b0ad839fafba938c60a95d6b4a865843643591e97573ed2b9c4af3754064f74",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:93a9bc1139f5c02a44fdbf51fb2ce0891e2a42033b66febb928ccf72e870408f",
"zh:977f75cdf365686aa16ae02dcc0fc1769bae6f86be6e165393756c940bfb4af0",
"zh:9afcda2660b3dc6ee6329acea532a719225bd1f6cf46f695feb2d77160847c1b",
"zh:9e3da67b1b05b03d1f0c18b8677edf3797815b5dd49629f10eb2f457dccbed30",
"zh:b8d7da230f5266367c1b6b1cf31aa39087e231a87d55b71bb9d5c854ca1fcfe6",
"zh:cc96e7cd7350456b7a11a53d1c76c73a542de8359f9750f637ff865e8e77be91",
"zh:da1f58d067def243047bb7178cb197e2b9c3a791a9eb380ad92b73290615ae29",
"zh:ed5f3e1f59a338bcdce0a2537a8a2f299cfe18c168626799b2a9c97af3d8d3cb",
"zh:f16cc31f73a58a26ffe2d93223eddb7c340623a4e2ef38c6091766fd611e3e11",
]
}
+18
View File
@@ -0,0 +1,18 @@
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
module "pages_project" {
source = "git::https://github.com/immich-app/devtools.git//tf/shared/modules/cloudflare-pages-project?ref=main"
cloudflare_api_token = var.cloudflare_api_token
cloudflare_account_id = var.cloudflare_account_id
app_name = "docs"
domain = "futo.cloud"
env = var.env
}
output "pages_project" {
value = module.pages_project.pages_project
}
+16
View File
@@ -0,0 +1,16 @@
include "root" {
path = find_in_parent_folders("terragrunt.hcl")
expose = true
}
# One Pages project per environment, shared by every stage of it.
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = merge(include.root.locals.s3_config, {
key = "yucca/pages/docs/project/${include.root.locals.env}/terraform.tfstate"
})
}
+13
View File
@@ -0,0 +1,13 @@
variable "cloudflare_api_token" {
type = string
sensitive = true
}
variable "cloudflare_account_id" {
type = string
}
variable "env" {
description = "prod (main) or dev (pull-request previews); names the Pages project."
type = string
}
+9
View File
@@ -0,0 +1,9 @@
terraform {
required_version = "~> 1.11"
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.8"
}
}
}
+24
View File
@@ -0,0 +1,24 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.8"
constraints = "~> 4.46, 4.52.8"
hashes = [
"h1:BGRNOzo8NUgbx5RwpWkWmr38f/s3txb7mzQhqfM5blI=",
"zh:08b305329a680a9213b2d8e642fbce7e4d97a524b1d2cef59e190ba9d678c477",
"zh:47975bd711ee18a46e589822171fa87474a552b332bfc8dea8fd1a64504eed8d",
"zh:5640d0d226bbafff3395542456c29feb942ca9c55ac01b4245a34c0590a33363",
"zh:5b0ad839fafba938c60a95d6b4a865843643591e97573ed2b9c4af3754064f74",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:93a9bc1139f5c02a44fdbf51fb2ce0891e2a42033b66febb928ccf72e870408f",
"zh:977f75cdf365686aa16ae02dcc0fc1769bae6f86be6e165393756c940bfb4af0",
"zh:9afcda2660b3dc6ee6329acea532a719225bd1f6cf46f695feb2d77160847c1b",
"zh:9e3da67b1b05b03d1f0c18b8677edf3797815b5dd49629f10eb2f457dccbed30",
"zh:b8d7da230f5266367c1b6b1cf31aa39087e231a87d55b71bb9d5c854ca1fcfe6",
"zh:cc96e7cd7350456b7a11a53d1c76c73a542de8359f9750f637ff865e8e77be91",
"zh:da1f58d067def243047bb7178cb197e2b9c3a791a9eb380ad92b73290615ae29",
"zh:ed5f3e1f59a338bcdce0a2537a8a2f299cfe18c168626799b2a9c97af3d8d3cb",
"zh:f16cc31f73a58a26ffe2d93223eddb7c340623a4e2ef38c6091766fd611e3e11",
]
}
+35
View File
@@ -0,0 +1,35 @@
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
module "site" {
source = "git::https://github.com/immich-app/devtools.git//tf/shared/modules/cloudflare-pages?ref=main"
cloudflare_api_token = var.cloudflare_api_token
cloudflare_account_id = var.cloudflare_account_id
pages_project = var.pages_project
app_name = "docs"
domain = "futo.cloud"
env = var.env
stage = var.stage
}
output "pages_project_name" {
value = module.site.pages_project_name
}
output "pages_branch" {
description = "The Pages branch wrangler uploads to: prod on main, pr-<n>dev for a preview."
value = module.site.pages_branch
}
output "hostname" {
description = "The custom domain serving this stage."
value = module.site.branch_subdomain
}
output "pages_hostname" {
description = "The *.pages.dev hostname the custom domain points at."
value = module.site.pages_branch_subdomain
}
+35
View File
@@ -0,0 +1,35 @@
include "root" {
path = find_in_parent_folders("terragrunt.hcl")
expose = true
}
dependency "project" {
config_path = "../project"
mock_outputs = {
pages_project = {
id = "mock"
name = "docs-futo-cloud-mock"
subdomain = "docs-futo-cloud-mock.pages.dev"
}
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
inputs = {
pages_project = dependency.project.outputs.pages_project
}
# One custom domain per stage (main, or a pr-<n> preview). The key carries a
# generation: cloudflare_pages_domain cannot refresh a hostname that no longer
# exists on the project, so a hostname change needs a clean state.
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = merge(include.root.locals.s3_config, {
key = "yucca/pages/docs/site/2/${include.root.locals.env}/${include.root.locals.stage == "" ? "main" : include.root.locals.stage}/terraform.tfstate"
})
}
+27
View File
@@ -0,0 +1,27 @@
variable "cloudflare_api_token" {
type = string
sensitive = true
}
variable "cloudflare_account_id" {
type = string
}
variable "env" {
description = "prod (main) or dev (pull-request previews)."
type = string
}
variable "stage" {
description = "Empty on main; pr-<n> for a pull-request preview."
type = string
}
variable "pages_project" {
description = "The environment's Pages project, from the project stack."
type = object({
id = string
name = string
subdomain = string
})
}
+9
View File
@@ -0,0 +1,9 @@
terraform {
required_version = "~> 1.11"
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.52.8"
}
}
}
+25
View File
@@ -0,0 +1,25 @@
# Cloudflare Pages sites. Outside tf/deployment so the infra workflow's stack
# discovery leaves these to the site's own workflow (.github/workflows/docs.yml).
locals {
env = get_env("TF_VAR_env")
stage = get_env("TF_VAR_stage", "")
s3_config = {
bucket = "yucca-tf-state"
region = "eu-west-par"
endpoints = {
s3 = "https://s3.eu-west-par.io.cloud.ovh.net/"
}
skip_credentials_validation = true
skip_requesting_account_id = true
skip_metadata_api_check = true
skip_region_validation = true
use_path_style = true
}
}
inputs = {
env = local.env
stage = local.stage
}