Replace Clay with n8n, Ollama, SearXNG and browserless; add Telegram assistant
Remove the Clay role (Claude Code web UI) and its Anthropic API key. New roles: - ollama: local CPU LLM (qwen3:4b-instruct), internal network only - browserless: headless Chromium for JavaScript-heavy pages - searxng: metasearch backend for the chat web_search tool - n8n at agent.ladkau.de: web chat (/chat), scheduled workflows, Telegram bot; workflows and credentials are imported from the repo Telegram bot: Gemini 3.8 -> Gemini 3.5 -> local Qwen fallback chain, /qwen to force the local model, /stocks and a weekday schedule (07:30, 13:00, 17:40) for the MTE.DE / PTX.F stock report. Also: dashboard and check-services entries for the chat, vault checks for the new secrets (n8n, SearXNG, Gemini, Telegram, SMTP), updated CLAUDE.md, runbooks and README.
This commit is contained in:
@@ -125,12 +125,56 @@ dl_sftp_port: 2223
|
||||
# Status dashboard (public — cloud.ladkau.de root)
|
||||
dashboard_data_dir: /opt/dashboard
|
||||
|
||||
# Clay — Claude Code web interface (agent.ladkau.de)
|
||||
domain_clay: "agent.{{ domain_base }}"
|
||||
clay_data_dir: /opt/clay
|
||||
clay_users: [] # override in vault with [{username: ..., password: ...}]
|
||||
# Ollama — local LLM server, internal network only (no public route)
|
||||
ollama_version: "0.35.1"
|
||||
ollama_data_dir: /opt/ollama
|
||||
ollama_model: "qwen3:4b-instruct-2507-q4_K_M" # non-thinking variant: the thinking model needs minutes per answer on this CPU
|
||||
ollama_context_length: 16384
|
||||
ollama_mem_limit: 4608m
|
||||
ollama_keep_alive: "1h" # unload the model after this idle time to free RAM
|
||||
|
||||
# Browserless — headless Chromium for rendering JavaScript-heavy pages (internal only)
|
||||
browserless_version: "v2.57.0"
|
||||
browserless_data_dir: /opt/browserless
|
||||
browserless_mem_limit: 1g
|
||||
|
||||
# SearXNG — metasearch backend for the n8n web_search tool (internal only)
|
||||
searxng_version: "2026.10.2-19ffbcd30"
|
||||
searxng_data_dir: /opt/searxng
|
||||
searxng_mem_limit: 384m
|
||||
# Secrets — store values in ansible/group_vars/all/vault.yml (Ansible Vault)
|
||||
# anthropic_api_key: "" # from console.anthropic.com → API keys
|
||||
# clay_users:
|
||||
# searxng_secret_key: "" # generate: openssl rand -hex 32
|
||||
|
||||
# n8n — workflow automation, AI chat and scheduled digests (agent.ladkau.de)
|
||||
domain_n8n: "agent.{{ domain_base }}"
|
||||
n8n_version: "2.41.6"
|
||||
n8n_data_dir: /opt/n8n
|
||||
n8n_timezone: "Europe/Berlin"
|
||||
n8n_mem_limit: 768m
|
||||
# Fixed webhook id of the chat workflow: the chat page is served at
|
||||
# https://agent.<domain>/webhook/<id>/chat and https://agent.<domain>/chat redirects there.
|
||||
# Must match "webhookId" in roles/n8n/files/workflows/chat.json.
|
||||
n8n_chat_webhook_id: "3f2b6c1e-8a4d-4e57-9b0a-6d1c2e7f5a10"
|
||||
# Fixed webhook id of the Telegram trigger; must match "webhookId" in roles/n8n/files/workflows/telegram-chat.json.
|
||||
n8n_telegram_webhook_id: "7c1d4e92-5b3a-4f08-a6e1-2d9b8c0f3a47"
|
||||
# Source addresses allowed to call the Telegram webhook (Telegram's webhook servers, see
|
||||
# core.telegram.org/bots/webhooks). Set to [] to disable the address filter.
|
||||
telegram_webhook_source_ranges:
|
||||
- 149.154.160.0/20
|
||||
- 91.108.4.0/22
|
||||
n8n_users: [] # override in vault with [{username: ..., password: ...}] — basic auth in front of n8n
|
||||
|
||||
# Outgoing mail for n8n (Strato SMTP)
|
||||
smtp_host: "smtp.strato.de"
|
||||
smtp_port: 465
|
||||
smtp_secure: true # true = implicit TLS (port 465); false = STARTTLS (port 587)
|
||||
# Secrets — store values in ansible/group_vars/all/vault.yml (Ansible Vault)
|
||||
# n8n_encryption_key: "" # generate: openssl rand -hex 32
|
||||
# gemini_api_key: "" # Google AI Studio API key (default model of the Telegram chat)
|
||||
# telegram_bot_token: "" # from @BotFather
|
||||
# telegram_user_id: "" # your numeric Telegram user id (the only user the bot answers)
|
||||
# smtp_user: "" # full Strato mailbox address
|
||||
# smtp_password: ""
|
||||
# n8n_users:
|
||||
# - username: admin
|
||||
# password: "your-password"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
- name: Restart browserless
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ browserless_data_dir }}"
|
||||
state: present
|
||||
recreate: always
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
- name: Create Browserless directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ browserless_data_dir }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
tags: browserless
|
||||
|
||||
- name: Deploy Docker Compose file
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ browserless_data_dir }}/docker-compose.yml"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Restart browserless
|
||||
tags: browserless
|
||||
|
||||
- name: Start Browserless
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ browserless_data_dir }}"
|
||||
state: present
|
||||
pull: missing
|
||||
tags: browserless
|
||||
@@ -0,0 +1,21 @@
|
||||
# Managed by Ansible — do not edit manually
|
||||
services:
|
||||
browserless:
|
||||
image: ghcr.io/browserless/chromium:{{ browserless_version }}
|
||||
container_name: browserless
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# One browser session at a time, short queue — keeps RAM use predictable on the shared VPS
|
||||
CONCURRENT: "1"
|
||||
QUEUED: "4"
|
||||
TIMEOUT: "60000"
|
||||
shm_size: 512m
|
||||
mem_limit: {{ browserless_mem_limit }}
|
||||
networks:
|
||||
- browserless_internal
|
||||
|
||||
# Not exposed via Traefik. Containers that need the API join this network
|
||||
# (declared as external there) and reach it at http://browserless:3000.
|
||||
networks:
|
||||
browserless_internal:
|
||||
name: browserless_internal
|
||||
@@ -1,20 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
# Claude Code needs git and bash to operate on repositories
|
||||
RUN apk add --no-cache git bash curl
|
||||
|
||||
# Install Claude Code CLI
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Install clay web server
|
||||
RUN npm install -g clay-server
|
||||
|
||||
# MCP servers and skills — add packages here to survive container rebuilds
|
||||
# RUN npm install -g @clay-ai/clay-ralph
|
||||
# RUN npm install -g @modelcontextprotocol/server-filesystem
|
||||
|
||||
RUN mkdir -p /workspace /root/.clay
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
EXPOSE 2633
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
- name: Rebuild and restart clay
|
||||
community.docker.docker_image:
|
||||
name: clay:local
|
||||
build:
|
||||
path: "{{ clay_data_dir }}"
|
||||
source: build
|
||||
force_source: true
|
||||
state: present
|
||||
notify: Restart clay
|
||||
|
||||
- name: Restart clay
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ clay_data_dir }}"
|
||||
state: present
|
||||
recreate: always
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
- name: Create clay directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
loop:
|
||||
- "{{ clay_data_dir }}"
|
||||
- "{{ clay_data_dir }}/data"
|
||||
- "{{ clay_data_dir }}/workspace"
|
||||
tags: clay
|
||||
|
||||
- name: Deploy Dockerfile
|
||||
ansible.builtin.copy:
|
||||
src: Dockerfile
|
||||
dest: "{{ clay_data_dir }}/Dockerfile"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Rebuild and restart clay
|
||||
tags: clay
|
||||
|
||||
- name: Deploy Docker Compose file
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ clay_data_dir }}/docker-compose.yml"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Restart clay
|
||||
tags: clay
|
||||
|
||||
- name: Build clay image
|
||||
community.docker.docker_image:
|
||||
name: clay:local
|
||||
build:
|
||||
path: "{{ clay_data_dir }}"
|
||||
source: build
|
||||
state: present
|
||||
tags: clay
|
||||
|
||||
- name: Start clay
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ clay_data_dir }}"
|
||||
state: present
|
||||
tags: clay
|
||||
@@ -1,35 +0,0 @@
|
||||
# Managed by Ansible — do not edit manually
|
||||
services:
|
||||
clay:
|
||||
image: clay:local
|
||||
build:
|
||||
context: {{ clay_data_dir }}
|
||||
dockerfile: Dockerfile
|
||||
container_name: clay
|
||||
restart: unless-stopped
|
||||
tty: true
|
||||
environment:
|
||||
- ANTHROPIC_API_KEY={{ anthropic_api_key }}
|
||||
volumes:
|
||||
- {{ clay_data_dir }}/data:/root/.clay
|
||||
- {{ clay_data_dir }}/workspace:/workspace
|
||||
command: ["clay-server", "--yes", "-p", "2633"]
|
||||
networks:
|
||||
- traefik_public
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.clay.rule=Host(`{{ domain_clay }}`)"
|
||||
- "traefik.http.routers.clay.entrypoints=websecure"
|
||||
- "traefik.http.routers.clay.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.clay.loadbalancer.server.scheme=https"
|
||||
- "traefik.http.services.clay.loadbalancer.server.port=2633"
|
||||
- "traefik.http.routers.clay.middlewares=clay-auth"
|
||||
{% set ns = namespace(entries=[]) %}
|
||||
{% for user in clay_users %}
|
||||
{% set ns.entries = ns.entries + [user.username + ':' + (user.password | password_hash('bcrypt', (user.username | hash('md5'))[:22]) | replace('$', '$$'))] %}
|
||||
{% endfor %}
|
||||
- "traefik.http.middlewares.clay-auth.basicauth.users={{ ns.entries | join(',') }}"
|
||||
|
||||
networks:
|
||||
traefik_public:
|
||||
external: true
|
||||
@@ -25,7 +25,8 @@ SERVICES = [
|
||||
("k8s", "https://{{ domain_k8s }}", 200, "https://{{ domain_k8s }}"),
|
||||
("Vaultwarden", "https://{{ domain_vault }}", 200, "https://{{ domain_vault }}"),
|
||||
("Downloads", "https://{{ domain_dl }}", 200, "https://{{ domain_dl }}"),
|
||||
("Clay", "https://{{ domain_clay }}", 401, "https://{{ domain_clay }}"),
|
||||
("AI Chat", "https://{{ domain_n8n }}/chat", 401, "https://{{ domain_n8n }}/chat"),
|
||||
("n8n", "https://{{ domain_n8n }}", 401, "https://{{ domain_n8n }}"),
|
||||
]
|
||||
{% raw %}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "ollama0000000001",
|
||||
"name": "Ollama (local)",
|
||||
"type": "ollamaApi",
|
||||
"data": {
|
||||
"baseUrl": "http://ollama:11434",
|
||||
"apiKey": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
{
|
||||
"id": "chat00000000001",
|
||||
"name": "Chat",
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "c1",
|
||||
"name": "Chat",
|
||||
"type": "@n8n/n8n-nodes-langchain.chatTrigger",
|
||||
"typeVersion": 1.4,
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"webhookId": "3f2b6c1e-8a4d-4e57-9b0a-6d1c2e7f5a10",
|
||||
"parameters": {
|
||||
"public": true,
|
||||
"mode": "hostedChat",
|
||||
"authentication": "none",
|
||||
"initialMessages": "Hi! Ask me anything or paste a link and I will read the page.",
|
||||
"options": {
|
||||
"loadPreviousSession": "notSupported",
|
||||
"responseMode": "streaming",
|
||||
"title": "Local AI chat"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c2",
|
||||
"name": "AI Agent",
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"typeVersion": 3.1,
|
||||
"position": [
|
||||
300,
|
||||
0
|
||||
],
|
||||
"parameters": {
|
||||
"promptType": "auto",
|
||||
"text": "={{ $json.chatInput }}",
|
||||
"options": {
|
||||
"systemMessage": "You are a concise assistant. Answer in the language of the user. For questions about current events, weather, news, prices or anything you cannot know for sure, call the web_search tool first with a short search query, then answer from the results and name the source URLs. When the user gives a web address, or a search result needs more detail, call the fetch_page tool with the full URL and base your answer only on what it returns. If a tool fails, say so. Do not invent facts or page content."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c3",
|
||||
"name": "Ollama Chat Model",
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatOllama",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
200,
|
||||
260
|
||||
],
|
||||
"parameters": {
|
||||
"model": "__OLLAMA_MODEL__",
|
||||
"options": {
|
||||
"numCtx": 16384,
|
||||
"temperature": 0.3,
|
||||
"numThread": 4
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"ollamaApi": {
|
||||
"id": "ollama0000000001",
|
||||
"name": "Ollama (local)"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c4",
|
||||
"name": "Window Buffer Memory",
|
||||
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
|
||||
"typeVersion": 1.3,
|
||||
"position": [
|
||||
380,
|
||||
260
|
||||
],
|
||||
"parameters": {
|
||||
"sessionIdType": "fromInput",
|
||||
"contextWindowLength": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c5",
|
||||
"name": "fetch_page",
|
||||
"type": "@n8n/n8n-nodes-langchain.toolWorkflow",
|
||||
"typeVersion": 2.2,
|
||||
"position": [
|
||||
560,
|
||||
260
|
||||
],
|
||||
"parameters": {
|
||||
"name": "fetch_page",
|
||||
"description": "Opens a web page in a real browser (JavaScript is executed) and returns its title and readable text, truncated to about 3000 characters. Input: the full URL.",
|
||||
"workflowId": {
|
||||
"__rl": true,
|
||||
"value": "fetchpage0000001",
|
||||
"mode": "id"
|
||||
},
|
||||
"workflowInputs": {
|
||||
"mappingMode": "defineBelow",
|
||||
"value": {
|
||||
"url": "={{ $fromAI('url', 'The full URL of the page to open, including https://', 'string') }}"
|
||||
},
|
||||
"matchingColumns": [],
|
||||
"schema": [
|
||||
{
|
||||
"id": "url",
|
||||
"displayName": "url",
|
||||
"required": false,
|
||||
"defaultMatch": false,
|
||||
"display": true,
|
||||
"canBeUsedToMatch": true,
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"attemptToConvertTypes": false,
|
||||
"convertFieldsToString": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c6",
|
||||
"name": "web_search",
|
||||
"type": "@n8n/n8n-nodes-langchain.toolWorkflow",
|
||||
"typeVersion": 2.2,
|
||||
"position": [
|
||||
740,
|
||||
260
|
||||
],
|
||||
"parameters": {
|
||||
"name": "web_search",
|
||||
"description": "Searches the web and returns the top results (title, URL, short snippet). Input: a short search query.",
|
||||
"workflowId": {
|
||||
"__rl": true,
|
||||
"value": "websearch000001",
|
||||
"mode": "id"
|
||||
},
|
||||
"workflowInputs": {
|
||||
"mappingMode": "defineBelow",
|
||||
"value": {
|
||||
"query": "={{ $fromAI('query', 'A short web search query', 'string') }}"
|
||||
},
|
||||
"matchingColumns": [],
|
||||
"schema": [
|
||||
{
|
||||
"id": "query",
|
||||
"displayName": "query",
|
||||
"required": false,
|
||||
"defaultMatch": false,
|
||||
"display": true,
|
||||
"canBeUsedToMatch": true,
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"attemptToConvertTypes": false,
|
||||
"convertFieldsToString": false
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Chat": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AI Agent",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Ollama Chat Model": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "AI Agent",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Window Buffer Memory": {
|
||||
"ai_memory": [
|
||||
[
|
||||
{
|
||||
"node": "AI Agent",
|
||||
"type": "ai_memory",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"fetch_page": {
|
||||
"ai_tool": [
|
||||
[
|
||||
{
|
||||
"node": "AI Agent",
|
||||
"type": "ai_tool",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"web_search": {
|
||||
"ai_tool": [
|
||||
[
|
||||
{
|
||||
"node": "AI Agent",
|
||||
"type": "ai_tool",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
{
|
||||
"id": "digesttg00000001",
|
||||
"name": "Weekly page digest to Telegram (example)",
|
||||
"active": false,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "g1",
|
||||
"name": "Every Monday 06:00",
|
||||
"type": "n8n-nodes-base.scheduleTrigger",
|
||||
"typeVersion": 1.2,
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"parameters": {
|
||||
"rule": {
|
||||
"interval": [
|
||||
{
|
||||
"field": "cronExpression",
|
||||
"expression": "0 6 * * 1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "g2",
|
||||
"name": "Sites",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
240,
|
||||
0
|
||||
],
|
||||
"parameters": {
|
||||
"jsCode": "// One item per page to summarise. Edit url and prompt.\nreturn [\n { json: { url: 'https://example.com', prompt: 'Summarise this page in five bullet points.' } },\n];\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "g3",
|
||||
"name": "Fetch page text",
|
||||
"type": "n8n-nodes-base.executeWorkflow",
|
||||
"typeVersion": 1.2,
|
||||
"position": [
|
||||
480,
|
||||
0
|
||||
],
|
||||
"parameters": {
|
||||
"source": "database",
|
||||
"workflowId": {
|
||||
"__rl": true,
|
||||
"value": "fetchpage0000001",
|
||||
"mode": "id"
|
||||
},
|
||||
"workflowInputs": {
|
||||
"mappingMode": "defineBelow",
|
||||
"value": {
|
||||
"url": "={{ $json.url }}"
|
||||
},
|
||||
"matchingColumns": [],
|
||||
"schema": [],
|
||||
"attemptToConvertTypes": false,
|
||||
"convertFieldsToString": false
|
||||
},
|
||||
"options": {
|
||||
"waitForSubWorkflow": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "g4",
|
||||
"name": "Summarise",
|
||||
"type": "@n8n/n8n-nodes-langchain.chainLlm",
|
||||
"typeVersion": 1.7,
|
||||
"position": [
|
||||
720,
|
||||
0
|
||||
],
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "={{ $('Sites').item.json.prompt + '\\n\\n---\\n' + $json.response }}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "g5",
|
||||
"name": "Ollama Chat Model",
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatOllama",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
720,
|
||||
240
|
||||
],
|
||||
"parameters": {
|
||||
"model": "__OLLAMA_MODEL__",
|
||||
"options": {
|
||||
"numCtx": 16384,
|
||||
"temperature": 0.2,
|
||||
"numThread": 4
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"ollamaApi": {
|
||||
"id": "ollama0000000001",
|
||||
"name": "Ollama (local)"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "g6",
|
||||
"name": "Format message",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
1000,
|
||||
0
|
||||
],
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "f1",
|
||||
"name": "message",
|
||||
"type": "string",
|
||||
"value": "={{ ('Digest: ' + $('Sites').item.json.url + '\\n\\n' + $json.text).slice(0, 4000) }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "g7",
|
||||
"name": "Post to Telegram chat",
|
||||
"type": "n8n-nodes-base.executeWorkflow",
|
||||
"typeVersion": 1.2,
|
||||
"position": [
|
||||
1240,
|
||||
0
|
||||
],
|
||||
"parameters": {
|
||||
"source": "database",
|
||||
"workflowId": {
|
||||
"__rl": true,
|
||||
"value": "telegramchat0001",
|
||||
"mode": "id"
|
||||
},
|
||||
"workflowInputs": {
|
||||
"mappingMode": "defineBelow",
|
||||
"value": {
|
||||
"message": "={{ $json.message }}"
|
||||
},
|
||||
"matchingColumns": [],
|
||||
"schema": [],
|
||||
"attemptToConvertTypes": false,
|
||||
"convertFieldsToString": false
|
||||
},
|
||||
"options": {
|
||||
"waitForSubWorkflow": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Every Monday 06:00": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Sites",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Sites": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Fetch page text",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Fetch page text": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Summarise",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Ollama Chat Model": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "Summarise",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Summarise": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Format message",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Format message": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Post to Telegram chat",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"id": "digest0000000001",
|
||||
"name": "Weekly page digest (example)",
|
||||
"active": false,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "d1",
|
||||
"name": "Every Monday 06:00",
|
||||
"type": "n8n-nodes-base.scheduleTrigger",
|
||||
"typeVersion": 1.2,
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"parameters": {
|
||||
"rule": {
|
||||
"interval": [
|
||||
{
|
||||
"field": "cronExpression",
|
||||
"expression": "0 6 * * 1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "d2",
|
||||
"name": "Sites",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
240,
|
||||
0
|
||||
],
|
||||
"parameters": {
|
||||
"jsCode": "// One item per page to summarise. Edit url and prompt.\nreturn [\n { json: { url: 'https://example.com', prompt: 'Summarise this page in five bullet points.', to: 'you@example.com' } },\n];\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "d3",
|
||||
"name": "Fetch page text",
|
||||
"type": "n8n-nodes-base.executeWorkflow",
|
||||
"typeVersion": 1.2,
|
||||
"position": [
|
||||
480,
|
||||
0
|
||||
],
|
||||
"parameters": {
|
||||
"source": "database",
|
||||
"workflowId": {
|
||||
"__rl": true,
|
||||
"value": "fetchpage0000001",
|
||||
"mode": "id"
|
||||
},
|
||||
"workflowInputs": {
|
||||
"mappingMode": "defineBelow",
|
||||
"value": {
|
||||
"url": "={{ $json.url }}"
|
||||
},
|
||||
"matchingColumns": [],
|
||||
"schema": [],
|
||||
"attemptToConvertTypes": false,
|
||||
"convertFieldsToString": false
|
||||
},
|
||||
"options": {
|
||||
"waitForSubWorkflow": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "d4",
|
||||
"name": "Summarise",
|
||||
"type": "@n8n/n8n-nodes-langchain.chainLlm",
|
||||
"typeVersion": 1.7,
|
||||
"position": [
|
||||
720,
|
||||
0
|
||||
],
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "={{ $('Sites').item.json.prompt + '\\n\\n---\\n' + $json.response }}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "d5",
|
||||
"name": "Ollama Chat Model",
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatOllama",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
720,
|
||||
240
|
||||
],
|
||||
"parameters": {
|
||||
"model": "__OLLAMA_MODEL__",
|
||||
"options": {
|
||||
"numCtx": 16384,
|
||||
"temperature": 0.2,
|
||||
"numThread": 4
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"ollamaApi": {
|
||||
"id": "ollama0000000001",
|
||||
"name": "Ollama (local)"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "d6",
|
||||
"name": "Send mail",
|
||||
"type": "n8n-nodes-base.emailSend",
|
||||
"typeVersion": 2.1,
|
||||
"position": [
|
||||
1020,
|
||||
0
|
||||
],
|
||||
"parameters": {
|
||||
"fromEmail": "={{ $credentials.user }}",
|
||||
"toEmail": "={{ $('Sites').item.json.to }}",
|
||||
"subject": "={{ 'Digest: ' + $('Sites').item.json.url }}",
|
||||
"emailFormat": "text",
|
||||
"text": "={{ $json.text }}",
|
||||
"options": {}
|
||||
},
|
||||
"credentials": {
|
||||
"smtp": {
|
||||
"id": "smtp00000000001",
|
||||
"name": "Strato SMTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Every Monday 06:00": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Sites",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Sites": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Fetch page text",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Fetch page text": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Summarise",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Ollama Chat Model": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "Summarise",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Summarise": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send mail",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"id": "fetchpage0000001",
|
||||
"name": "Fetch page text",
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "n1",
|
||||
"name": "When called by another workflow",
|
||||
"type": "n8n-nodes-base.executeWorkflowTrigger",
|
||||
"typeVersion": 1.1,
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"parameters": {
|
||||
"inputSource": "workflowInputs",
|
||||
"workflowInputs": {
|
||||
"values": [
|
||||
{
|
||||
"name": "url",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "n2",
|
||||
"name": "Render page in browserless",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
260,
|
||||
0
|
||||
],
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "http://browserless:3000/function",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ code: \"export default async function ({ page, context }) {\\n await page.goto(context.url, { waitUntil: 'networkidle2', timeout: 40000 });\\n const title = await page.title();\\n const text = await page.evaluate(() => {\\n document.querySelectorAll('script,style,noscript,nav,footer,header,iframe,svg').forEach(e => e.remove());\\n return (document.body.innerText || '').replace(/\\\\n{3,}/g, '\\\\n\\\\n').trim();\\n });\\n return { data: { title, text: text.slice(0, context.maxChars || 3000) }, type: 'application/json' };\\n}\\n\", context: { url: $json.url, maxChars: 3000 } }) }}",
|
||||
"options": {
|
||||
"timeout": 90000
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "n3",
|
||||
"name": "Format result",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
520,
|
||||
0
|
||||
],
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "a1",
|
||||
"name": "response",
|
||||
"type": "string",
|
||||
"value": "={{ 'Title: ' + $json.data.title + '\\n\\n' + $json.data.text }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"When called by another workflow": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Render page in browserless",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Render page in browserless": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Format result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"id": "websearch000001",
|
||||
"name": "Web search",
|
||||
"active": true,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "s1",
|
||||
"name": "When called by another workflow",
|
||||
"type": "n8n-nodes-base.executeWorkflowTrigger",
|
||||
"typeVersion": 1.1,
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"parameters": {
|
||||
"inputSource": "workflowInputs",
|
||||
"workflowInputs": {
|
||||
"values": [
|
||||
{
|
||||
"name": "query",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "s2",
|
||||
"name": "Query SearXNG",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [
|
||||
260,
|
||||
0
|
||||
],
|
||||
"parameters": {
|
||||
"method": "GET",
|
||||
"url": "http://searxng:8080/search",
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "q",
|
||||
"value": "={{ $json.query }}"
|
||||
},
|
||||
{
|
||||
"name": "format",
|
||||
"value": "json"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"timeout": 30000
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "s3",
|
||||
"name": "Format results",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
520,
|
||||
0
|
||||
],
|
||||
"parameters": {
|
||||
"jsCode": "const results = ($input.first().json.results || []).slice(0, 6);\nif (!results.length) return [{ json: { response: 'No search results found.' } }];\nconst text = results.map((r, i) => (i + 1) + '. ' + r.title + '\\n ' + r.url + '\\n ' + (r.content || '').slice(0, 300)).join('\\n\\n');\nreturn [{ json: { response: text.slice(0, 3500) } }];\n"
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"When called by another workflow": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Query SearXNG",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Query SearXNG": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Format results",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
- name: Restart n8n
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ n8n_data_dir }}"
|
||||
state: present
|
||||
recreate: always
|
||||
|
||||
# "Import n8n content" is a topic, not a single handler: the tasks below all listen
|
||||
# on it and run in the order they are defined (handlers cannot be blocks).
|
||||
# Credentials first (workflows reference them by id), then workflows.
|
||||
# Imported workflows start inactive; the ones marked "active": true are published.
|
||||
# n8n only registers triggers/webhooks at startup, so restart afterwards.
|
||||
- name: Wait for n8n to be ready (database migrations finished)
|
||||
ansible.builtin.command: docker exec n8n wget -qO- http://localhost:5678/healthz/readiness
|
||||
listen: Import n8n content
|
||||
register: n8n_ready
|
||||
changed_when: false
|
||||
retries: 40
|
||||
delay: 3
|
||||
until: n8n_ready.rc == 0
|
||||
|
||||
- name: Import n8n credentials
|
||||
ansible.builtin.command: docker exec n8n n8n import:credentials --separate --input=/import/credentials
|
||||
listen: Import n8n content
|
||||
no_log: true
|
||||
changed_when: true
|
||||
|
||||
- name: Import n8n workflows
|
||||
ansible.builtin.command: docker exec n8n n8n import:workflow --separate --input=/import/workflows
|
||||
listen: Import n8n content
|
||||
changed_when: true
|
||||
|
||||
- name: Publish active n8n workflows
|
||||
ansible.builtin.command: docker exec n8n n8n publish:workflow --id={{ item }}
|
||||
listen: Import n8n content
|
||||
loop: "{{ n8n_publish_ids | from_json }}"
|
||||
changed_when: true
|
||||
|
||||
- name: Restart n8n to register triggers
|
||||
ansible.builtin.command: docker restart n8n
|
||||
listen: Import n8n content
|
||||
changed_when: true
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
- name: Create n8n directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ item.path }}"
|
||||
state: directory
|
||||
owner: "{{ item.owner }}"
|
||||
group: "{{ item.owner }}"
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- { path: "{{ n8n_data_dir }}", owner: root, mode: "0755" }
|
||||
# n8n runs as uid 1000 (node) inside the container
|
||||
- { path: "{{ n8n_data_dir }}/data", owner: "1000", mode: "0700" }
|
||||
- { path: "{{ n8n_data_dir }}/import", owner: "1000", mode: "0700" }
|
||||
- { path: "{{ n8n_data_dir }}/import/workflows", owner: "1000", mode: "0700" }
|
||||
- { path: "{{ n8n_data_dir }}/import/credentials", owner: "1000", mode: "0700" }
|
||||
tags: n8n
|
||||
|
||||
- name: Deploy Docker Compose file
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ n8n_data_dir }}/docker-compose.yml"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Restart n8n
|
||||
tags: n8n
|
||||
|
||||
# __TELEGRAM_USER_ID__ and __OLLAMA_MODEL__ in the workflow files are replaced by telegram_user_id and ollama_model
|
||||
- name: Deploy workflows
|
||||
ansible.builtin.copy:
|
||||
content: "{{ lookup('ansible.builtin.file', item) | replace('__TELEGRAM_USER_ID__', telegram_user_id | string) | replace('__OLLAMA_MODEL__', ollama_model) }}"
|
||||
dest: "{{ n8n_data_dir }}/import/workflows/{{ item | basename }}"
|
||||
owner: "1000"
|
||||
group: "1000"
|
||||
mode: "0600"
|
||||
with_fileglob:
|
||||
- "workflows/*.json"
|
||||
notify: Import n8n content
|
||||
tags: n8n
|
||||
|
||||
- name: Deploy Ollama credential
|
||||
ansible.builtin.copy:
|
||||
src: credentials/ollama.json
|
||||
dest: "{{ n8n_data_dir }}/import/credentials/ollama.json"
|
||||
owner: "1000"
|
||||
group: "1000"
|
||||
mode: "0600"
|
||||
notify: Import n8n content
|
||||
tags: n8n
|
||||
|
||||
- name: Deploy SMTP credential
|
||||
ansible.builtin.template:
|
||||
src: smtp-credential.json.j2
|
||||
dest: "{{ n8n_data_dir }}/import/credentials/smtp.json"
|
||||
owner: "1000"
|
||||
group: "1000"
|
||||
mode: "0600"
|
||||
no_log: true
|
||||
notify: Import n8n content
|
||||
tags: n8n
|
||||
|
||||
# Workflows with "active": true in their JSON are published after import
|
||||
- name: Collect workflow ids to publish
|
||||
ansible.builtin.set_fact:
|
||||
n8n_publish_ids: >-
|
||||
{%- set ns = namespace(ids=[]) -%}
|
||||
{%- for f in query('fileglob', role_path ~ '/files/workflows/*.json') -%}
|
||||
{%- set wf = lookup('file', f) | from_json -%}
|
||||
{%- if wf.active | default(false) -%}{%- set ns.ids = ns.ids + [wf.id] -%}{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{ ns.ids | to_json }}
|
||||
tags: n8n
|
||||
|
||||
- name: Deploy Telegram credential
|
||||
ansible.builtin.template:
|
||||
src: telegram-credential.json.j2
|
||||
dest: "{{ n8n_data_dir }}/import/credentials/telegram.json"
|
||||
owner: "1000"
|
||||
group: "1000"
|
||||
mode: "0600"
|
||||
no_log: true
|
||||
notify: Import n8n content
|
||||
tags: n8n
|
||||
|
||||
- name: Deploy Gemini credential
|
||||
ansible.builtin.template:
|
||||
src: gemini-credential.json.j2
|
||||
dest: "{{ n8n_data_dir }}/import/credentials/gemini.json"
|
||||
owner: "1000"
|
||||
group: "1000"
|
||||
mode: "0600"
|
||||
no_log: true
|
||||
notify: Import n8n content
|
||||
tags: n8n
|
||||
|
||||
- name: Start n8n
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ n8n_data_dir }}"
|
||||
state: present
|
||||
pull: missing
|
||||
tags: n8n
|
||||
|
||||
# Handlers run at the end of the play; flush so the import happens before the role finishes
|
||||
- name: Apply pending handlers
|
||||
ansible.builtin.meta: flush_handlers
|
||||
tags: n8n
|
||||
@@ -0,0 +1,77 @@
|
||||
# Managed by Ansible — do not edit manually
|
||||
services:
|
||||
n8n:
|
||||
image: n8nio/n8n:{{ n8n_version }}
|
||||
container_name: n8n
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
N8N_HOST: "{{ domain_n8n }}"
|
||||
N8N_PROTOCOL: "https"
|
||||
N8N_PORT: "5678"
|
||||
# Public URL used to build webhook / chat links behind Traefik
|
||||
WEBHOOK_URL: "https://{{ domain_n8n }}/"
|
||||
N8N_PROXY_HOPS: "1"
|
||||
N8N_ENCRYPTION_KEY: "{{ n8n_encryption_key }}"
|
||||
GENERIC_TIMEZONE: "{{ n8n_timezone }}"
|
||||
TZ: "{{ n8n_timezone }}"
|
||||
N8N_DIAGNOSTICS_ENABLED: "false"
|
||||
N8N_VERSION_NOTIFICATIONS_ENABLED: "false"
|
||||
N8N_TEMPLATES_ENABLED: "false"
|
||||
# Keep the SQLite database small: drop execution history after 7 days
|
||||
EXECUTIONS_DATA_PRUNE: "true"
|
||||
EXECUTIONS_DATA_MAX_AGE: "168"
|
||||
volumes:
|
||||
- {{ n8n_data_dir }}/data:/home/node/.n8n
|
||||
# Workflows and credentials imported by Ansible (see roles/n8n/tasks/main.yml)
|
||||
- {{ n8n_data_dir }}/import:/import:ro
|
||||
mem_limit: {{ n8n_mem_limit }}
|
||||
networks:
|
||||
- traefik_public
|
||||
- ollama_internal
|
||||
- browserless_internal
|
||||
- searxng_internal
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.services.n8n.loadbalancer.server.port=5678"
|
||||
# Editor, webhooks and chat page — all behind basic auth
|
||||
- "traefik.http.routers.n8n.rule=Host(`{{ domain_n8n }}`)"
|
||||
- "traefik.http.routers.n8n.entrypoints=websecure"
|
||||
- "traefik.http.routers.n8n.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.n8n.service=n8n"
|
||||
- "traefik.http.routers.n8n.middlewares=n8n-auth"
|
||||
# Short link: /chat redirects to the hosted chat page of the chat workflow
|
||||
- "traefik.http.routers.n8n-chat.rule=Host(`{{ domain_n8n }}`) && Path(`/chat`)"
|
||||
- "traefik.http.routers.n8n-chat.priority=100"
|
||||
- "traefik.http.routers.n8n-chat.entrypoints=websecure"
|
||||
- "traefik.http.routers.n8n-chat.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.n8n-chat.service=n8n"
|
||||
- "traefik.http.routers.n8n-chat.middlewares=n8n-auth,n8n-chat-redirect"
|
||||
- "traefik.http.middlewares.n8n-chat-redirect.redirectregex.regex=^https://{{ domain_n8n | replace('.', '\\\\.') }}/chat/?$$"
|
||||
- "traefik.http.middlewares.n8n-chat-redirect.redirectregex.replacement=https://{{ domain_n8n }}/webhook/{{ n8n_chat_webhook_id }}/chat"
|
||||
# Telegram bot webhook: called by Telegram's servers, so no basic auth. Restricted to Telegram's
|
||||
# address ranges; n8n additionally checks Telegram's secret token header and the workflow
|
||||
# only answers the user id in telegram_user_id.
|
||||
- "traefik.http.routers.n8n-telegram.rule=Host(`{{ domain_n8n }}`) && Path(`/webhook/{{ n8n_telegram_webhook_id }}/webhook`) && Method(`POST`)"
|
||||
- "traefik.http.routers.n8n-telegram.priority=200"
|
||||
- "traefik.http.routers.n8n-telegram.entrypoints=websecure"
|
||||
- "traefik.http.routers.n8n-telegram.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.n8n-telegram.service=n8n"
|
||||
{% if telegram_webhook_source_ranges | length > 0 %}
|
||||
- "traefik.http.routers.n8n-telegram.middlewares=n8n-telegram-ips"
|
||||
- "traefik.http.middlewares.n8n-telegram-ips.ipallowlist.sourcerange={{ telegram_webhook_source_ranges | join(',') }}"
|
||||
{% endif %}
|
||||
{% set ns = namespace(entries=[]) %}
|
||||
{% for user in n8n_users %}
|
||||
{% set ns.entries = ns.entries + [user.username + ':' + (user.password | password_hash('bcrypt', (user.username | hash('md5'))[:22]) | replace('$', '$$'))] %}
|
||||
{% endfor %}
|
||||
- "traefik.http.middlewares.n8n-auth.basicauth.users={{ ns.entries | join(',') }}"
|
||||
|
||||
networks:
|
||||
traefik_public:
|
||||
external: true
|
||||
ollama_internal:
|
||||
external: true
|
||||
browserless_internal:
|
||||
external: true
|
||||
searxng_internal:
|
||||
external: true
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "gemini00000001",
|
||||
"name": "Google Gemini",
|
||||
"type": "googlePalmApi",
|
||||
"data": {
|
||||
"host": "https://generativelanguage.googleapis.com",
|
||||
"apiKey": {{ gemini_api_key | to_json }}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "smtp00000000001",
|
||||
"name": "Strato SMTP",
|
||||
"type": "smtp",
|
||||
"data": {
|
||||
"user": {{ smtp_user | to_json }},
|
||||
"password": {{ smtp_password | to_json }},
|
||||
"host": {{ smtp_host | to_json }},
|
||||
"port": {{ smtp_port }},
|
||||
"secure": {{ smtp_secure | to_json }},
|
||||
"disableStartTls": false,
|
||||
"hostName": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "telegram0000001",
|
||||
"name": "Telegram bot",
|
||||
"type": "telegramApi",
|
||||
"data": {
|
||||
"accessToken": {{ telegram_bot_token | to_json }},
|
||||
"baseUrl": "https://api.telegram.org"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
- name: Restart ollama
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ ollama_data_dir }}"
|
||||
state: present
|
||||
recreate: always
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
- name: Create Ollama directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
loop:
|
||||
- "{{ ollama_data_dir }}"
|
||||
- "{{ ollama_data_dir }}/models"
|
||||
tags: ollama
|
||||
|
||||
- name: Deploy Docker Compose file
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ ollama_data_dir }}/docker-compose.yml"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Restart ollama
|
||||
tags: ollama
|
||||
|
||||
- name: Start Ollama
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ ollama_data_dir }}"
|
||||
state: present
|
||||
pull: missing
|
||||
tags: ollama
|
||||
|
||||
- name: List installed Ollama models
|
||||
ansible.builtin.command: docker exec ollama ollama list
|
||||
register: ollama_installed
|
||||
changed_when: false
|
||||
retries: 10
|
||||
delay: 3
|
||||
until: ollama_installed.rc == 0
|
||||
tags: ollama
|
||||
|
||||
- name: Pull Ollama model
|
||||
ansible.builtin.command: docker exec ollama ollama pull {{ ollama_model }}
|
||||
when: ollama_model not in ollama_installed.stdout
|
||||
changed_when: true
|
||||
tags: ollama
|
||||
@@ -0,0 +1,26 @@
|
||||
# Managed by Ansible — do not edit manually
|
||||
services:
|
||||
ollama:
|
||||
image: ollama/ollama:{{ ollama_version }}
|
||||
container_name: ollama
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
OLLAMA_CONTEXT_LENGTH: "{{ ollama_context_length }}"
|
||||
OLLAMA_FLASH_ATTENTION: "1"
|
||||
OLLAMA_KV_CACHE_TYPE: "q8_0"
|
||||
# One model, one request at a time — keeps RAM use predictable on the shared VPS
|
||||
OLLAMA_MAX_LOADED_MODELS: "1"
|
||||
OLLAMA_NUM_PARALLEL: "1"
|
||||
# Unload the model after this idle time to free RAM for the other services
|
||||
OLLAMA_KEEP_ALIVE: "{{ ollama_keep_alive }}"
|
||||
volumes:
|
||||
- {{ ollama_data_dir }}/models:/root/.ollama
|
||||
mem_limit: {{ ollama_mem_limit }}
|
||||
networks:
|
||||
- ollama_internal
|
||||
|
||||
# Not exposed via Traefik. Containers that need the API join this network
|
||||
# (declared as external there) and reach it at http://ollama:11434.
|
||||
networks:
|
||||
ollama_internal:
|
||||
name: ollama_internal
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
- name: Restart searxng
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ searxng_data_dir }}"
|
||||
state: present
|
||||
recreate: always
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
- name: Create SearXNG directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ searxng_data_dir }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
tags: searxng
|
||||
|
||||
- name: Deploy SearXNG settings
|
||||
ansible.builtin.template:
|
||||
src: settings.yml.j2
|
||||
dest: "{{ searxng_data_dir }}/settings.yml"
|
||||
owner: "977"
|
||||
group: "977"
|
||||
mode: "0640"
|
||||
no_log: true
|
||||
notify: Restart searxng
|
||||
tags: searxng
|
||||
|
||||
- name: Deploy Docker Compose file
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ searxng_data_dir }}/docker-compose.yml"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Restart searxng
|
||||
tags: searxng
|
||||
|
||||
- name: Start SearXNG
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ searxng_data_dir }}"
|
||||
state: present
|
||||
pull: missing
|
||||
tags: searxng
|
||||
@@ -0,0 +1,19 @@
|
||||
# Managed by Ansible — do not edit manually
|
||||
services:
|
||||
searxng:
|
||||
image: searxng/searxng:{{ searxng_version }}
|
||||
container_name: searxng
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SEARXNG_BASE_URL: "http://searxng:8080/"
|
||||
volumes:
|
||||
- {{ searxng_data_dir }}/settings.yml:/etc/searxng/settings.yml:ro
|
||||
mem_limit: {{ searxng_mem_limit }}
|
||||
networks:
|
||||
- searxng_internal
|
||||
|
||||
# Not exposed via Traefik. Containers that need the API join this network
|
||||
# (declared as external there) and reach it at http://searxng:8080.
|
||||
networks:
|
||||
searxng_internal:
|
||||
name: searxng_internal
|
||||
@@ -0,0 +1,24 @@
|
||||
# Managed by Ansible — do not edit manually
|
||||
use_default_settings: true
|
||||
|
||||
general:
|
||||
instance_name: "search"
|
||||
enable_metrics: false
|
||||
|
||||
server:
|
||||
secret_key: "{{ searxng_secret_key }}"
|
||||
# Internal use only (no public route), so the bot limiter is not needed
|
||||
limiter: false
|
||||
image_proxy: false
|
||||
|
||||
search:
|
||||
# json is required by the n8n web_search tool
|
||||
formats:
|
||||
- html
|
||||
- json
|
||||
safe_search: 0
|
||||
autocomplete: ""
|
||||
|
||||
outgoing:
|
||||
request_timeout: 6.0
|
||||
max_request_timeout: 10.0
|
||||
@@ -30,9 +30,6 @@ entryPoints:
|
||||
imaps:
|
||||
address: ":993"
|
||||
|
||||
serversTransport:
|
||||
insecureSkipVerify: true # allows Traefik to proxy clay's self-signed d.clay.studio cert on port 2633
|
||||
|
||||
providers:
|
||||
docker:
|
||||
exposedByDefault: false
|
||||
|
||||
+4
-1
@@ -15,5 +15,8 @@
|
||||
- k8s
|
||||
- vaultwarden
|
||||
- dl
|
||||
- clay
|
||||
- ollama
|
||||
- browserless
|
||||
- searxng
|
||||
- n8n
|
||||
- dashboard
|
||||
|
||||
Reference in New Issue
Block a user