n8n на своём компьютере: как принять webhook из интернета
Подключите внешний сервис к локальному n8n под Windows: внешний HTTPS-адрес, фильтр для webhook, отдельный секрет и проверка тестового и рабочего события.
Обновлено:
Содержание
n8n работает на компьютере, а внешний сервис должен сообщить ему о событии и запустить цепочку действий. Для этого нужен webhook: адрес, на который другая программа присылает событие. Дадим ему внешний HTTPS-адрес через DevProxy, а редактор n8n оставим доступным только на компьютере.
Начнём с простой цепочки: получить выдуманный JSON и ответить {"ok":true}. Так можно проверить доставку, не отправляя письма, не меняя сделки и не затрагивая чужие данные.
Ниже настройки для уже установленного n8n 2.45.5 и Node 24 на Windows с запуском из PowerShell. Существующую базу, ключ шифрования и остальные настройки сохраните.
Как запрос дойдёт до n8n
Внешний сервис обратится к HTTPS-адресу DevProxy. Клиент DevProxy передаст запрос небольшому фильтру на http://127.0.0.1:5680, а фильтр отправит его в n8n на локальном порту 5678.
Фильтр пропускает только два адреса и только методом POST:
/webhook-test/devproxy-demoдля тестового события;/webhook/devproxy-demoдля опубликованной рабочей цепочки.
Для обоих нужен один и тот же отдельный секрет в заголовке X-DevProxy-Demo-Secret. Другие пути, включая редактор, фильтр отклоняет. Просто добавить /webhook к локальному адресу канала DevProxy недостаточно: такая запись не ограничивает доступ к остальным путям. Поэтому нужен отдельный фильтр.
Не направляйте этот канал на весь порт n8n 5678. Так наружу станут доступны и маршруты редактора. Обычный вход n8n продолжит работать, но для приёма события доступ к редактору извне не нужен.
1. Получите внешний адрес DevProxy
Нужен уже работающий клиент DevProxy версии 1.0.8 или новее. Если клиент ещё не настроен, начните с инструкции по запуску, затем вернитесь сюда.

Это форма ключа DevProxy с вариантом «Сайт или API». Если клиент уже настроен, используйте имеющийся ключ.
Откройте локальную панель клиента по адресу из консоли и войдите с одноразовым кодом из той же консоли. Выберите Каналы → Создать канал → HTTP. Назовите новый канал, например n8n-hook, а в поле локального адреса укажите http://127.0.0.1:5680. Сохранение в исходный YAML оставьте включённым. Нажмите Сохранить, затем отдельно Запустить.
В разделе Туннели скопируйте фактический HTTPS-адрес этого канала. Не собирайте его по имени n8n-hook. Вам нужен только адрес сайта, без /webhook и других путей.

На этой иллюстрации адрес показан у HTTP-канала site из другого примера. Для n8n скопируйте адрес своего нового канала.
Канал можно создать до запуска фильтра. Запросы начнут доходить, когда будут запущены и фильтр, и n8n. Панель добавит новый канал в ваш файл настроек; остальные каналы оставьте как есть.
Для этого подключения нужен отдельный канал без браузерной формы входа. Запросы проверят фильтр и Header Auth в n8n по секретному заголовку. Внешний сервис не сможет заполнить форму пароля. Защиту других каналов менять не нужно.
2. Укажите внешний адрес в n8n
Понадобятся три окна PowerShell: в первом работает n8n, во втором фильтр, в третьем вы отправите запросы.
В первом окне добавьте настройки ниже перед своей обычной командой запуска n8n. Если n8n уже запущен в этом окне, остановите только его через Ctrl+C, затем запустите снова с настройками. Если он сейчас выполняет другие цепочки, выберите время, когда остановка им не помешает.
$webhookAddress = (Read-Host 'Вставьте HTTPS-адрес своего канала из Туннелей, без пути').Trim().TrimEnd('/')
$env:N8N_WEBHOOK_URL = $webhookAddress + '/'
$env:N8N_LISTEN_ADDRESS = '127.0.0.1'
$env:N8N_PROXY_HOPS = '1'
n8n startПоследняя строка подходит для обычного запуска n8n start. Если вы используете другие параметры запуска, сохраните их. Не меняйте существующие N8N_USER_FOLDER, базу и ключ шифрования.
N8N_WEBHOOK_URL задаёт внешний адрес, который n8n покажет в узле Webhook. N8N_LISTEN_ADDRESS оставляет сам n8n доступным только на компьютере. Значение N8N_PROXY_HOPS=1 относится к этой схеме: непосредственно перед n8n стоит фильтр, который сам задаёт заголовки об исходном адресе запроса. Для другой цепочки промежуточных серверов это число нельзя переносить без проверки.
Вернитесь в редактор по своему привычному локальному адресу n8n. Порт 5678 дальше используется как пример. Если у вас другой порт, запомните его для N8N_LOCAL_PORT в окне фильтра.
3. Сделайте цепочку с ответом
Создайте отдельный workflow, то есть цепочку действий n8n. Добавьте Webhook и задайте параметры:
- HTTP Method: POST;
- Path:
devproxy-demo; - Authentication: Header Auth;
- Respond: Using 'Respond to Webhook' Node.
В настройке Header Auth создайте отдельную запись доступа. В поле Name укажите X-DevProxy-Demo-Secret. В поле Value вставьте случайную строку из менеджера паролей: не менее 32 латинских букв и цифр. Не используйте пароль входа в n8n или пример-заглушку. Этот же секрет понадобится фильтру и отправителю запроса.
Соедините Webhook с узлом Respond to Webhook. В нём выберите Respond With → JSON, а в Response Body укажите:
{"ok":true}Сохраните цепочку. Пока оставьте в ней только эти два узла. Webhook получает данные, Respond to Webhook возвращает понятное подтверждение.
4. Сохраните и запустите фильтр
Создайте файл webhook-gate.mjs в удобной папке, сохраните его в UTF-8 с расширением .mjs. Скопируйте в него весь код ниже. Дополнительные пакеты npm не нужны: фильтр использует Node, который уже нужен n8n.
// Локальный фильтр двух webhook-адресов n8n.
// Принимает запросы только на 127.0.0.1 и передаёт их на loopback upstream n8n.
import http from 'node:http';
import { timingSafeEqual } from 'node:crypto';
import { pathToFileURL } from 'node:url';
const LOOPBACK_HOST = '127.0.0.1';
const DEFAULT_GATE_PORT = 5680;
const DEFAULT_N8N_PORT = 5678;
const MAX_BODY_BYTES = 256 * 1024;
const REQUEST_TIMEOUT_MS = 10_000;
const UPSTREAM_TIMEOUT_MS = 10_000;
// Ключ - точная строка request line, значение - фиксированный путь upstream.
// Нормализация и декодирование не применяются, иначе traversal мог бы стать разрешённым путём.
const ALLOWED_PATHS = new Map([
['/webhook-test/devproxy-demo', '/webhook-test/devproxy-demo'],
['/webhook/devproxy-demo', '/webhook/devproxy-demo'],
]);
const SECRET_PATTERN = /^[A-Za-z0-9_-]{32,}$/;
const PLACEHOLDER_PATTERN = /placeholder|changeme|change-me|example|your[-_]?secret|replace[-_]?me|x{8,}/i;
class RequestBodyTooLarge extends Error {}
class RequestTimeout extends Error {}
class UpstreamTimeout extends Error {}
class UpstreamBodyTooLarge extends Error {}
export function readConfig(env = process.env) {
return {
gatePort: parsePort(env.GATE_PORT, DEFAULT_GATE_PORT, 'GATE_PORT'),
upstreamPort: parsePort(env.N8N_LOCAL_PORT, DEFAULT_N8N_PORT, 'N8N_LOCAL_PORT'),
secret: parseSecret(env.N8N_DEMO_SECRET),
origin: parseOrigin(env.N8N_WEBHOOK_ORIGIN),
};
}
function parsePort(raw, fallback, name) {
if (raw === undefined || raw === '') return fallback;
if (typeof raw !== 'string' || !/^\d{1,5}$/.test(raw)) {
throw new Error(name + ': нужен порт от 1 до 65535');
}
const port = Number(raw);
if (port < 1 || port > 65535) throw new Error(name + ': нужен порт от 1 до 65535');
return port;
}
function parseSecret(raw) {
if (typeof raw !== 'string' || !SECRET_PATTERN.test(raw) || PLACEHOLDER_PATTERN.test(raw)) {
throw new Error('N8N_DEMO_SECRET: нужен секрет не короче 32 символов A-Za-z0-9_- без placeholder');
}
return raw;
}
function parseOrigin(raw) {
const invalid = new Error('N8N_WEBHOOK_ORIGIN: нужен HTTPS-origin без userinfo, лишнего path, query и fragment');
if (typeof raw !== 'string' || raw === '') throw invalid;
let url;
try {
url = new URL(raw);
} catch {
throw invalid;
}
if (url.protocol !== 'https:' || url.username !== '' || url.password !== '' || url.pathname !== '/' || url.search !== '' || url.hash !== '' || url.host === '') {
throw invalid;
}
return url;
}
export function createGate(options = {}) {
const secret = parseSecret(options.secret);
const upstreamPort = Number(options.upstreamPort);
if (!Number.isInteger(upstreamPort) || upstreamPort < 1 || upstreamPort > 65535) {
throw new Error('upstreamPort: нужен порт от 1 до 65535');
}
// Origin всегда разбирается заново: переданный URL проверяется теми же правилами
// и не остаётся изменяемой ссылкой внутри gate.
const origin = parseOrigin(options.origin instanceof URL ? options.origin.href : options.origin);
const upstreamTimeoutMs = options.upstreamTimeoutMs ?? UPSTREAM_TIMEOUT_MS;
if (!Number.isInteger(upstreamTimeoutMs) || upstreamTimeoutMs < 1) {
throw new Error('upstreamTimeoutMs: нужно положительное целое число');
}
const config = { secret, upstreamPort, origin, upstreamTimeoutMs };
const server = http.createServer((request, response) => {
void handleRequest(request, response, config).catch(() => {
respondJson(null, response, 502, { error: 'bad_gateway' });
});
});
server.on('upgrade', (request, socket) => {
socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
});
server.on('clientError', (error, socket) => socket.destroy());
return server;
}
async function handleRequest(request, response, config) {
const upstreamPath = ALLOWED_PATHS.get(request.url ?? '');
if (request.method !== 'POST' || upstreamPath === undefined) {
respondJson(request, response, 404, { error: 'not_found' });
return;
}
if (!hasValidSecret(request, config.secret)) {
respondJson(request, response, 403, { error: 'forbidden' });
return;
}
const contentType = typeof request.headers['content-type'] === 'string' ? request.headers['content-type'] : '';
if (!isJsonContentType(contentType)) {
respondJson(request, response, 415, { error: 'unsupported_media_type' });
return;
}
let body;
try {
body = await readBody(request, MAX_BODY_BYTES);
} catch (error) {
if (error instanceof RequestBodyTooLarge) {
respondJson(request, response, 413, { error: 'payload_too_large' });
return;
}
if (error instanceof RequestTimeout) {
respondJson(request, response, 408, { error: 'request_timeout' });
return;
}
respondJson(request, response, 400, { error: 'bad_request' });
return;
}
const remoteAddress = request.socket.remoteAddress;
if (typeof remoteAddress !== 'string' || remoteAddress === '') {
respondJson(request, response, 400, { error: 'bad_request' });
return;
}
try {
const result = await forward(config, upstreamPath, contentType, body, remoteAddress);
sendUpstreamResponse(response, result);
} catch (error) {
if (error instanceof UpstreamTimeout) {
respondJson(request, response, 504, { error: 'gateway_timeout' });
return;
}
respondJson(request, response, 502, { error: 'bad_gateway' });
}
}
function hasValidSecret(request, secret) {
const values = [];
for (let index = 0; index < request.rawHeaders.length; index += 2) {
if (request.rawHeaders[index].toLowerCase() === 'x-devproxy-demo-secret') {
values.push(request.rawHeaders[index + 1]);
}
}
if (values.length !== 1 || typeof values[0] !== 'string') return false;
const provided = Buffer.from(values[0], 'utf8');
const expected = Buffer.from(secret, 'utf8');
if (provided.length !== expected.length) return false;
return timingSafeEqual(provided, expected);
}
function isJsonContentType(value) {
return value.split(';')[0].trim().toLowerCase() === 'application/json';
}
function readBody(request, limit) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
let finished = false;
const timer = setTimeout(() => settle(new RequestTimeout()), REQUEST_TIMEOUT_MS);
function settle(error, value) {
if (finished) return;
finished = true;
clearTimeout(timer);
request.off('data', onData);
request.off('end', onEnd);
request.off('error', onError);
request.off('aborted', onAborted);
if (error) reject(error);
else resolve(value);
}
function onData(chunk) {
size += chunk.length;
if (size > limit) {
settle(new RequestBodyTooLarge());
return;
}
chunks.push(chunk);
}
function onEnd() {
settle(null, Buffer.concat(chunks));
}
function onError() {
settle(new Error('request_error'));
}
function onAborted() {
settle(new Error('request_aborted'));
}
request.on('data', onData);
request.on('end', onEnd);
request.on('error', onError);
request.on('aborted', onAborted);
});
}
function forward(config, upstreamPath, contentType, body, remoteAddress) {
return new Promise((resolve, reject) => {
const request = http.request({
host: LOOPBACK_HOST,
port: config.upstreamPort,
method: 'POST',
path: upstreamPath,
timeout: config.upstreamTimeoutMs,
headers: {
'content-type': contentType,
'content-length': String(body.length),
'x-devproxy-demo-secret': config.secret,
'x-forwarded-host': config.origin.host,
'x-forwarded-proto': 'https',
'x-forwarded-for': remoteAddress,
},
}, (response) => {
readUpstreamBody(response).then(
(buffer) => resolve({
status: response.statusCode ?? 502,
contentType: String(response.headers['content-type'] ?? ''),
body: buffer,
}),
(error) => {
response.destroy();
reject(error);
},
);
});
request.on('timeout', () => request.destroy(new UpstreamTimeout()));
request.on('error', (error) => reject(error));
request.end(body);
});
}
function readUpstreamBody(response) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
response.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
response.destroy();
reject(new UpstreamBodyTooLarge());
return;
}
chunks.push(chunk);
});
response.on('end', () => resolve(Buffer.concat(chunks)));
response.on('error', reject);
});
}
function sendUpstreamResponse(response, result) {
if (result.status >= 300 && result.status < 400) {
respondJson(null, response, 502, { error: 'upstream_redirect' });
return;
}
if (result.status < 200 || result.status >= 300) {
respondJson(null, response, 502, { error: 'upstream_error', status: result.status });
return;
}
if (!isJsonContentType(result.contentType)) {
respondJson(null, response, 502, { error: 'upstream_invalid_response' });
return;
}
response.writeHead(result.status, {
'content-type': 'application/json; charset=utf-8',
'content-length': String(result.body.length),
'cache-control': 'no-store',
});
response.end(result.body);
}
function respondJson(request, response, status, payload) {
if (response.headersSent || response.writableEnded) {
if (request) request.resume();
return;
}
const body = Buffer.from(JSON.stringify(payload));
response.writeHead(status, {
'content-type': 'application/json; charset=utf-8',
'content-length': String(body.length),
'cache-control': 'no-store',
});
response.end(body);
if (request && !request.readableEnded) request.resume();
}
const isCli = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isCli) {
let config;
try {
config = readConfig(process.env);
} catch (error) {
console.error('webhook-gate: ' + error.message);
process.exitCode = 1;
}
if (config) {
const server = createGate(config);
server.on('error', (error) => {
console.error('webhook-gate: ошибка запуска (' + (error.code ?? 'error') + ')');
process.exit(1);
});
server.listen(config.gatePort, LOOPBACK_HOST, () => {
console.log('webhook-gate: слушает http://' + LOOPBACK_HOST + ':' + server.address().port);
});
}
}Во втором окне PowerShell перейдите в папку, где сохранили webhook-gate.mjs. Введите тот же HTTPS-адрес, что задали в N8N_WEBHOOK_URL. Здесь он называется N8N_WEBHOOK_ORIGIN. Если порт вашего n8n отличается от 5678, замените значение N8N_LOCAL_PORT.
$env:N8N_WEBHOOK_ORIGIN = (Read-Host 'Вставьте тот же HTTPS-адрес своего канала, без пути').Trim().TrimEnd('/') + '/'
$env:N8N_LOCAL_PORT = '5678'
$env:GATE_PORT = '5680'
$secretInput = Read-Host 'Вставьте секрет из Header Auth n8n' -AsSecureString
try {
$env:N8N_DEMO_SECRET = [System.Net.NetworkCredential]::new('', $secretInput).Password
}
finally {
$secretInput.Dispose()
Remove-Variable secretInput -ErrorAction SilentlyContinue
}
try {
node .\webhook-gate.mjs
}
finally {
Remove-Item Env:N8N_DEMO_SECRET -ErrorAction SilentlyContinue
}Вставка секрета скрыта. Его не нужно записывать в текст команды, файл фильтра или передавать параметром после имени файла. После запуска появится строка webhook-gate: слушает http://127.0.0.1:5680. Оставьте окно открытым.
Переменные из одного окна PowerShell не переходят в другое. Поэтому ниже секрет нужно отдельно ввести в третьем окне. Это всё та же строка из Header Auth, а не новый секрет.
5. Отправьте тестовое событие
В узле Webhook выберите Test URL и нажмите Listen for Test Event. Так n8n начинает ждать тестовый запрос. Адрес должен содержать ваш HTTPS-адрес DevProxy и путь /webhook-test/devproxy-demo.
В третьем окне PowerShell подготовьте адрес, заголовок и выдуманные данные. Не выводите содержимое $headers в консоль и не помещайте его в скриншоты.
$hookOrigin = (Read-Host 'Вставьте HTTPS-адрес своего канала, без пути').Trim().TrimEnd('/')
$secretInput = Read-Host 'Вставьте тот же секрет из Header Auth n8n' -AsSecureString
try {
$headers = @{
'X-DevProxy-Demo-Secret' = [System.Net.NetworkCredential]::new('', $secretInput).Password
}
}
finally {
$secretInput.Dispose()
Remove-Variable secretInput -ErrorAction SilentlyContinue
}
$body = @{
event = 'test'
message = 'hello from external service'
} | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri "$hookOrigin/webhook-test/devproxy-demo" -Headers $headers -ContentType 'application/json' -Body $bodyВ ответе должно быть ok: true, а в редакторе n8n появятся данные запроса в Webhook, в том числе body.event со значением test. Проверьте, что оба узла завершились успешно. Одного ответа от сервера недостаточно: найдите именно своё событие в n8n.
Тестовый адрес не слушает постоянно. Если n8n перестал ждать событие или вы хотите отправить ещё один запрос, снова нажмите Listen for Test Event.
6. Перейдите на рабочий адрес
Когда тестовое событие пришло, сохраните цепочку и нажмите Publish. Теперь Production URL принимает запросы без ручного ожидания. Его путь другой: /webhook/devproxy-demo.
В том же третьем окне отправьте новое событие. Адрес и $headers уже подготовлены:
$body = @{
event = 'production'
message = 'hello from external service'
} | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri "$hookOrigin/webhook/devproxy-demo" -Headers $headers -ContentType 'application/json' -Body $bodyСнова ожидается ok: true. Данные рабочего запроса не появляются прямо на схеме в редакторе. Откройте Executions, выберите новое выполнение и найдите body.event со значением production. Оно должно завершиться успешно.
Внешнему сервису передайте Production URL, метод POST, тип содержимого application/json и заголовок X-DevProxy-Demo-Secret с тем же секретом. Если сервис не умеет добавлять такой заголовок, этот способ подключения ему не подходит. Отключать проверку доступа ради отправки не нужно.
7. Убедитесь, что запрос без секрета не проходит
В третьем окне повторите рабочий запрос без -Headers:
Invoke-RestMethod -Method Post -Uri "$hookOrigin/webhook/devproxy-demo" -ContentType 'application/json' -Body $bodyPowerShell покажет ошибку с кодом 403. Это ожидаемый отказ. Во вкладке Executions не должно появиться нового выполнения от этого запроса. Такой же отказ будет с неверным секретом.
Если открыть в браузере корень внешнего адреса или /signin, фильтр вернёт 404. Редактор продолжайте открывать по своему локальному адресу.
Если что-то не получилось
Фильтр не запускается. Проверьте Node и текст ошибки в его окне. Адрес в N8N_WEBHOOK_ORIGIN должен начинаться с https:// и не содержать пути, параметров запроса или фрагмента после #. Секрет должен быть отдельной случайной строкой длиной от 32 символов. Если порт 5680 занят, выберите свободный GATE_PORT и тот же порт у локального адреса нового канала DevProxy.
Пришёл 401 от DevProxy или появилась форма входа. У адреса включён браузерный вход. Для этой инструкции нужен отдельный канал, который принимает программные запросы с секретным заголовком. Пароль от кабинета не заменяет этот секрет; защиту уже работающих каналов не отключайте.
Пришёл 403. Фильтр не получил правильный секрет. Сравните имя X-DevProxy-Demo-Secret и значение в n8n, окне фильтра и третьем окне. После смены секрета перезапустите фильтр и заново соберите $headers. Проверку Header Auth оставьте включённой.
Пришёл 404. Фильтр пропускает ровно два пути из инструкции, только методом POST и без параметров после ?. Корень сайта, редактор, лишний символ в пути или другой метод будут отклонены. Сверьте адрес с Test URL или Production URL.
Пришёл 502. Фильтр не смог получить подходящий ответ от n8n. Проверьте, что n8n работает на порту из N8N_LOCAL_PORT, а цепочка возвращает JSON через Respond to Webhook. Ответ upstream_error со status: 403 означает, что секрет в фильтре не совпал с Header Auth в n8n. Если указан status: 404, запрос дошёл до n8n, но тот не нашёл включённый webhook. Для тестового адреса снова нажмите Listen for Test Event, для рабочего проверьте Publish и правильность пути. Эту ситуацию не нужно путать с 404 самого фильтра.
Пришёл 504. n8n не ответил вовремя: фильтр ждёт ответ около 10 секунд. Для первой проверки оставьте только приём запроса и ответ. Найдите выполнение в n8n, прежде чем отправлять событие повторно.
Пришёл 415 или 413. Для 415 проверьте тип содержимого application/json. Код 413 означает, что тело запроса больше 256 КиБ. Ответ n8n тоже ограничен 256 КиБ; слишком большой ответ даст 502. Этот фильтр рассчитан на короткие события JSON, а не на загрузку файлов.
Нет соединения с внешним адресом. Убедитесь, что клиент DevProxy работает, ваш канал запущен, а его локальный адрес ведёт на фильтр 5680. Если компьютер спит или выключен, n8n не сможет принять событие.
После проверки
В третьем окне удалите временные заголовки и данные:
if ($null -ne $headers) {
$headers.Clear()
}
Remove-Variable headers, body, hookOrigin -ErrorAction SilentlyContinueЕсли подключение было нужно только на время проверки, остановите свой канал n8n-hook в локальной панели DevProxy, затем нажмите Ctrl+C во втором окне. Команда запуска фильтра удалит N8N_DEMO_SECRET из окружения этого окна. Другие каналы и рабочий n8n останавливать не нужно.
Чтобы принимать следующие события, оставьте нужный канал, фильтр и n8n запущенными. Фильтр передаёт только указанный заголовок доступа и необходимые заголовки запроса, а Cookie и Authorization не пересылает. Настоящий IP внешнего отправителя эта схема не сохраняет.
Если HTTPS-адрес канала сменится, обновите и N8N_WEBHOOK_URL у n8n, и N8N_WEBHOOK_ORIGIN у фильтра. Затем перезапустите только их со всеми прежними настройками и обновите Production URL у отправителя.
С устройством HTTP-канала можно подробнее разобраться в инструкции о показе локального сайта. Настройки самого узла описаны в официальной справке Webhook node, а внешний адрес и заголовки в справке n8n о работе за промежуточным сервером.
Попробуйте бесплатно
Бесплатного тарифа хватает на все сценарии из инструкций.
Другие инструкции
- Как начать пользоваться DevProxy: регистрация, ключ и установка программы
- Как показать заказчику сайт, который открыт только на вашем компьютере
- Как подключиться к рабочему компьютеру из дома без белого IP и VPN
- Как открыть умный дом Home Assistant с телефона вне дома без проброса портов
- Как смотреть свою домашнюю медиатеку Jellyfin вне дома
- Как пользоваться нейросетью на домашнем компьютере со своего телефона
- Как слушать музыку с домашнего компьютера как своё интернет-радио
- Как дать доступ к папке на компьютере по ссылке
- Синхронизация папок между двумя компьютерами через интернет
- Telegram-бот на своём компьютере: настройка webhook
- DevProxy вместо ngrok: туннели для работы и дома
- Как проверить GitHub webhook на localhost через HTTPS