Add joplin web article (#9)

This commit is contained in:
deytenit authored and GitHub committed 2026-03-20 01:32:26 +03:00
1 parent 90bf68c4c8
commit 575959ffa2
37 files changed
+1019 -530

No files matched your search

+25 -4
View File
@@ -33,7 +33,7 @@
│ ├── stl/ # Standard Type Library (utilities)
│ └── ui/ # Shared React component library
├── services/ # Independent web services
│ ├── www/ # Main website (Astro + React)
│ ├── www/ # Main website (Rsbuild + React)
│ └── docs/ # Documentation site (Hugo)
├── .github/workflows/ # CI/CD automation
├── pnpm-workspace.yaml # Workspace configuration
@@ -44,7 +44,7 @@
#### Configuration Packages (`packages/`)
- **eslint-config**: Shared ESLint rules for TypeScript, React, and Astro
- **eslint-config**: Shared ESLint rules for TypeScript and React
- **rsbuild-config**: Shared Rsbuild and Rspack configurations for library and service builds
- **storybook-config**: Shared Storybook setup and configurations
@@ -52,10 +52,12 @@
- **stl** (Standard Type Library): Common utilities, types, and helper functions
- **ui**: React component library with design system and Storybook stories
- **i18n**: Internationalization utilities
#### Service Packages (`services/`)
- **www**: Main website built with Astro.js, React, and Tailwind CSS
- **www**: Main website built with Rsbuild, React, and Tailwind CSS
- **static**: Static landing pages service built with Rsbuild and React
- **docs**: Documentation site built with Hugo and Hextra theme
### Key Directory Rules
@@ -140,7 +142,9 @@ All packages use the `@ermnvldmr/` namespace:
- `@ermnvldmr/storybook-config` - Shared Storybook configurations
- `@ermnvldmr/stl` - Standard Type Library
- `@ermnvldmr/ui` - Component library
- `@ermnvldmr/i18n` - Internationalization utilities
- `@ermnvldmr/www` - Main website service
- `@ermnvldmr/static` - Static landing pages service
- `@ermnvldmr/docs` - Documentation service
### TypeScript Guidelines
@@ -175,6 +179,15 @@ export function cn(...inputs: ClassValue[]): string {
}
```
### Internationalization (i18n) Guidelines
- **Package**: Use the `@ermnvldmr/i18n` package for all translations across services.
- **Keyset Pattern**: Create `.i18n.ts` files alongside components that require translation.
- **Russian Translation Style**: Embrace the Russian language fully. Prefer phonetic transliteration/transcription for global brand names and descriptive terms over keeping them in English.
- **Brands**: 'GitHub' → 'ГитХаб', 'Instagram' → 'Инстаграм', 'Telegram' → 'Телеграм'.
- **Descriptive**: 'Email' → 'Электропочта'.
- **Source of Truth**: The English locale (`en`) serves as the primary reference for keys and structure.
### Component Development Patterns
#### UI Package Components
@@ -306,7 +319,7 @@ Each configuration package provides standardized setups:
#### ESLint Configuration
```javascript
// Provides: base, react, astro configurations
// Provides: base, react configurations
import { baseConfig } from '@ermnvldmr/eslint-config/dev';
```
@@ -421,3 +434,11 @@ describe('components/paper/Button/Button', () => {
---
_This guide serves as the architectural foundation for the monorepo. Focus on these stable patterns and structures rather than specific implementation details that may change over time._
## 🤖 AI Agent Best Practices
The following are explicit best practices and preferences intended for AI tools and agents operating within this repository:
- **Build System Preference**: Rsbuild and Rspack are the standard and strongly preferred build tools for all projects in this monorepo due to their Rust-based architecture and performance. Do not introduce Vite, Astro, or Webpack unless strictly necessary.
- **Framework Preference**: The monorepo has migrated away from Astro in favor of React + Rsbuild (with MDX support).
- **Content Sites**: Rspress is the preferred infrastructure for any new content-heavy or documentation sites.
+3 -1
View File
@@ -23,13 +23,15 @@ Composite (see [Copyright & License](#copyright--license))
#### Services
- **[www](./services/www)**: Main website, built with Astro and React.
- **[www](./services/www)**: Main website, built with Rsbuild, React, and MDX.
- **[static](./services/static)**: Static landing pages, built with Rsbuild and React.
- **[docs](./services/docs)**: Documentation site, built with Hugo and the Hextra theme.
#### Packages
- **[@ermnvldmr/ui](./packages/ui)**: Shared React component library and design system.
- **[@ermnvldmr/stl](./packages/stl)**: Standard Template Library – common utilities and reusable logic.
- **[@ermnvldmr/i18n](./packages/i18n)**: Internationalization utilities.
- **[@ermnvldmr/eslint-config](./packages/eslint-config)**: Shared linting configurations.
- **[@ermnvldmr/rsbuild-config](./packages/rsbuild-config)**: Shared build configurations (Rsbuild & Rspack).
- **[@ermnvldmr/storybook-config](./packages/storybook-config)**: Shared Storybook configurations.
-11
View File
@@ -1,6 +1,5 @@
// @ts-check
import { baseConfig } from '@ermnvldmr/eslint-config/dev';
import eslintPluginAstro from 'eslint-plugin-astro';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
@@ -22,20 +21,10 @@ export default [
'**/coverage/**',
'**/storybook-static/**',
'**/.next/**',
'**/.astro/**',
'**/*.d.ts',
],
},
// Astro
...eslintPluginAstro.configs.recommended,
{
files: ['services/www/**/*.astro', 'services/static/**/*.astro'],
rules: {
// Astro specific rules can go here
},
},
// Storybook - recommended configuration for flat config
...storybook.configs['flat/recommended'],
+3 -1
View File
@@ -43,7 +43,9 @@
"typecheck": "pnpm -r typecheck",
"test": "pnpm -r test",
"test:coverage": "pnpm -r test:coverage",
"clean": "pnpm -r clean && rm -rf node_modules"
"clean": "pnpm -r clean && rm -rf node_modules",
"build:deps": "pnpm -r --filter='./packages/*-config' build && pnpm --filter='./packages/i18n' build",
"postinstall": "pnpm build:deps"
},
"devDependencies": {
"globals": "^16.0.0",
-1
View File
@@ -13,7 +13,6 @@
"dependencies": {
"@eslint/js": "^9.39.2",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-astro": "^1.3.1",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsdoc": "^61.5.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
+3 -1
View File
@@ -22,7 +22,9 @@
"default": "./src/testing.ts"
}
},
"files": ["dist"],
"files": [
"dist"
],
"scripts": {
"build": "rsbuild build",
"test": "vitest run",
+1 -1
View File
@@ -35,7 +35,7 @@ export function localeRsbuildConfig(): RsbuildConfig {
// The canonical import is always `.en.mdx`; other locales are resolved here.
api.modifyRspackConfig((config, { rspack }) => {
config.plugins = [
...(config.plugins ?? []),
...config.plugins,
new rspack.NormalModuleReplacementPlugin(
/\.en\.mdx$/,
(resource: { request: string }) => {
+7 -3
View File
@@ -8,7 +8,12 @@ const { t, tRaw } = createKeyset({
'count {value}': { en: 'count {value}', ru: 'счёт {value}' },
items: {
en: { none: 'no items', one: 'one item', some: '{count} items', many: '{count} items' },
ru: { none: 'нет элементов', one: 'один элемент', some: '{count} элемента', many: '{count} элементов' },
ru: {
none: 'нет элементов',
one: 'один элемент',
some: '{count} элемента',
many: '{count} элементов',
},
},
});
@@ -17,8 +22,7 @@ describe('t', () => {
it('interpolates {value}', () => expect(t('count {value}', { value: '42' })).toBe('count 42'));
it('pluralizes none', () => expect(t('items', { count: 0 })).toBe('no items'));
it('pluralizes one', () => expect(t('items', { count: 1 })).toBe('one item'));
it('pluralizes many with interpolation', () =>
expect(t('items', { count: 5 })).toBe('5 items'));
it('pluralizes many with interpolation', () => expect(t('items', { count: 5 })).toBe('5 items'));
});
describe('tRaw', () => {
+8 -2
View File
@@ -4,10 +4,16 @@ import { pluralize, type PluralForms } from './pluralize.js';
import type { ReactNode } from 'react';
/** A translation entry with a plain string per locale. */
interface PlainTranslation { en: string; ru: string }
interface PlainTranslation {
en: string;
ru: string;
}
/** A translation entry with plural forms per locale. */
interface PluralTranslation { en: PluralForms; ru: PluralForms }
interface PluralTranslation {
en: PluralForms;
ru: PluralForms;
}
/** Union of all supported translation shapes. */
type Translation = PlainTranslation | PluralTranslation;
+2 -4
View File
@@ -5,8 +5,7 @@ import { localePath, localeHref } from './path.js';
describe('localePath', () => {
it('prepends LOCALE to absolute path', () =>
expect(localePath('/articles')).toBe('/en/articles'));
it('handles root path', () =>
expect(localePath('/')).toBe('/en/'));
it('handles root path', () => expect(localePath('/')).toBe('/en/'));
it('handles nested path', () =>
expect(localePath('/articles/2026/test')).toBe('/en/articles/2026/test'));
});
@@ -14,8 +13,7 @@ describe('localePath', () => {
describe('localeHref', () => {
it('replaces locale prefix in URL', () =>
expect(localeHref('/en/articles', 'ru')).toBe('/ru/articles'));
it('replaces locale at root', () =>
expect(localeHref('/en/', 'ru')).toBe('/ru/'));
it('replaces locale at root', () => expect(localeHref('/en/', 'ru')).toBe('/ru/'));
it('falls back to path if no prefix found', () =>
expect(localeHref('/articles', 'ru')).toBe('/ru/articles'));
});
+1 -3
View File
@@ -4,9 +4,7 @@ import { extendTailwindMerge } from 'tailwind-merge';
const RB_TYPES = ['display', 'headline', 'title', 'body', 'label'] as const;
const RB_SIZES = ['l', 'm', 's'] as const;
const rbFontSizeClasses = RB_TYPES.flatMap((type) =>
RB_SIZES.map((size) => `${type}-${size}`)
);
const rbFontSizeClasses = RB_TYPES.flatMap((type) => RB_SIZES.map((size) => `${type}-${size}`));
const twMerge = extendTailwindMerge({
extend: {
+6 -2
View File
@@ -38,8 +38,12 @@ export function createLocalStorage<T>(key: string, defaultValue: T): StorageMana
return {
key,
get: () => defaultValue,
set: () => { /* no-op in SSR/SSG context */ },
remove: () => { /* no-op in SSR/SSG context */ },
set: () => {
/* no-op in SSR/SSG context */
},
remove: () => {
/* no-op in SSR/SSG context */
},
};
}
+12 -4
View File
@@ -30,11 +30,19 @@ if (typeof window.localStorage.clear !== 'function') {
configurable: true,
value: {
getItem: (key: string): string | null => store.get(key) ?? null,
setItem: (key: string, value: string): void => { store.set(key, value); },
removeItem: (key: string): void => { store.delete(key); },
clear: (): void => { store.clear(); },
setItem: (key: string, value: string): void => {
store.set(key, value);
},
removeItem: (key: string): void => {
store.delete(key);
},
clear: (): void => {
store.clear();
},
key: (index: number): string | null => [...store.keys()][index] ?? null,
get length(): number { return store.size; },
get length(): number {
return store.size;
},
},
});
}
@@ -51,9 +51,6 @@ const MarkdownComponent = memo(function Markdown({ children, className }: Markdo
* <Markdown.H1>Title</Markdown.H1>
* <Markdown.P>Content goes here.</Markdown.P>
* </Markdown>
*
* // Or with Astro:
* <Content components={MARKDOWN_COMPONENTS} />
* ```
*/
export const Markdown = Object.assign(MarkdownComponent, {
@@ -24,7 +24,7 @@ import { MarkdownUL } from './MarkdownUL/MarkdownUL';
import type React from 'react';
/**
* A map of HTML tags to Markdown components for use with MDX or Astro Content.
* A map of HTML tags to Markdown components for use with MDX or other Markdown systems.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const MARKDOWN_COMPONENTS: Record<string, React.ComponentType<any>> = {
@@ -32,7 +32,7 @@ describe('PageHead', () => {
it('applies the correct classes for the strategy', () => {
render(<PageHead heading="Test" strategy="always-collapsed-fixed" />);
const header = screen.getByRole('banner');
expect(header).toHaveClass('fixed');
expect(header).toHaveClass('sticky');
expect(header).toHaveClass('shadow-md');
});
});
@@ -1,5 +1,5 @@
import { cn, useScroll, useResizeObserver } from '@ermnvldmr/stl';
import React, { type ReactNode, useRef, useEffect, useState } from 'react';
import { cn, useScroll } from '@ermnvldmr/stl';
import React, { type ReactNode, useEffect, useState } from 'react';
import { Header } from '../../Header/Header';
import { HStack } from '../../HStack/HStack';
@@ -68,8 +68,6 @@ export const PageHead = ({
}: PageHeadProps) => {
const { y: scrollY } = useScroll();
const [isCollapsed, setIsCollapsed] = useState(strategy.includes('always-collapsed'));
const headerRef = useRef<HTMLDivElement>(null);
const { height } = useResizeObserver(headerRef);
useEffect(() => {
if (strategy === 'collapsible-sticky') {
@@ -78,7 +76,7 @@ export const PageHead = ({
}, [scrollY, strategy]);
const rootClasses = cn(
'page-head w-full transition-all duration-200 bg-[var(--rb-background)] fixed top-0 z-10 border-b border-[var(--rb-outline)]/10',
'page-head w-full transition-all duration-200 bg-[var(--rb-background)] sticky top-0 z-10 border-b border-[var(--rb-outline)]/10',
{
'shadow-md bg-[var(--rb-background)]/90 backdrop-blur-md': isCollapsed,
},
@@ -124,8 +122,7 @@ export const PageHead = ({
);
return (
<>
<header ref={headerRef} className={rootClasses}>
<header className={rootClasses}>
<div
className={cn(
'grid transition-[grid-template-rows] duration-300 ease-in-out',
@@ -145,7 +142,5 @@ export const PageHead = ({
<div className="overflow-hidden">{collapsedContent}</div>
</div>
</header>
<div style={{ height }} />
</>
);
};
+25 -5
View File
@@ -132,29 +132,49 @@ describe('components/Text', () => {
// TDD: fails before implementation (old code uses text-[14px], not text-rb-body-m)
it('applies text-rb-{type}-{size} class', () => {
render(<Text size="m" type="body">Content</Text>);
render(
<Text size="m" type="body">
Content
</Text>
);
expect(screen.getByText('Content')).toHaveClass('text-rb-body-m');
});
it('does not apply legacy px-based font-size classes', () => {
render(<Text size="m" type="body">Content</Text>);
render(
<Text size="m" type="body">
Content
</Text>
);
expect(screen.getByText('Content')).not.toHaveClass('text-[14px]');
expect(screen.getByText('Content')).not.toHaveClass('leading-[20px]');
});
// Regression guards: pass before and after
it('applies font-medium for label type', () => {
render(<Text size="m" type="label">Label</Text>);
render(
<Text size="m" type="label">
Label
</Text>
);
expect(screen.getByText('Label')).toHaveClass('font-medium');
});
it('applies font-sans for body type', () => {
render(<Text size="m" type="body">Body</Text>);
render(
<Text size="m" type="body">
Body
</Text>
);
expect(screen.getByText('Body')).toHaveClass('font-sans');
});
it('applies font-serif for display type', () => {
render(<Text size="l" type="display">Display</Text>);
render(
<Text size="l" type="display">
Display
</Text>
);
expect(screen.getByText('Display')).toHaveClass('font-serif');
});
});
+13 -1
View File
@@ -119,7 +119,19 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
{...props}
ref={internalRef}
className={cn(
getTypographyClassNames({ type, size, color, bold, italic, underline, strike, align, wrap, overflow, maxLines }),
getTypographyClassNames({
type,
size,
color,
bold,
italic,
underline,
strike,
align,
wrap,
overflow,
maxLines,
}),
delay !== undefined &&
(isVisible
? 'animate-in fade-in slide-in-from-bottom-2 duration-1000 ease-out'
@@ -7,8 +7,12 @@ describe('lib/typography/getTypographyClassNames', () => {
});
it('returns correct class for each type/size combination', () => {
expect(getTypographyClassNames({ type: 'display', size: 'l' })).toContain('text-rb-display-l');
expect(getTypographyClassNames({ type: 'headline', size: 'm' })).toContain('text-rb-headline-m');
expect(getTypographyClassNames({ type: 'display', size: 'l' })).toContain(
'text-rb-display-l'
);
expect(getTypographyClassNames({ type: 'headline', size: 'm' })).toContain(
'text-rb-headline-m'
);
expect(getTypographyClassNames({ type: 'title', size: 's' })).toContain('text-rb-title-s');
expect(getTypographyClassNames({ type: 'body', size: 'l' })).toContain('text-rb-body-l');
expect(getTypographyClassNames({ type: 'label', size: 'm' })).toContain('text-rb-label-m');
@@ -54,7 +58,9 @@ describe('lib/typography/getTypographyClassNames', () => {
it('applies each color variant', () => {
expect(getTypographyClassNames({ color: 'muted' })).toContain('text-[var(--rb-muted-text)]');
expect(getTypographyClassNames({ color: 'primary' })).toContain('text-[var(--rb-primary-text)]');
expect(getTypographyClassNames({ color: 'primary' })).toContain(
'text-[var(--rb-primary-text)]'
);
expect(getTypographyClassNames({ color: 'error' })).toContain('text-[var(--rb-error-text)]');
expect(getTypographyClassNames({ color: 'inherit' })).toContain('text-inherit');
});
+104 -35
View File
@@ -191,29 +191,62 @@
@theme {
/* display */
--text-rb-display-l: 3.5625rem; --text-rb-display-l--line-height: 4rem; --text-rb-display-l--letter-spacing: -0.015625rem;
--text-rb-display-m: 2.8125rem; --text-rb-display-m--line-height: 3.25rem; --text-rb-display-m--letter-spacing: 0rem;
--text-rb-display-s: 2.25rem; --text-rb-display-s--line-height: 2.75rem; --text-rb-display-s--letter-spacing: 0rem;
--text-rb-display-l: 3.5625rem;
--text-rb-display-l--line-height: 4rem;
--text-rb-display-l--letter-spacing: -0.015625rem;
--text-rb-display-m: 2.8125rem;
--text-rb-display-m--line-height: 3.25rem;
--text-rb-display-m--letter-spacing: 0rem;
--text-rb-display-s: 2.25rem;
--text-rb-display-s--line-height: 2.75rem;
--text-rb-display-s--letter-spacing: 0rem;
/* headline */
--text-rb-headline-l: 2rem; --text-rb-headline-l--line-height: 2.5rem; --text-rb-headline-l--letter-spacing: 0rem;
--text-rb-headline-m: 1.75rem; --text-rb-headline-m--line-height: 2.25rem; --text-rb-headline-m--letter-spacing: 0rem;
--text-rb-headline-s: 1.5rem; --text-rb-headline-s--line-height: 2rem; --text-rb-headline-s--letter-spacing: 0rem;
--text-rb-headline-l: 2rem;
--text-rb-headline-l--line-height: 2.5rem;
--text-rb-headline-l--letter-spacing: 0rem;
--text-rb-headline-m: 1.75rem;
--text-rb-headline-m--line-height: 2.25rem;
--text-rb-headline-m--letter-spacing: 0rem;
--text-rb-headline-s: 1.5rem;
--text-rb-headline-s--line-height: 2rem;
--text-rb-headline-s--letter-spacing: 0rem;
/* title */
--text-rb-title-l: 1.375rem; --text-rb-title-l--line-height: 1.75rem; --text-rb-title-l--letter-spacing: 0rem;
--text-rb-title-m: 1rem; --text-rb-title-m--line-height: 1.5rem; --text-rb-title-m--letter-spacing: 0.009375rem;
--text-rb-title-s: 0.875rem; --text-rb-title-s--line-height: 1.25rem; --text-rb-title-s--letter-spacing: 0.00625rem;
--text-rb-title-l: 1.375rem;
--text-rb-title-l--line-height: 1.75rem;
--text-rb-title-l--letter-spacing: 0rem;
--text-rb-title-m: 1rem;
--text-rb-title-m--line-height: 1.5rem;
--text-rb-title-m--letter-spacing: 0.009375rem;
--text-rb-title-s: 0.875rem;
--text-rb-title-s--line-height: 1.25rem;
--text-rb-title-s--letter-spacing: 0.00625rem;
/* body — baseline raised: m = 1rem (was 14px/0.875rem) */
--text-rb-body-l: 1.125rem; --text-rb-body-l--line-height: 1.75rem; --text-rb-body-l--letter-spacing: 0.03125rem;
--text-rb-body-m: 1rem; --text-rb-body-m--line-height: 1.5rem; --text-rb-body-m--letter-spacing: 0.015625rem;
--text-rb-body-s: 0.875rem; --text-rb-body-s--line-height: 1.25rem; --text-rb-body-s--letter-spacing: 0.025rem;
--text-rb-body-l: 1.125rem;
--text-rb-body-l--line-height: 1.75rem;
--text-rb-body-l--letter-spacing: 0.03125rem;
--text-rb-body-m: 1rem;
--text-rb-body-m--line-height: 1.5rem;
--text-rb-body-m--letter-spacing: 0.015625rem;
--text-rb-body-s: 0.875rem;
--text-rb-body-s--line-height: 1.25rem;
--text-rb-body-s--letter-spacing: 0.025rem;
/* label */
--text-rb-label-l: 0.875rem; --text-rb-label-l--line-height: 1.25rem; --text-rb-label-l--letter-spacing: 0.00625rem; --text-rb-label-l--font-weight: 500;
--text-rb-label-m: 0.75rem; --text-rb-label-m--line-height: 1rem; --text-rb-label-m--letter-spacing: 0.03125rem; --text-rb-label-m--font-weight: 500;
--text-rb-label-s: 0.6875rem; --text-rb-label-s--line-height: 1rem; --text-rb-label-s--letter-spacing: 0.03125rem; --text-rb-label-s--font-weight: 500;
--text-rb-label-l: 0.875rem;
--text-rb-label-l--line-height: 1.25rem;
--text-rb-label-l--letter-spacing: 0.00625rem;
--text-rb-label-l--font-weight: 500;
--text-rb-label-m: 0.75rem;
--text-rb-label-m--line-height: 1rem;
--text-rb-label-m--letter-spacing: 0.03125rem;
--text-rb-label-m--font-weight: 500;
--text-rb-label-s: 0.6875rem;
--text-rb-label-s--line-height: 1rem;
--text-rb-label-s--letter-spacing: 0.03125rem;
--text-rb-label-s--font-weight: 500;
}
:root {
@@ -285,34 +318,70 @@
@layer components {
/* ── Scale parents — set CSS vars for all type children below ── */
.rb-scale-s {
--rb-sz-display: var(--text-rb-display-s); --rb-lh-display: var(--text-rb-display-s--line-height);
--rb-sz-headline: var(--text-rb-headline-s); --rb-lh-headline: var(--text-rb-headline-s--line-height);
--rb-sz-title: var(--text-rb-title-s); --rb-lh-title: var(--text-rb-title-s--line-height);
--rb-sz-body: var(--text-rb-body-s); --rb-lh-body: var(--text-rb-body-s--line-height);
--rb-sz-label: var(--text-rb-label-s); --rb-lh-label: var(--text-rb-label-s--line-height);
--rb-sz-display: var(--text-rb-display-s);
--rb-lh-display: var(--text-rb-display-s--line-height);
--rb-sz-headline: var(--text-rb-headline-s);
--rb-lh-headline: var(--text-rb-headline-s--line-height);
--rb-sz-title: var(--text-rb-title-s);
--rb-lh-title: var(--text-rb-title-s--line-height);
--rb-sz-body: var(--text-rb-body-s);
--rb-lh-body: var(--text-rb-body-s--line-height);
--rb-sz-label: var(--text-rb-label-s);
--rb-lh-label: var(--text-rb-label-s--line-height);
}
.rb-scale-m {
--rb-sz-display: var(--text-rb-display-m); --rb-lh-display: var(--text-rb-display-m--line-height);
--rb-sz-headline: var(--text-rb-headline-m); --rb-lh-headline: var(--text-rb-headline-m--line-height);
--rb-sz-title: var(--text-rb-title-m); --rb-lh-title: var(--text-rb-title-m--line-height);
--rb-sz-body: var(--text-rb-body-m); --rb-lh-body: var(--text-rb-body-m--line-height);
--rb-sz-label: var(--text-rb-label-m); --rb-lh-label: var(--text-rb-label-m--line-height);
--rb-sz-display: var(--text-rb-display-m);
--rb-lh-display: var(--text-rb-display-m--line-height);
--rb-sz-headline: var(--text-rb-headline-m);
--rb-lh-headline: var(--text-rb-headline-m--line-height);
--rb-sz-title: var(--text-rb-title-m);
--rb-lh-title: var(--text-rb-title-m--line-height);
--rb-sz-body: var(--text-rb-body-m);
--rb-lh-body: var(--text-rb-body-m--line-height);
--rb-sz-label: var(--text-rb-label-m);
--rb-lh-label: var(--text-rb-label-m--line-height);
}
.rb-scale-l {
--rb-sz-display: var(--text-rb-display-l); --rb-lh-display: var(--text-rb-display-l--line-height);
--rb-sz-headline: var(--text-rb-headline-l); --rb-lh-headline: var(--text-rb-headline-l--line-height);
--rb-sz-title: var(--text-rb-title-l); --rb-lh-title: var(--text-rb-title-l--line-height);
--rb-sz-body: var(--text-rb-body-l); --rb-lh-body: var(--text-rb-body-l--line-height);
--rb-sz-label: var(--text-rb-label-l); --rb-lh-label: var(--text-rb-label-l--line-height);
--rb-sz-display: var(--text-rb-display-l);
--rb-lh-display: var(--text-rb-display-l--line-height);
--rb-sz-headline: var(--text-rb-headline-l);
--rb-lh-headline: var(--text-rb-headline-l--line-height);
--rb-sz-title: var(--text-rb-title-l);
--rb-lh-title: var(--text-rb-title-l--line-height);
--rb-sz-body: var(--text-rb-body-l);
--rb-lh-body: var(--text-rb-body-l--line-height);
--rb-sz-label: var(--text-rb-label-l);
--rb-lh-label: var(--text-rb-label-l--line-height);
}
/* ── Type children — declare type, read from parent scale vars ── */
/* Fallback (second arg to var()) applies m-size when no rb-scale-* parent exists */
.rb-text-display { font-family: var(--font-serif); font-size: var(--rb-sz-display, var(--text-rb-display-m)); line-height: var(--rb-lh-display, var(--text-rb-display-m--line-height)); }
.rb-text-headline { font-family: var(--font-serif); font-size: var(--rb-sz-headline, var(--text-rb-headline-m)); line-height: var(--rb-lh-headline, var(--text-rb-headline-m--line-height)); }
.rb-text-title { font-family: var(--font-sans); font-size: var(--rb-sz-title, var(--text-rb-title-m)); line-height: var(--rb-lh-title, var(--text-rb-title-m--line-height)); }
.rb-text-body { font-family: var(--font-sans); font-size: var(--rb-sz-body, var(--text-rb-body-m)); line-height: var(--rb-lh-body, var(--text-rb-body-m--line-height)); }
.rb-text-label { font-family: var(--font-sans); font-weight: 500; font-size: var(--rb-sz-label, var(--text-rb-label-m)); line-height: var(--rb-lh-label, var(--text-rb-label-m--line-height)); }
.rb-text-display {
font-family: var(--font-serif);
font-size: var(--rb-sz-display, var(--text-rb-display-m));
line-height: var(--rb-lh-display, var(--text-rb-display-m--line-height));
}
.rb-text-headline {
font-family: var(--font-serif);
font-size: var(--rb-sz-headline, var(--text-rb-headline-m));
line-height: var(--rb-lh-headline, var(--text-rb-headline-m--line-height));
}
.rb-text-title {
font-family: var(--font-sans);
font-size: var(--rb-sz-title, var(--text-rb-title-m));
line-height: var(--rb-lh-title, var(--text-rb-title-m--line-height));
}
.rb-text-body {
font-family: var(--font-sans);
font-size: var(--rb-sz-body, var(--text-rb-body-m));
line-height: var(--rb-lh-body, var(--text-rb-body-m--line-height));
}
.rb-text-label {
font-family: var(--font-sans);
font-weight: 500;
font-size: var(--rb-sz-label, var(--text-rb-label-m));
line-height: var(--rb-lh-label, var(--text-rb-label-m--line-height));
}
}
@keyframes shimmer {
+2 -96
View File
@@ -33,9 +33,6 @@ importers:
eslint-config-prettier:
specifier: ^10.1.2
version: 10.1.8(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-astro:
specifier: ^1.3.1
version: 1.5.0(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-import:
specifier: ^2.32.0
version: 2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1))
@@ -231,7 +228,7 @@ importers:
version: 7.0.1
storybook-react-rsbuild:
specifier: ^1.0.3
version: 1.0.3(@rsbuild/core@1.7.3)(@storybook/test@8.6.15(storybook@8.6.17(prettier@3.8.1)))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.56.0)(storybook@8.6.17(prettier@3.8.1))(typescript@5.8.3)(webpack@5.105.1)
version: 1.0.3(@rsbuild/core@1.7.3)(@storybook/test@8.6.15(storybook@8.6.17(prettier@3.8.1)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.56.0)(storybook@8.6.17(prettier@3.8.1))(typescript@5.8.3)(webpack@5.105.1)
typescript:
specifier: ^5.8.3
version: 5.8.3
@@ -672,9 +669,6 @@ packages:
resolution: {integrity: sha512-Hb4o6h1Pf6yRUAX07DR4JVY7dmQw+RVQMW5/m55GoiAT/VRoKCWBtIUPPOnqDVhbx1Cjfil9b6EDrgJsUAujEQ==}
engines: {node: '>= 10'}
'@astrojs/compiler@2.13.0':
resolution: {integrity: sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw==}
'@astrojs/compiler@2.13.1':
resolution: {integrity: sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==}
@@ -1749,10 +1743,6 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@pkgr/core@0.2.9':
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -4209,16 +4199,6 @@ packages:
resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
hasBin: true
astro-eslint-parser@1.2.2:
resolution: {integrity: sha512-JepyLROIad6f44uyqMF6HKE2QbunNzp3mYKRcPoDGt0QkxXmH222FAFC64WTyQu2Kg8NNEXHTN/sWuUId9sSxw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
astrojs-compiler-sync@1.1.1:
resolution: {integrity: sha512-0mKvB9sDQRIZPsEJadw6OaFbGJ92cJPPR++ICca9XEyiUAZqgVuk25jNmzHPT0KF80rI94trSZrUR5iHFXGGOQ==}
engines: {node: ^18.18.0 || >=20.9.0}
peerDependencies:
'@astrojs/compiler': '>=0.27.0'
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@@ -4923,12 +4903,6 @@ packages:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
eslint-compat-utils@0.6.5:
resolution: {integrity: sha512-vAUHYzue4YAa2hNACjB8HvUQj5yehAZgiClyFVVom9cP8z5NSFq3PwB/TtJslN2zAMgRX6FCFCjYBbQh71g5RQ==}
engines: {node: '>=12'}
peerDependencies:
eslint: '>=6.0.0'
eslint-config-prettier@10.1.8:
resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
hasBin: true
@@ -4959,12 +4933,6 @@ packages:
eslint-import-resolver-webpack:
optional: true
eslint-plugin-astro@1.5.0:
resolution: {integrity: sha512-IWy4kY3DKTJxd7g652zIWpBGFuxw7NIIt16kyqc8BlhnIKvI8yGJj+Maua0DiNYED3F/D8AmzoTTTA6A95WX9g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: '>=8.57.0'
eslint-plugin-import@2.32.0:
resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==}
engines: {node: '>=4'}
@@ -6664,10 +6632,6 @@ packages:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'}
postcss-selector-parser@7.1.1:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
engines: {node: '>=4'}
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
@@ -7389,10 +7353,6 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
synckit@0.11.12:
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
engines: {node: ^14.18.0 || >=16.0.0}
tailwind-merge@3.4.0:
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
@@ -8101,8 +8061,6 @@ snapshots:
'@ast-grep/napi-win32-ia32-msvc': 0.37.0
'@ast-grep/napi-win32-x64-msvc': 0.37.0
'@astrojs/compiler@2.13.0': {}
'@astrojs/compiler@2.13.1':
optional: true
@@ -9291,8 +9249,6 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
'@pkgr/core@0.2.9': {}
'@polka/url@1.0.0-next.29': {}
'@radix-ui/primitive@1.1.3': {}
@@ -12819,28 +12775,6 @@ snapshots:
astring@1.9.0: {}
astro-eslint-parser@1.2.2:
dependencies:
'@astrojs/compiler': 2.13.0
'@typescript-eslint/scope-manager': 8.53.1
'@typescript-eslint/types': 8.53.1
astrojs-compiler-sync: 1.1.1(@astrojs/compiler@2.13.0)
debug: 4.4.3
entities: 6.0.1
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
espree: 10.4.0
fast-glob: 3.3.3
is-glob: 4.0.3
semver: 7.7.3
transitivePeerDependencies:
- supports-color
astrojs-compiler-sync@1.1.1(@astrojs/compiler@2.13.0):
dependencies:
'@astrojs/compiler': 2.13.0
synckit: 0.11.12
async-function@1.0.0: {}
async@3.2.6: {}
@@ -13668,11 +13602,6 @@ snapshots:
escape-string-regexp@5.0.0: {}
eslint-compat-utils@0.6.5(eslint@9.39.2(jiti@2.6.1)):
dependencies:
eslint: 9.39.2(jiti@2.6.1)
semver: 7.7.3
eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)):
dependencies:
eslint: 9.39.2(jiti@2.6.1)
@@ -13695,20 +13624,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-astro@1.5.0(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
'@jridgewell/sourcemap-codec': 1.5.5
'@typescript-eslint/types': 8.53.1
astro-eslint-parser: 1.2.2
eslint: 9.39.2(jiti@2.6.1)
eslint-compat-utils: 0.6.5(eslint@9.39.2(jiti@2.6.1))
globals: 16.5.0
postcss: 8.5.6
postcss-selector-parser: 7.1.1
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
@@ -16023,11 +15938,6 @@ snapshots:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-selector-parser@7.1.1:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
@@ -16840,7 +16750,7 @@ snapshots:
- tslib
- webpack
storybook-react-rsbuild@1.0.3(@rsbuild/core@1.7.3)(@storybook/test@8.6.15(storybook@8.6.17(prettier@3.8.1)))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.56.0)(storybook@8.6.17(prettier@3.8.1))(typescript@5.8.3)(webpack@5.105.1):
storybook-react-rsbuild@1.0.3(@rsbuild/core@1.7.3)(@storybook/test@8.6.15(storybook@8.6.17(prettier@3.8.1)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.56.0)(storybook@8.6.17(prettier@3.8.1))(typescript@5.8.3)(webpack@5.105.1):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.56.0)
'@rsbuild/core': 1.7.3
@@ -17023,10 +16933,6 @@ snapshots:
symbol-tree@3.2.4: {}
synckit@0.11.12:
dependencies:
'@pkgr/core': 0.2.9
tailwind-merge@3.4.0: {}
tailwindcss@4.1.18: {}
-1
View File
@@ -1,7 +1,6 @@
/**
* Type definitions for the static service
*/
/// <reference path="../.astro/types.d.ts" />
interface PerformanceNavigationTiming {
responseStatus?: number;
+1 -1
View File
@@ -9,6 +9,6 @@
"@ermnvldmr/ui/*": ["../../../packages/ui/src/*"]
}
},
"include": [".astro/types.d.ts", "src/**/*"],
"include": ["src/**/*"],
"exclude": ["dist", "node_modules", "**/*.config.*"]
}
@@ -0,0 +1,227 @@
---
title: 'Joplin Web Deployment Guide'
description: 'Joplin Server & Web App deployment from scratch'
createdDate: 2026-03-20
tags: ['joplin', 'deployment', 'guide']
---
This guide describes how to deploy a private note-taking infrastructure using the Joplin Server and its web client.
## Why I Chose Joplin?
I have been using Joplin for a long time, and it has become my primary note-taking tool. There are several reasons why I chose it over other alternatives:
- **Local-First**: Notes are stored locally on your device, ensuring fast access and full ownership (especially when the internet is inaccessible).
- **Markdown Format**: Joplin uses Markdown for all notes, making it incredibly easy to export, import, or move your data to another platform if needed.
- **Easy Sharing**: The ability to share notes via the web makes collaboration or public sharing straightforward.
- **Backups**: Joplin provides robust backup options, giving me peace of mind that my data is safe.
One thing that was missing for me was the ability to quickly add or view notes when I don't have access to my primary devices (for example, at work). This article provides the solution: a self-hosted web client.
## Technical Stack Overview
1. **Joplin Server**: The synchronization server. It handles the Joplin sync protocol, account logic, and note synchronization across devices.
2. **Postgres**: The database. Essential for production performance.
3. **Joplin Web App**: A browser-based client built from the Joplin mobile app source code using React Native for Web.
## 1. The Deployment Stack
This configuration is generic, uses **Docker Volumes**, and provides a minimal setup for deploying the full stack.
### Main docker-compose.yml
This compose file is a minimal configuration to run the Joplin Server and the web app. It assumes you understand how to set up a reverse proxy, or better yet, already have your own Joplin setup.
```yaml
x-environment: &shared-environment
TZ: 'Europe/Moscow'
POSTGRES_DB: 'joplin'
POSTGRES_USER: ${SHARED_USERNAME?:error}
POSTGRES_PASSWORD: ${SHARED_PASSWORD?:error}
services:
postgres:
image: postgres:16
container_name: joplin-postgres
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
<<: *shared-environment
restart: unless-stopped
server:
image: joplin/server:3.5.2
container_name: joplin-server
depends_on:
- postgres
environment:
<<: *shared-environment
APP_PORT: 22300
APP_BASE_URL: 'https://example.com'
DB_CLIENT: 'pg'
POSTGRES_HOST: 'joplin-postgres'
POSTGRES_PORT: 5432
POSTGRES_DATABASE: 'joplin'
ports:
- '22300:22300'
restart: unless-stopped
app:
build:
context: ./app
args:
- GIT_TAG=v3.5.13
container_name: joplin-app
environment:
<<: *shared-environment
ports:
- '8080:8080'
restart: unless-stopped
volumes:
postgres_data:
```
### Dockerfile
Create an `app` directory and place this `Dockerfile` inside it to build the web client. We build our own image since the original authors don't provide one.
While there are repositories with pre-built images, it's easy enough to just create our own.
```dockerfile
# Stage 1: Build
FROM node:20-bookworm-slim AS builder
ARG GIT_TAG=v3.5.13
# Install build dependencies for Joplin monorepo
RUN apt-get update && apt-get install -y \
git python3 build-essential rsync \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Configure regional mirror and timeout for reliability
RUN corepack enable && \
yarn config set registry https://registry.npmmirror.com
# Clone specific tag
RUN git clone --depth 1 --branch ${GIT_TAG} https://github.com/laurent22/joplin.git .
# Install dependencies and build the web distribution
RUN yarn install --network-timeout 1000000
RUN cd packages/app-mobile/ && yarn web
# Stage 2: Production Server
FROM nginx:alpine
# Remove default assets
RUN rm -rf /usr/share/nginx/html/*
# Copy built app and custom nginx config
COPY --from=builder /app/packages/app-mobile/web/dist /usr/share/nginx/html/app
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]
```
### Nginx configuration
Place this `nginx.conf` alongside the `Dockerfile` in the `app` directory. Joplin is an SPA, meaning it doesn't matter where it's deployed since all navigation is handled internally without location dependency. So it's alright to just deploy it on a path that doesn't overlap with the main server, such as `/app`.
> This is a pretty minimal configuration; it assumes your reverse proxy is good enough to cover all questionable items, but you can expand it as you like.
```nginx
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression for performance
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_min_length 1000;
# SPA Routing: Fall back to index.html if a file isn't found
location /app/ {
try_files $uri $uri/ /app/index.html;
}
# Cache static assets
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|webmanifest)$ {
expires 6M;
access_log off;
add_header Cache-Control "public, max-age=15552000, immutable";
}
}
```
## 2. Configuration
Create a `.env` file in the same directory as your `docker-compose.yml`. **Do not skip the secret generation step below.**
```bash
# --- Database Credentials ---
SHARED_USERNAME=joplin
SHARED_PASSWORD=generate_this_with_openssl
```
### Generating Secrets
Use `openssl` to easily generate secure, random strings for your `.env` entries:
```bash
# Generate SHARED_PASSWORD
openssl rand -hex 32
```
## 3. Deployment
Start your stack by running the following command:
```bash
docker compose up -d --build
```
## 4. Initial Server Setup
Before configuring the web app, you must initialize your Joplin Server:
Follow the official guide: [Joplin Server README](https://github.com/laurent22/joplin/blob/dev/packages/server/README.md)
Essentially, you need to ensure that basic synchronization via the app is functional.
## 5. Web App Showcase & Setup
Once the web app is running at `https://example.com/app/`, you will be greeted by it.
### About the Web App
![Joplin Web App Interface](https://static.ermnvldmr.com/www/articles/2026/joplin-web-deployment-guide/joplin-web-interface.webp)
It is basically the same mobile Joplin app, but in-browser. It leverages the LocalStorage API for data handling. Everything is still local-first, so after logging in, it will take some time to synchronize all remote notes.
But most importantly: _The Joplin web interface allows you to view and edit your notes directly from the browser._
> It is said that it's only guaranteed to work on Chrome due to the required API compatibility; however, I have had great success with Firefox as well. Browsers are catching up quickly.
### Authentication Setup
To connect to your self-hosted Joplin Server, go to **Settings > Synchronization** and configure the following parameters:
- **Sync Target**: Joplin Server
- **Joplin Server URL**: `https://example.com`
- **Joplin Server Email**: `your-email@example.com` (the one you just created/configured)
- **Joplin Server Password**: `your-password`
![Joplin Web App Setup](https://static.ermnvldmr.com/www/articles/2026/joplin-web-deployment-guide/joplin-web-setup.webp)
_Enjoy!_
## Resources
- [Joplin GitHub](https://github.com/laurent22/joplin)
- [Joplin Server README](https://github.com/laurent22/joplin/blob/dev/packages/server/README.md)
@@ -0,0 +1,227 @@
---
title: 'Руководство по развертыванию Joplin Web'
description: 'Развертывание Joplin Server и веб-приложения с нуля'
createdDate: 2026-03-20
tags: ['joplin', 'deployment', 'guide']
---
Это руководство описывает, как развернуть приватную инфраструктуру для заметок, используя Joplin Server и его веб-клиент.
## Почему я выбрал Joplin?
Я давно пользуюсь Joplin, и он стал моим основным инструментом для ведения заметок. Есть несколько причин, по которым я выбрал его среди других альтернатив:
- **Local-First**: Заметки хранятся локально на вашем устройстве, что обеспечивает быстрый доступ и полное владение данными (особенно когда интернет недоступен).
- **Формат Markdown**: Joplin использует Markdown для всех заметок, что позволяет невероятно легко экспортировать, импортировать или переносить данные на другую платформу при необходимости.
- **Удобный обмен**: Возможность делиться заметками через веб делает совместную работу или публичный доступ простыми.
- **Резервное копирование**: Joplin предоставляет надежные варианты резервного копирования, давая мне уверенность в сохранности моих данных.
Одной вещи мне не хватало — возможности быстро добавить или просмотреть заметки, когда у меня нет доступа к моим основным устройствам (например, на работе). Эта статья предлагает решение: самостоятельно размещенный (self-hosted) веб-клиент.
## Обзор технологического стека
1. **Joplin Server**: Сервер синхронизации. Он обрабатывает протокол синхронизации Joplin, логику учетных записей и синхронизацию заметок между устройствами.
2. **Postgres**: База данных. Необходима для производительности в рабочей среде.
3. **Joplin Web App**: Браузерный клиент, созданный из исходного кода мобильного приложения Joplin с использованием React Native for Web.
## 1. Стек развертывания
Эта конфигурация является универсальной, использует **Docker Volumes** и предоставляет минимальную настройку для развертывания полного стека.
### Основной docker-compose.yml
Этот compose-файл представляет собой минимальную конфигурацию для запуска Joplin Server и веб-приложения. Предполагается, что вы понимаете, как настроить обратный прокси-сервер, или, что еще лучше, у вас уже есть собственная установка Joplin.
```yaml
x-environment: &shared-environment
TZ: 'Europe/Moscow'
POSTGRES_DB: 'joplin'
POSTGRES_USER: ${SHARED_USERNAME?:error}
POSTGRES_PASSWORD: ${SHARED_PASSWORD?:error}
services:
postgres:
image: postgres:16
container_name: joplin-postgres
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
<<: *shared-environment
restart: unless-stopped
server:
image: joplin/server:3.5.2
container_name: joplin-server
depends_on:
- postgres
environment:
<<: *shared-environment
APP_PORT: 22300
APP_BASE_URL: 'https://example.com'
DB_CLIENT: 'pg'
POSTGRES_HOST: 'joplin-postgres'
POSTGRES_PORT: 5432
POSTGRES_DATABASE: 'joplin'
ports:
- '22300:22300'
restart: unless-stopped
app:
build:
context: ./app
args:
- GIT_TAG=v3.5.13
container_name: joplin-app
environment:
<<: *shared-environment
ports:
- '8080:8080'
restart: unless-stopped
volumes:
postgres_data:
```
### Dockerfile
Создайте директорию `app` и поместите в нее этот `Dockerfile` для сборки веб-клиента. Мы собираем собственный образ, так как авторы оригинала его не предоставляют.
Хотя существуют репозитории с готовыми образами, создать свой достаточно просто.
```dockerfile
# Stage 1: Build
FROM node:20-bookworm-slim AS builder
ARG GIT_TAG=v3.5.13
# Install build dependencies for Joplin monorepo
RUN apt-get update && apt-get install -y \
git python3 build-essential rsync \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Configure regional mirror and timeout for reliability
RUN corepack enable && \
yarn config set registry https://registry.npmmirror.com
# Clone specific tag
RUN git clone --depth 1 --branch ${GIT_TAG} https://github.com/laurent22/joplin.git .
# Install dependencies and build the web distribution
RUN yarn install --network-timeout 1000000
RUN cd packages/app-mobile/ && yarn web
# Stage 2: Production Server
FROM nginx:alpine
# Remove default assets
RUN rm -rf /usr/share/nginx/html/*
# Copy built app and custom nginx config
COPY --from=builder /app/packages/app-mobile/web/dist /usr/share/nginx/html/app
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]
```
### Конфигурация Nginx
Поместите этот `nginx.conf` рядом с `Dockerfile` в директории `app`. Joplin — это SPA (Single Page Application), а значит, не имеет значения, где оно развернуто, так как вся навигация обрабатывается внутренне без зависимости от местоположения. Поэтому вполне нормально развернуть его по пути, который не пересекается с основным сервером, например `/app`.
> Это довольно минималистичная конфигурация; предполагается, что ваш обратный прокси достаточно хорош, чтобы закрыть все спорные моменты, но вы можете расширить ее по своему усмотрению.
```nginx
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip-сжатие для производительности
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_min_length 1000;
# SPA-роутинг: возврат к index.html, если файл не найден
location /app/ {
try_files $uri $uri/ /app/index.html;
}
# Кэширование статических ресурсов
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|webmanifest)$ {
expires 6M;
access_log off;
add_header Cache-Control "public, max-age=15552000, immutable";
}
}
```
## 2. Конфигурация
Создайте файл `.env` в той же директории, что и `docker-compose.yml`. **Не пропускайте шаг генерации секретов ниже.**
```bash
# --- Учетные данные базы данных ---
SHARED_USERNAME=joplin
SHARED_PASSWORD=generate_this_with_openssl
```
### Генерация секретов
Используйте `openssl`, чтобы легко создать безопасные случайные строки для записей в вашем `.env`:
```bash
# Генерация SHARED_PASSWORD
openssl rand -hex 32
```
## 3. Развертывание
Запустите стек, выполнив следующую команду:
```bash
docker compose up -d --build
```
## 4. Начальная настройка сервера
Перед настройкой веб-приложения необходимо инициализировать ваш Joplin Server:
Следуйте официальному руководству: [Joplin Server README](https://github.com/laurent22/joplin/blob/dev/packages/server/README.md)
По сути, вам нужно убедиться, что базовая синхронизация через приложение работает.
## 5. Демонстрация и настройка веб-приложения
Как только веб-приложение запустится по адресу `https://example.com/app/`, оно поприветствует вас.
### О веб-приложении
![Интерфейс Joplin Web](https://static.ermnvldmr.com/www/articles/2026/joplin-web-deployment-guide/joplin-web-interface.webp)
По сути, это то же самое мобильное приложение Joplin, но в браузере. Оно использует LocalStorage API для обработки данных. Все по-прежнему работает по принципу local-first, поэтому после входа в систему потребуется время для синхронизации всех удаленных заметок.
Но самое главное: _Веб-интерфейс Joplin позволяет просматривать и редактировать заметки прямо из браузера._
> Говорят, что работа гарантирована только в Chrome из-за требований к совместимости API; однако я добился отличных результатов и в Firefox. Браузеры быстро догоняют друг друга.
### Настройка аутентификации
Чтобы подключиться к вашему self-hosted серверу Joplin, перейдите в **Settings > Synchronization** и настройте следующие параметры:
- **Sync Target**: Joplin Server
- **Joplin Server URL**: `https://example.com`
- **Joplin Server Email**: `your-email@example.com` (тот, который вы только что создали/настроили)
- **Joplin Server Password**: `your-password`
![Настройка Joplin Web](https://static.ermnvldmr.com/www/articles/2026/joplin-web-deployment-guide/joplin-web-setup.webp)
_Наслаждайтесь!_
## Ресурсы
- [Joplin GitHub](https://github.com/laurent22/joplin)
- [Joplin Server README](https://github.com/laurent22/joplin/blob/dev/packages/server/README.md)
@@ -1,182 +1,154 @@
---
title: 'Xray Subnet Mimicry'
description: 'Building a distributed 10.0.0.0/8 private network with Xray-core'
createdDate: 2026-03-08
tags: ['networking', 'guide']
title: 'Xray Subnet Mimicry (10.0.0.0/8)'
description: 'Building a distributed private network with Xray-core and reverse tunneling'
createdDate: 2026-03-20
tags: ['networking', 'xray', 'guide']
---
> This guide is for **educational purposes** only. It is designed as a technical demonstration of Xray-core's routing capabilities and private subnet mimicry. It is NOT intended for restriction evasion or illegal activities. Use this knowledge responsibly and in compliance with your local laws.
> **EDUCATIONAL PURPOSES ONLY**
> This project is designed as a technical demonstration of Xray-core's routing capabilities and private subnet mimicry. It is NOT intended for restriction evasion or illegal activities. Use this knowledge responsibly and in compliance with your local laws.
## Why Subnet Mimicry?
This guide describes how to deploy a private note-taking infrastructure (or any distributed service) using a contiguous `10.0.0.0/8` private subnet mimicked across distinct servers using **Xray-core** and **reverse tunneling**.
By constructing a "Flat Network" (Layer 3) over the public internet, you achieve a unified private address space (`10.0.0.0/8`) distributed across distinct servers.
## Technical Deep-Dive & Architecture
- **Distributed Topology**: Each node is an authoritative gateway for its own `/16` segment (e.g., Hub on `10.1.0.0/16`, Spokes on `10.2.0.0/16` and `10.3.0.0/16`).
- **NAT Traversal**: Spokes (Bridges) actively initiate connections to the Hub (Portal), allowing nodes behind NAT to be reachable.
- **Centralized Resolution**: Enforce network-wide DNS policies through a central Hub resolver.
### The Virtual Subnet Concept
---
We are constructing a "Flat Network" (Layer 3) over the public internet. Each node acts as an authoritative gateway for its own `/16` segment of the `10.0.0.0/8` range. This allows seamless communication between servers as if they were on the same local network.
## Technical Stack Overview
### The Science of Interception (TPROXY)
1. **Xray-core**: The routing engine handling VLESS connections, reverse tunneling, and TPROXY interception.
2. **TPROXY**: Transparent proxying in the Linux Mangle table that intercepts TCP/UDP traffic while preserving original destination IPs.
3. **Policy Routing**: Using `ip rule` and routing Table 100 to force intercepted packets into the local loopback for Xray to process.
4. **Mux.cool**: A multiplexing protocol that packs hundreds of virtual connections (TCP/UDP streams) into a single physical TLS connection.
5. **AdGuard Home**: The centralized DNS resolver for the entire subnet.
6. **Nginx**: Used as camouflage on the main public inbound port to hide the Xray instance.
Unlike `REDIRECT`, which only works for TCP and modifies the destination IP, **TPROXY** (Transparent Proxy) allows Xray to intercept both TCP and UDP while preserving the original packet headers.
---
- **Mechanism**: When a packet destined for `10.x.x.x` enters the `PREROUTING` or `OUTPUT` chain, the Linux Mangle table marks it with `0x1`.
- **Preservation**: Xray's `tunnel` inbound uses the `tproxy` socket option to read the original destination IP from the socket metadata, enabling it to route the packet correctly.
## 1. The Deployment Stack
### Policy Routing (Table 100)
This configuration represents the Central Hub (Server A) which acts as the Portal for Spoke connections. The Hub hosts the centralized DNS and the fallback Nginx camouflage.
While firewall marks identify intercepted packets, they don't inherently change the routing path. To redirect this traffic to Xray, we use **Policy Routing**:
```yaml
name: 'a'
1. **The Rule (`ip rule`)**: `ip rule add fwmark 1 lookup 100` instructs the networking stack to use a custom routing table for marked packets.
2. **The Table (`ip route`)**: Table 100 is configured with `local default dev lo`, treating all destination IPs as local and routing them to the loopback interface.
3. **The Handoff**: Xray, listening on `127.0.0.1:12345` with the `tproxy` option, intercepts the packet from the loopback interface.
4. **Loop Prevention**: We use `mark: 255` in Xray's outbounds to exempt its own traffic from interception, avoiding infinite routing loops.
services:
xray:
image: ghcr.io/xtls/xray-core:26.1.23
container_name: a-xray
network_mode: host
volumes:
- ./xray.jsonc:/etc/xray/config.jsonc:ro
- /etc/letsencrypt/live:/etc/letsencrypt/live:ro
command: -c '/etc/xray/config.jsonc'
restart: unless-stopped
cap_add:
- NET_ADMIN
- NET_RAW
### The Reverse Proxy Mechanism
nginx:
image: nginxinc/nginx-unprivileged:1.29-alpine
container_name: a-nginx
networks:
direct:
ipv4_address: 10.1.1.4
restart: unless-stopped
security_opt:
- no-new-privileges:true
To reach servers behind NAT (Spokes) from a central Hub, Xray employs a sophisticated Reverse Proxy architecture:
adguard:
image: adguard/adguardhome:v0.107.72
container_name: root-adguard
volumes:
- adguard-work:/opt/adguardhome/work
- adguard-conf:/opt/adguardhome/conf
networks:
direct:
ipv4_address: 10.1.1.3
restart: unless-stopped
cap_add:
- NET_BIND_SERVICE
- SYS_NICE
- CAP_CHOWN
security_opt:
- no-new-privileges:true
- **Bridge (Spoke)**: Initiates the connection to the Portal. It acts as a client for the tunnel but functionally behaves as a server once the connection is established.
- **Portal (Hub)**: Listens for Bridge connections and uses "Dummy Domains" to identify them, relaying traffic through the established tunnels.
- **Multiplexing (Mux)**: Uses [Mux.cool](https://xtls.github.io/en/development/protocols/muxcool.html) to pack hundreds of virtual streams into a single physical TLS connection, reducing overhead and bypassing NAT timeouts.
networks:
direct:
name: a-direct
driver: bridge
ipam:
config:
- subnet: 10.1.0.0/16 # Using the full /16 as defined for Server A
gateway: 10.1.0.1
## Setup Guide
volumes:
adguard-work:
name: a-adguard-work
adguard-conf:
name: a-adguard-conf
```
### Phase 0: Prerequisites (Server A only)
---
Server A (the Hub) requires a valid SSL certificate for XTLS-Vision. Point your domain (`A-DOMAIN`) to Server A's public IP and ensure certificates are available (e.g., via Certbot) at `/etc/letsencrypt/live/A-DOMAIN/`.
## 2. Configuration & Routing
### Phase 1: Security & Identity
### Generating Identity UUIDs
Each connection needs its own cryptographic "Key". You need 4 UUIDs mapped to your config files.
```bash
# Generate UUIDs for the Server B and Server C mappings
docker run --rm ghcr.io/xtls/xray-core uuid
```
The mapping is as follows:
Each connection requires a unique UUID. Generate 4 UUIDs to map the following connections:
- `UUID_B_REVERSE`: Tunnel control for Server B.
- `UUID_B_DIRECT`: Data traffic for Server B.
- `UUID_C_REVERSE`: Tunnel control for Server C.
- `UUID_C_DIRECT`: Data traffic for Server C.
### Policy Routing (Table 100)
### Phase 2: Host Networking
Apply the routing configuration script on **EVERY** server node to set up the kernel parameters and routing Table 100. This tricks the kernel into handing the packet to our TPROXY inbound listening on `127.0.0.1:12345`.
On **EVERY** server node, perform the following:
```bash
#!/usr/bin/env bash
1. **Initialize Routing**: Run the routing setup script to configure kernel parameters and Table 100.
2. **Firewall Configuration**: Update `/etc/ufw/before.rules`. Insert the specific node rules immediately after the `*filter` header. These rules handle the `mangle` table logic required for TPROXY.
3. **Reload**: Apply changes with `sudo ufw reload`.
# 1. Enable IP Forwarding
sudo sysctl -w net.ipv4.ip_forward=1
### Phase 3: Deployment
# 2. IP Rule for mark 0x1 -> table 100
sudo ip rule add fwmark 0x1 lookup 100 priority 100
Update your `xray.jsonc` configurations with the generated UUIDs, your domain, and public IPs. Launch the stack using `docker compose up -d`. On Server A, the compose setup will also initialize the internal bridge network for services like AdGuard and Nginx.
# 3. Local route in table 100
sudo ip route add local 0.0.0.0/0 dev lo table 100
## Configuration Examples
### Server A: Hub (10.1.0.0/16)
The Hub handles reverse tunnel entries and routes traffic to the appropriate Spoke based on the 10.x.x.x range.
Full configuration available here: [a/xray.jsonc](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/a/xray.jsonc)
```jsonc
{
"inbounds": [
{
"port": 443,
"protocol": "vless",
"tag": "proxy-direct",
"settings": {
"clients": [
{ "id": "REPLACE_WITH_UUID_B_REVERSE", "reverse": { "tag": "reverse-b" } },
{ "id": "REPLACE_WITH_UUID_B_DIRECT" },
// ... repeat for other Spokes
],
},
},
{
"tag": "proxy-enclave",
"protocol": "tunnel",
"streamSettings": { "sockopt": { "tproxy": "tproxy", "mark": 255 } },
},
],
"routing": {
"rules": [
{ "ip": ["10.2.0.0/16"], "outboundTag": "reverse-b" },
{ "ip": ["10.3.0.0/16"], "outboundTag": "reverse-c" },
],
},
}
```
---
### Server B: Spoke (10.2.0.0/16)
## 3. Firewall Interception
The Spoke initiates the reverse connection to the Hub and handles local subnet breakout.
Full configuration available here: [b/xray.jsonc](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/b/xray.jsonc)
Open `/etc/ufw/before.rules` and insert the specific node rules immediately after the `*filter` header requirements. Below is the configuration for Server A (Hub, `10.1.0.0/16`):
```text
*mangle
:XRAY - [0:0]
:XRAY_MASK - [0:0]
:DIVERT - [0:0]
# --- PREROUTING CHAIN (Traffic from other containers/nodes) ---
-A XRAY -d 10.1.0.0/16 -j RETURN
-A XRAY ! -d 10.0.0.0/8 -j RETURN
-A XRAY -m mark --mark 0xff -j RETURN
-A XRAY -p udp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
-A XRAY -p tcp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
-A PREROUTING -j XRAY
# --- OUTPUT CHAIN (Traffic originating from the server itself) ---
-A XRAY_MASK -d 10.1.0.0/16 -j RETURN
-A XRAY_MASK ! -d 10.0.0.0/8 -j RETURN
-A XRAY_MASK -m mark --mark 0xff -j RETURN
-A XRAY_MASK -p udp -j MARK --set-mark 1
-A XRAY_MASK -p tcp -j MARK --set-mark 1
-A OUTPUT -j XRAY_MASK
# --- DIVERT Optimization ---
-A DIVERT -j MARK --set-mark 1
-A DIVERT -j ACCEPT
-I PREROUTING -p tcp -m socket -j DIVERT
COMMIT
```jsonc
{
"outbounds": [
{
"protocol": "vless",
"tag": "forward-a-reverse",
"settings": {
"address": "A-EXTERNAL-IP",
"id": "REPLACE_WITH_UUID_B_REVERSE",
"reverse": { "tag": "reverse-b" },
},
},
{
"protocol": "vless",
"tag": "forward-a",
"settings": {
"address": "A-EXTERNAL-IP",
"id": "REPLACE_WITH_UUID_B_DIRECT",
},
},
],
"routing": {
"rules": [
{ "inboundTag": ["proxy-enclave"], "outboundTag": "forward-a" },
{ "inboundTag": ["reverse-b"], "outboundTag": "forward-direct" },
{ "ip": ["10.2.0.0/16"], "outboundTag": "forward-direct" },
{ "ip": ["10.0.0.0/8"], "outboundTag": "forward-a" },
],
},
}
```
Reload UFW once applied:
## Resources & Reference
```bash
sudo ufw reload
```
For a complete, automated example of this architecture, check out the following repository:
---
- [xray-core-subnet-mimicry on GitHub](https://github.com/deytenit/xray-core-subnet-mimicry/tree/main)
## Resources
Additional technical references:
- [Xray-core Official Documentation](https://xtls.github.io/)
- [Xray Reverse Proxy Documentation](https://xtls.github.io/en/config/reverse.html)
- [Project X (Xray) GitHub](https://github.com/XTLS/Xray-core)
- [Understanding TPROXY (Cloudflare Blog)](https://blog.cloudflare.com/how-we-built-spectrum/)
- [Linux Kernel TPROXY Documentation](https://www.kernel.org/doc/Documentation/networking/tproxy.txt)
- [Cloudflare Blog: TPROXY](https://blog.cloudflare.com/how-we-built-spectrum/)
@@ -1,182 +1,154 @@
---
title: 'Мимикрия подсетей Xray'
description: 'Построение распределённой приватной сети 10.0.0.0/8 с использованием Xray-core'
createdDate: 2026-03-08
tags: ['networking', 'guide']
title: 'Имитация подсети Xray (10.0.0.0/8)'
description: 'Создание распределенной частной сети с использованием Xray-core и обратного туннелирования'
createdDate: 2026-03-20
tags: ['networking', 'xray', 'guide']
---
> Это руководство предназначено **исключительно для образовательных целей**. Оно демонстрирует маршрутизацию в Xray-core и подход к мимикрии приватной подсети. Материал НЕ предназначен для обхода ограничений или незаконной деятельности. Используйте эти знания ответственно и в соответствии с местным законодательством.
> **ТОЛЬКО ДЛЯ ОБРАЗОВАТЕЛЬНЫХ ЦЕЛЕЙ**
> Этот проект разработан как техническая демонстрация возможностей маршрутизации Xray-core и имитации частных подсетей. Он НЕ предназначен для обхода ограничений или незаконной деятельности. Используйте эти знания ответственно и в соответствии с местным законодательством.
## Зачем нужна мимикрия подсети?
Это руководство описывает, как развернуть распределенную инфраструктуру (например, для заметок или любого другого сервиса), используя непрерывную частную подсеть `10.0.0.0/8`, имитируемую на различных серверах с помощью **Xray-core** и **обратного туннелирования**.
Если построить "плоскую сеть" (Layer 3) поверх публичного интернета, можно получить единое приватное адресное пространство (`10.0.0.0/8`), распределенное между несколькими отдельными серверами.
## Технический разбор и архитектура
- **Распределенная топология**: каждый узел выступает в роли основного шлюза для своего сегмента `/16` (например, Hub для `10.1.0.0/16`, Spoke для `10.2.0.0/16` и `10.3.0.0/16`).
- **Преодоление NAT**: Spoke-узлы (Bridges) сами инициируют соединения с Hub-узлом (Portal), благодаря чему даже узлы за NAT остаются доступными.
- **Централизованное разрешение DNS**: сетевые DNS-политики единообразно применяются через центральный резолвер на Hub.
### Концепция виртуальной подсети
---
Мы создаем «плоскую сеть» (Layer 3) поверх публичного интернета. Каждый узел выступает в качестве полномочного шлюза для своего собственного сегмента `/16` из диапазона `10.0.0.0/8`. Это обеспечивает бесперебойную связь между серверами, как если бы они находились в одной локальной сети.
## Обзор технического стека
### Наука перехвата (TPROXY)
1. **Xray-core**: движок маршрутизации для VLESS-соединений, reverse-туннелей и TPROXY-перехвата.
2. **TPROXY**: прозрачное проксирование в таблице Linux Mangle, которое перехватывает TCP/UDP-трафик и сохраняет исходный IP назначения.
3. **Policy Routing**: использование `ip rule` и таблицы маршрутизации 100 для принудительной отправки перехваченных пакетов в локальный loopback, где их обрабатывает Xray.
4. **Mux.cool**: протокол мультиплексирования, который упаковывает сотни виртуальных соединений (TCP/UDP-потоков) в одно физическое TLS-соединение.
5. **AdGuard Home**: централизованный DNS-резолвер для всей подсети.
6. **Nginx**: используется как маскировка на основном публичном входном порту, чтобы скрыть инстанс Xray.
В отличие от `REDIRECT`, который работает только для TCP и изменяет IP-адрес назначения, **TPROXY** (Transparent Proxy) позволяет Xray перехватывать как TCP, так и UDP, сохраняя при этом исходные заголовки пакетов.
---
- **Механизм**: Когда пакет, предназначенный для `10.x.x.x`, попадает в цепочку `PREROUTING` или `OUTPUT`, таблица Linux Mangle помечает его меткой `0x1`.
- **Сохранение**: Входящий поток `tunnel` в Xray использует опцию сокета `tproxy` для чтения исходного IP-адреса назначения из метаданных сокета, что позволяет правильно маршрутизировать пакет.
## 1. Стек развертывания
### Политика маршрутизации (Таблица 100)
Эта конфигурация описывает центральный Hub (сервер A), который выступает как Portal для подключений Spoke-узлов. На Hub размещены централизованный DNS и fallback-маскировка через Nginx.
Хотя метки брандмауэра идентифицируют перехваченные пакеты, они сами по себе не изменяют путь маршрутизации. Чтобы перенаправить этот трафик в Xray, мы используем **Policy Routing**:
```yaml
name: 'a'
1. **Правило (`ip rule`)**: `ip rule add fwmark 1 lookup 100` предписывает сетевому стеку использовать пользовательскую таблицу маршрутизации для помеченных пакетов.
2. **Таблица (`ip route`)**: Таблица 100 настроена с параметром `local default dev lo`, что заставляет систему рассматривать все IP-адреса назначения как локальные и направлять их на интерфейс петли (loopback).
3. **Передача**: Xray, прослушивающий `127.0.0.1:12345` с опцией `tproxy`, перехватывает пакет с интерфейса loopback.
4. **Предотвращение петель**: Мы используем `mark: 255` в исходящих потоках Xray, чтобы исключить его собственный трафик из перехвата, предотвращая бесконечные циклы маршрутизации.
services:
xray:
image: ghcr.io/xtls/xray-core:26.1.23
container_name: a-xray
network_mode: host
volumes:
- ./xray.jsonc:/etc/xray/config.jsonc:ro
- /etc/letsencrypt/live:/etc/letsencrypt/live:ro
command: -c '/etc/xray/config.jsonc'
restart: unless-stopped
cap_add:
- NET_ADMIN
- NET_RAW
### Механизм обратного прокси
nginx:
image: nginxinc/nginx-unprivileged:1.29-alpine
container_name: a-nginx
networks:
direct:
ipv4_address: 10.1.1.4
restart: unless-stopped
security_opt:
- no-new-privileges:true
Для доступа к серверам за NAT (Spokes) из центрального хаба Xray использует сложную архитектуру обратного прокси:
adguard:
image: adguard/adguardhome:v0.107.72
container_name: root-adguard
volumes:
- adguard-work:/opt/adguardhome/work
- adguard-conf:/opt/adguardhome/conf
networks:
direct:
ipv4_address: 10.1.1.3
restart: unless-stopped
cap_add:
- NET_BIND_SERVICE
- SYS_NICE
- CAP_CHOWN
security_opt:
- no-new-privileges:true
- **Bridge (Spoke)**: Инициирует соединение с порталом. Он действует как клиент для туннеля, но функционально ведет себя как сервер после установления соединения.
- **Portal (Hub)**: Прослушивает входящие соединения от Bridge и использует «фиктивные домены» (Dummy Domains) для их идентификации, ретранслируя трафик через установленные туннели.
- **Мультиплексирование (Mux)**: Использует [Mux.cool](https://xtls.github.io/en/development/protocols/muxcool.html) для упаковки сотен виртуальных потоков в одно физическое TLS-соединение, что снижает накладные расходы и позволяет обходить тайм-ауты NAT.
networks:
direct:
name: a-direct
driver: bridge
ipam:
config:
- subnet: 10.1.0.0/16 # Using the full /16 as defined for Server A
gateway: 10.1.0.1
## Руководство по настройке
volumes:
adguard-work:
name: a-adguard-work
adguard-conf:
name: a-adguard-conf
### Этап 0: Предварительные требования (только для сервера А)
Серверу А (Hub) требуется действующий SSL-сертификат для XTLS-Vision. Направьте ваш домен (`A-DOMAIN`) на публичный IP сервера А и убедитесь, что сертификаты доступны (например, через Certbot) по адресу `/etc/letsencrypt/live/A-DOMAIN/`.
### Этап 1: Безопасность и идентификация
Для каждого соединения требуется уникальный UUID. Сгенерируйте 4 UUID для сопоставления следующих соединений:
- `UUID_B_REVERSE`: Управление туннелем для сервера B.
- `UUID_B_DIRECT`: Трафик данных для сервера B.
- `UUID_C_REVERSE`: Управление туннелем для сервера C.
- `UUID_C_DIRECT`: Трафик данных для сервера C.
### Этап 2: Настройка хоста
На **КАЖДОМ** узле сервера выполните следующее:
1. **Инициализация маршрутизации**: Запустите скрипт настройки маршрутизации для конфигурации параметров ядра и таблицы 100.
2. **Настройка брандмауэра**: Обновите `/etc/ufw/before.rules`. Вставьте специфические правила узла сразу после заголовка `*filter`. Эти правила обрабатывают логику таблицы `mangle`, необходимую для TPROXY.
3. **Перезагрузка**: Примените изменения с помощью `sudo ufw reload`.
### Этап 3: Развертывание
Обновите конфигурации `xray.jsonc`, указав сгенерированные UUID, ваш домен и публичные IP-адреса. Запустите стек с помощью `docker compose up -d`. На сервере А установка compose также инициализирует внутреннюю мостовую сеть для таких сервисов, как AdGuard и Nginx.
## Примеры конфигурации
### Сервер A: Хаб (10.1.0.0/16)
Хаб обрабатывает входы обратных туннелей и маршрутизирует трафик в соответствующий Spoke на основе диапазона 10.x.x.x.
Полная конфигурация доступна здесь: [a/xray.jsonc](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/a/xray.jsonc)
```jsonc
{
"inbounds": [
{
"port": 443,
"protocol": "vless",
"tag": "proxy-direct",
"settings": {
"clients": [
{ "id": "REPLACE_WITH_UUID_B_REVERSE", "reverse": { "tag": "reverse-b" } },
{ "id": "REPLACE_WITH_UUID_B_DIRECT" },
// ... повторите для других Spokes
],
},
},
{
"tag": "proxy-enclave",
"protocol": "tunnel",
"streamSettings": { "sockopt": { "tproxy": "tproxy", "mark": 255 } },
},
],
"routing": {
"rules": [
{ "ip": ["10.2.0.0/16"], "outboundTag": "reverse-b" },
{ "ip": ["10.3.0.0/16"], "outboundTag": "reverse-c" },
],
},
}
```
---
### Сервер B: Узел Spoke (10.2.0.0/16)
## 2. Конфигурация и маршрутизация
Spoke инициирует обратное соединение с Хабом и обрабатывает выход в локальную подсеть.
Полная конфигурация доступна здесь: [b/xray.jsonc](https://github.com/deytenit/xray-core-subnet-mimicry/blob/main/b/xray.jsonc)
### Генерация UUID-идентификаторов
Каждому соединению нужен собственный криптографический "ключ". Для сопоставления с конфигами вам понадобятся 4 UUID.
```bash
# Generate UUIDs for the Server B and Server C mappings
docker run --rm ghcr.io/xtls/xray-core uuid
```jsonc
{
"outbounds": [
{
"protocol": "vless",
"tag": "forward-a-reverse",
"settings": {
"address": "A-EXTERNAL-IP",
"id": "REPLACE_WITH_UUID_B_REVERSE",
"reverse": { "tag": "reverse-b" },
},
},
{
"protocol": "vless",
"tag": "forward-a",
"settings": {
"address": "A-EXTERNAL-IP",
"id": "REPLACE_WITH_UUID_B_DIRECT",
},
},
],
"routing": {
"rules": [
{ "inboundTag": ["proxy-enclave"], "outboundTag": "forward-a" },
{ "inboundTag": ["reverse-b"], "outboundTag": "forward-direct" },
{ "ip": ["10.2.0.0/16"], "outboundTag": "forward-direct" },
{ "ip": ["10.0.0.0/8"], "outboundTag": "forward-a" },
],
},
}
```
Соответствие такое:
## Ресурсы и ссылки
- `UUID_B_REVERSE`: управляющий туннель для сервера B.
- `UUID_B_DIRECT`: пользовательский трафик сервера B.
- `UUID_C_REVERSE`: управляющий туннель для сервера C.
- `UUID_C_DIRECT`: пользовательский трафик сервера C.
Для получения полного автоматизированного примера этой архитектуры посетите следующий репозиторий:
### Маршрутизация по политикам (таблица 100)
- [xray-core-subnet-mimicry на GitHub](https://github.com/deytenit/xray-core-subnet-mimicry/tree/main)
Примените скрипт маршрутизации на **каждом** серверном узле, чтобы настроить параметры ядра и таблицу маршрутизации 100. Это заставляет ядро передавать пакет в наш TPROXY inbound, который слушает `127.0.0.1:12345`.
```bash
#!/usr/bin/env bash
# 1. Enable IP Forwarding
sudo sysctl -w net.ipv4.ip_forward=1
# 2. IP Rule for mark 0x1 -> table 100
sudo ip rule add fwmark 0x1 lookup 100 priority 100
# 3. Local route in table 100
sudo ip route add local 0.0.0.0/0 dev lo table 100
```
---
## 3. Перехват в firewall
Откройте `/etc/ufw/before.rules` и сразу после обязательного заголовка `*filter` добавьте правила конкретного узла. Ниже пример для сервера A (Hub, `10.1.0.0/16`):
```text
*mangle
:XRAY - [0:0]
:XRAY_MASK - [0:0]
:DIVERT - [0:0]
# --- PREROUTING CHAIN (Traffic from other containers/nodes) ---
-A XRAY -d 10.1.0.0/16 -j RETURN
-A XRAY ! -d 10.0.0.0/8 -j RETURN
-A XRAY -m mark --mark 0xff -j RETURN
-A XRAY -p udp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
-A XRAY -p tcp -j TPROXY --on-ip 127.0.0.1 --on-port 12345 --tproxy-mark 1
-A PREROUTING -j XRAY
# --- OUTPUT CHAIN (Traffic originating from the server itself) ---
-A XRAY_MASK -d 10.1.0.0/16 -j RETURN
-A XRAY_MASK ! -d 10.0.0.0/8 -j RETURN
-A XRAY_MASK -m mark --mark 0xff -j RETURN
-A XRAY_MASK -p udp -j MARK --set-mark 1
-A XRAY_MASK -p tcp -j MARK --set-mark 1
-A OUTPUT -j XRAY_MASK
# --- DIVERT Optimization ---
-A DIVERT -j MARK --set-mark 1
-A DIVERT -j ACCEPT
-I PREROUTING -p tcp -m socket -j DIVERT
COMMIT
```
После применения перезагрузите UFW:
```bash
sudo ufw reload
```
---
## Ресурсы
Дополнительные технические материалы:
- [Официальная документация Xray-core](https://xtls.github.io/)
- [Документация Xray Reverse Proxy](https://xtls.github.io/en/config/reverse.html)
- [Project X (Xray) на GitHub](https://github.com/XTLS/Xray-core)
- [Документация Linux Kernel по TPROXY](https://www.kernel.org/doc/Documentation/networking/tproxy.txt)
- [Блог Cloudflare: TPROXY](https://blog.cloudflare.com/how-we-built-spectrum/)
- [Понимание TPROXY (блог Cloudflare)](https://blog.cloudflare.com/how-we-built-spectrum/)
- [Документация TPROXY в ядре Linux](https://www.kernel.org/doc/Documentation/networking/tproxy.txt)
+5
View File
@@ -2,6 +2,7 @@ import React from 'react';
import * as TestArticle from './articles/2026/test-article.en.mdx';
import * as MatrixServerDeploymentGuide from './articles/2026/matrix-server-deployment-guide.en.mdx';
import * as XrayCoreSubnetMimicry from './articles/2026/xray-core-subnet-mimicry.en.mdx';
import * as JoplinWebDeploymentGuide from './articles/2026/joplin-web-deployment-guide.en.mdx';
export interface Article {
slug: string;
@@ -47,6 +48,10 @@ export const articles: Record<string, Article> = {
'2026/matrix-server-deployment-guide'
),
'xray-core-subnet-mimicry': register(XrayCoreSubnetMimicry, '2026/xray-core-subnet-mimicry'),
'joplin-web-deployment-guide': register(
JoplinWebDeploymentGuide,
'2026/joplin-web-deployment-guide'
),
};
export const sortedArticles = Object.values(articles).sort(
-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

Before

Width:  |  Height:  |  Size: 749 B

@@ -0,0 +1,43 @@
import { localePath } from '@ermnvldmr/i18n';
import { MARKDOWN_COMPONENTS } from '@ermnvldmr/ui';
import React from 'react';
import { articles } from '../../../../content/registry';
import { ArticleLayout } from '../../../components/Layout/ArticleLayout';
import { createPage } from '../../../lib/core/createPage';
const article = articles['joplin-web-deployment-guide'];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!article) {
throw new Error('Article not found');
}
/**
* Joplin web deployment guide article page.
*
* @example
* ```tsx
* createPage(JoplinWebDeploymentGuidePage, { title: '...', description: '...' });
* ```
*/
function JoplinWebDeploymentGuidePage(): React.JSX.Element {
return (
<ArticleLayout
createdDate={article.createdDate}
currentPath={localePath('/articles/2026/joplin-web-deployment-guide')}
description={article.description}
tags={article.tags}
title={article.title}
updatedDate={article.updatedDate}
>
<article.Component components={MARKDOWN_COMPONENTS} />
</ArticleLayout>
);
}
createPage(JoplinWebDeploymentGuidePage, {
title: `${article.title} - Vladimir Eremin`,
description: article.description,
});
export default JoplinWebDeploymentGuidePage;
@@ -0,0 +1,7 @@
import { createKeyset } from '@ermnvldmr/i18n';
export const { t } = createKeyset({
Articles: { en: 'Articles', ru: 'Статьи' },
Home: { en: 'Home', ru: 'Главная' },
Updated: { en: 'Updated', ru: 'Обновлено' },
});
@@ -22,11 +22,11 @@ const meta: Meta<typeof ArticleLayout> = {
component: ArticleLayout,
tags: ['autodocs'],
args: {
title: 'Building a Modern Content System with Astro 5',
title: 'Building a Modern Content System with Rsbuild',
description:
"A deep dive into building a scalable, file-based content architecture using Astro's new Content Layer API and MDX.",
createdDate: new Date('2026-01-31'),
tags: ['engineering', 'astro', 'react'],
"A deep dive into building a scalable, file-based content architecture using Rsbuild's MDX support and Rspack.",
createdDate: new Date('2026-03-19'),
tags: ['engineering', 'rsbuild', 'react'],
},
};
@@ -45,9 +45,9 @@ export const Default: Story = {
<ArticleLayout {...args}>
<Header level={2}>Introduction</Header>
<Paragraph>
Astro 5 introduces the Content Layer API, a powerful way to fetch content from any source,
including local files, remote APIs, and CMSs. In this article, we&apos;ll explore how to set
up a robust system for articles and projects.
Rsbuild provides a modern, high-performance build toolchain based on Rspack. In this
article, we&apos;ll explore how to set up a robust system for articles and projects using
MDX and React.
</Paragraph>
<Blockquote>
@@ -56,46 +56,45 @@ export const Default: Story = {
</Blockquote>
<Paragraph>
Let&apos;s look at some code. Our new <Code>content.config.ts</Code> uses the{' '}
<Code>glob</Code> loader to pull in MDX files from outside the <Code>src</Code> directory:
Let&apos;s look at some code. Our <Code>rsbuild.config.ts</Code> uses the{' '}
<Code>pluginMdx</Code> to handle MDX files:
</Paragraph>
<CodeBlock label="content.config.ts">
<CodeBlock label="rsbuild.config.ts">
<Code>
{`// services/www/src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
{`import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import { pluginMdx } from '@rsbuild/plugin-mdx';
const articles = defineCollection({
loader: glob({ pattern: "**/*.mdx", base: "./content/articles" }),
schema: z.object({
title: z.string(),
description: z.string().optional(),
createdDate: z.date(),
updatedDate: z.date().optional(),
tags: z.array(z.string()).optional(),
export default defineConfig({
plugins: [
pluginReact(),
pluginMdx({
mdxOptions: {
remarkPlugins: [],
rehypePlugins: [],
},
}),
});
export const collections = { articles };`}
],
});`}
</Code>
</CodeBlock>
<Header level={3}>Why Astro 5?</Header>
<Header level={3}>Why Rsbuild?</Header>
<Paragraph>
Astro&apos;s zero-JS by default approach is perfect for content-heavy sites. By moving our
content outside of the <Code>src</Code> directory, we keep our project clean and organized.
Rsbuild&apos;s performance is unparalleled thanks to Rspack. By using a Rust-based bundler,
we keep our development loop fast and our builds efficient.
</Paragraph>
<List spacing="s" variant="unordered">
<List.Item>
<Text bold>Better performance:</Text> Only what you need is shipped.
<Text bold>Better performance:</Text> Rust-powered builds and HMR.
</List.Item>
<List.Item>
<Text bold>Improved developer experience:</Text> Fast HMR and great tooling.
<Text bold>Improved developer experience:</Text> Sensible defaults and great tooling.
</List.Item>
<List.Item>
<Text bold>Type safety:</Text> Zod schemas validate your frontmatter.
<Text bold>Type safety:</Text> Full TypeScript support out of the box.
</List.Item>
</List>
@@ -2,6 +2,7 @@ import { localePath } from '@ermnvldmr/i18n';
import { Markdown, Time, Text, PageColumns, PageColumn, Breadcrumbs, HStack } from '@ermnvldmr/ui';
import React from 'react';
import { t } from './ArticleLayout.i18n';
import { DefaultLayout } from '../DefaultLayout/DefaultLayout';
/**
@@ -50,7 +51,7 @@ export const ArticleLayout: React.FC<ArticleLayoutProps> = ({
}) => {
const displayDate = updatedDate ? (
<Text color="muted" size="s" type="label">
Updated <Time date={updatedDate} />
{t('Updated')} <Time date={updatedDate} />
</Text>
) : (
<Time color="muted" date={createdDate} size="s" type="label" />
@@ -73,9 +74,9 @@ export const ArticleLayout: React.FC<ArticleLayoutProps> = ({
const breadcrumbs = (
<Breadcrumbs>
<Breadcrumbs.Item href={localePath('/')}>Home</Breadcrumbs.Item>
<Breadcrumbs.Item href={localePath('/')}>{t('Home')}</Breadcrumbs.Item>
<Breadcrumbs.Separator />
<Breadcrumbs.Item href={localePath('/articles')}>Articles</Breadcrumbs.Item>
<Breadcrumbs.Item href={localePath('/articles')}>{t('Articles')}</Breadcrumbs.Item>
<Breadcrumbs.Separator />
<Breadcrumbs.Item isCurrent>{title}</Breadcrumbs.Item>
</Breadcrumbs>
@@ -0,0 +1,6 @@
import { createKeyset } from '@ermnvldmr/i18n';
export const { t } = createKeyset({
Articles: { en: 'Articles', ru: 'Статьи' },
GitHub: { en: 'GitHub', ru: 'ГитХаб' },
});
@@ -1,6 +1,8 @@
import { PageHead as PageHeadOriginal, HStack, Link } from '@ermnvldmr/ui';
import React from 'react';
import { t } from './PageHead.i18n';
import type { PageHeadStrategy } from '@ermnvldmr/ui';
/**
@@ -34,8 +36,8 @@ export interface PageHeadProps {
export const PageHead = ({ heading, caption, breadcrumbs, metadata, strategy }: PageHeadProps) => {
const nav = (
<HStack gap={4}>
<Link href="/articles">Articles</Link>
<Link href="https://github.com/deytenit">GitHub</Link>
<Link href="/articles">{t('Articles')}</Link>
<Link href="https://github.com/deytenit">{t('GitHub')}</Link>
</HStack>
);
+1 -1
View File
@@ -9,6 +9,6 @@
"@ermnvldmr/ui/*": ["../../../packages/ui/src/*"]
}
},
"include": [".astro/types.d.ts", "src/**/*", ".storybook/**/*"],
"include": ["src/**/*", ".storybook/**/*"],
"exclude": ["dist", "coverage", "node_modules", "**/*.config.*"]
}