Sort of like J.A.R.V.I.S. but different. This is a project to explore creating a personal assistant
753
Friday is a self-hosted, personal AI assistant. It's a web app (chat + a dashboard) built around your own choice of LLM backend - you bring the model, Friday brings the persona, memory, tool calling, and voice/automation integrations around it.
Core features work out of the box once you've connected an LLM:
Friday does not include an LLM. You need your own - a local server (Ollama, vLLM, or anything else with an OpenAI-compatible API) or a cloud provider's API key (OpenAI, Groq, etc.). Everything else - which provider, which model, your first persona - is set up through an in-app first-run wizard after you log in for the first time, not through compose file edits.
Required:
omnivoice-tts service and
leave TextToSpeech__Url blank if you don't have one - everything else runs fine on CPU)Everything below is bundled in the one compose file - nothing else to download or stand up
yourself. The compose file is entirely self-contained: its two small config files (for the
memory feature's Graphiti server and its nginx workaround) are embedded inline as Compose
configs: blocks, not separate files.
| Feature | Service | Needs configuring after first run? |
|---|---|---|
| Database | PostgreSQL | No |
| Text-to-speech | OmniVoice | No |
| Speech-to-text | whisper-asr-webservice | No |
| Face recognition | DeepFace | No |
| Wake-word detection | Wyoming openWakeWord | Yes - point Friday's Settings page at it |
| Real-time voice (WebRTC) | LiveKit | No (env vars only) |
| Camera streaming | go2rtc | No |
| Cross-conversation memory | Neo4j + Graphiti | Yes - add Graphiti as an MCP server from Friday's McpServers page, and set its LLM/embedding server on the Settings page |
Needs a custom-built service, not just a public image - Friday's own wrapper Dockerfiles for these aren't published, so they're only available if you build a compatible replacement yourself: speaker-verification embedding, Piper TTS, and voice-cloning engines other than OmniVoice (Qwen3-TTS, Chatterbox-Turbo, IndexTTS). None of these are required for Friday to run.
Save this as docker-compose.yml, then run docker compose up -d. Before deploying, generate
real values for POSTGRES_PASSWORD, NEO4J_PASSWORD, LIVEKIT_API_KEY/LIVEKIT_API_SECRET
(32+ characters), and set GO2RTC_PUBLIC_URL to your server's real address - either in a .env
file next to the compose file, or exported before docker compose up. Every placeholder default
below is clearly marked and not safe to deploy as-is.
services:
postgres:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_DB: friday_db
POSTGRES_USER: admin
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-generate-a-real-password}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin -d friday_db"]
interval: 5s
timeout: 5s
retries: 10
# faster-whisper via whisper-asr-webservice - speech-to-text. CPU-mode by default; add a GPU
# reservation (see omnivoice-tts below for the shape) if you have one.
whisper:
image: onerahmet/openai-whisper-asr-webservice:latest
restart: unless-stopped
environment:
ASR_MODEL: base
ASR_ENGINE: faster_whisper
# DeepFace's official image with its built-in REST API. CPU-mode by default.
facerecognition:
image: serengil/deepface:latest
restart: unless-stopped
# rhasspy's Wyoming-protocol wrapper around openWakeWord - streamed to continuously over a
# plain TCP socket rather than one HTTP-uploaded clip at a time. Point Friday's own Settings
# page at ws://wakeword-wyoming:10400 (or the mapped host port) after first run - this engine
# is configured in-app, not via an env var here.
#
# --custom-model-dir is NOT optional: this image loads zero wake-word models by default and
# silently reports "not-detected" forever with no error otherwise.
wakeword-wyoming:
image: rhasspy/wyoming-openwakeword
restart: unless-stopped
command: ["--custom-model-dir", "/usr/src/.venv/lib/python3.11/site-packages/pyopen_wakeword/models"]
ports:
- "10400:10400"
# Neo4j Community Edition - Graphiti's graph storage backend for cross-conversation memory.
neo4j:
image: neo4j:5.26.0
restart: unless-stopped
environment:
NEO4J_AUTH: "neo4j/${NEO4J_PASSWORD:-change-me-generate-a-real-password}"
NEO4J_server_memory_heap_initial__size: 512m
NEO4J_server_memory_heap_max__size: 1G
NEO4J_server_memory_pagecache_size: 512m
volumes:
- neo4j-data:/data
- neo4j-logs:/logs
healthcheck:
test: ["CMD", "wget", "-O", "/dev/null", "http://localhost:7474"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# Graphiti's own official MCP server. Once running, add it as an MCP server from Friday's own
# McpServers page (Url: http://graphiti-proxy:8000/mcp/ if Friday's on the same compose
# network, or the host's published address otherwise) - Friday then gets its memory
# search/recall tools automatically. The actual LLM/embedding server used for extraction is
# NOT set here - configure it on Friday's own Settings page; this always talks through Friday's
# /llm-proxy/* endpoints, since this image can't read Friday's database directly.
#
# Its own baked-in Neo4j config preset (CONFIG_PATH pointed at the image's own
# config-docker-neo4j.yaml) was tried and does NOT work - verified live it silently keeps using
# FalkorDB regardless. The `graphiti_config` file below (mounted over its default config path)
# is required, not optional.
graphiti-mcp:
image: zepai/knowledge-graph-mcp:standalone
restart: unless-stopped
depends_on:
neo4j:
condition: service_healthy
web:
condition: service_started
environment:
NEO4J_URI: "bolt://neo4j:7687"
NEO4J_USER: "neo4j"
NEO4J_PASSWORD: "${NEO4J_PASSWORD:-change-me-generate-a-real-password}"
# A small/fast model - keep this in sync with whichever model Friday's own Tool-Calling
# Model setting points at, since this does the same kind of role (structured extraction).
MODEL_NAME: ${GRAPHITI_MODEL_NAME:-qwen3:1.7b}
OPENAI_API_KEY: "none"
OPENAI_BASE_URL: "http://web:8080/llm-proxy/llm/v1"
configs:
- source: graphiti_config
target: /app/mcp/config/config.yaml
# Works around a hardcoded Host-header check in the Graphiti MCP server that rejects any
# hostname other than localhost/127.0.0.1 - see the nginx_proxy_conf config below.
graphiti-proxy:
image: nginx:alpine
restart: unless-stopped
depends_on:
- graphiti-mcp
configs:
- source: nginx_proxy_conf
target: /etc/nginx/conf.d/default.conf
ports:
- "8020:8000"
# Bridges RTSP camera feeds to a browser-playable stream for the Dashboard's Camera widget.
# Streams are registered dynamically through Friday's own UI. Must be reachable directly by the
# browser (not proxied through `web`) - see Go2Rtc__PublicUrl below. api.origin:"*" is needed
# because go2rtc rejects cross-origin requests by default, and Friday's page is served from a
# different origin - passed inline since go2rtc accepts a config string as well as a file.
go2rtc:
image: alexxit/go2rtc:latest
restart: unless-stopped
command: ["go2rtc", "-c", "{\"api\":{\"origin\":\"*\"},\"streams\":{}}"]
ports:
- "1984:1984"
volumes:
- go2rtc-data:/config
# Single-node self-hosted setup: host networking sidesteps mapping LiveKit's WebRTC UDP port
# range through Docker's bridge network (LiveKit's own recommendation for this case). That also
# means the `web` container can't reach it via a service name on the compose network - see
# LiveKit__ServerApiUrl below. LIVEKIT_KEYS must be "key: secret" (with that exact spacing) and
# the secret needs to be 32+ characters or the server refuses to start.
livekit:
image: livekit/livekit-server:latest
restart: unless-stopped
network_mode: host
environment:
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-changeme-key}: ${LIVEKIT_API_SECRET:-changeme-generate-a-real-32-char-plus-secret}"
LIVEKIT_RTC_PORT_RANGE_START: 50000
LIVEKIT_RTC_PORT_RANGE_END: 50100
LIVEKIT_RTC_USE_EXTERNAL_IP: "false"
# OmniVoice (k2-fsa/OmniVoice, wrapped by diogod2r/omnivoice-fastapi) - the only TTS engine
# bundled here. Any other engine (Piper, Qwen3-TTS, Chatterbox, IndexTTS, etc.) is not included -
# stand one up yourself and point TextToSpeech__Url at it instead. Needs an NVIDIA GPU - remove
# this service (and leave TextToSpeech__Url blank below) if you don't have one.
omnivoice-tts:
image: diogod2r/omnivoice-fastapi:latest
restart: unless-stopped
ports:
- "5007:8880"
volumes:
- omnivoice-models:/app/models
environment:
DEVICE: cuda:0
MODEL_ID: k2-fsa/OmniVoice
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
web:
image: ${FRIDAY_IMAGE:-yourdockerhubname/friday-web:latest}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
TZ: ${TZ:-UTC}
ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=friday_db;Username=admin;Password=${POSTGRES_PASSWORD:-change-me-generate-a-real-password}"
# Persisted outside the container filesystem, or every container recreation invalidates
# every login session - see the volume mount below.
DataProtection__KeysPath: /keys
# --- Bundled above ---
TextToSpeech__Url: "http://omnivoice-tts:8880"
SpeechToText__Url: "http://whisper:9000"
FaceRecognition__Url: "http://facerecognition:5000"
Neo4j__Uri: "bolt://neo4j:7687"
Neo4j__User: "neo4j"
Neo4j__Password: "${NEO4J_PASSWORD:-change-me-generate-a-real-password}"
# Go2Rtc__Url is what the backend uses to register streams (compose service name);
# Go2Rtc__PublicUrl is handed to the browser directly, so it needs YOUR server's real,
# externally-reachable address - "localhost" only works if you're browsing from the same
# machine this is deployed on.
Go2Rtc__Url: "http://go2rtc:1984"
Go2Rtc__PublicUrl: ${GO2RTC_PUBLIC_URL:-http://localhost:1984}
# Same split as Go2Rtc above, for the same reason - LiveKit__Url goes to the browser,
# LiveKit__ServerApiUrl is what this container uses. Must match the livekit service's
# LIVEKIT_KEYS above (same LIVEKIT_API_KEY/LIVEKIT_API_SECRET vars, single source of truth).
LiveKit__Url: ${LIVEKIT_URL:-ws://localhost:7880}
LiveKit__ServerApiUrl: "http://host.docker.internal:7880"
LiveKit__ApiKey: ${LIVEKIT_API_KEY:-changeme-key}
LiveKit__ApiSecret: ${LIVEKIT_API_SECRET:-changeme-generate-a-real-32-char-plus-secret}
# --- Not bundled - each needs a custom-built image not published anywhere, so it's only
# available if you build a compatible replacement yourself. Leave blank to leave off. ---
SpeakerEmbedding__Url: ${SPEAKER_EMBEDDING_URL:-}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- dataprotection-keys:/keys
ports:
- "${WEB_PORT:-8080}:8080"
volumes:
postgres-data:
dataprotection-keys:
omnivoice-models:
neo4j-data:
neo4j-logs:
go2rtc-data:
configs:
# Graphiti MCP server config - both api_url fields point at Friday's own web container, not at
# an LLM host directly. graphiti-mcp is a separate prebuilt image with no way to read Friday's
# database, so it always talks to a fixed proxy endpoint inside Friday's web app, which forwards
# each request to whatever LLM/embedding server you've configured on Friday's own Settings page.
# Change your real LLM/embedding server there, not here.
graphiti_config:
content: |
server:
transport: "http"
host: "0.0.0.0"
port: 8000
llm:
provider: "openai"
model: $${MODEL_NAME:qwen3.5:latest}
max_tokens: 4096
providers:
openai:
api_key: $${OPENAI_API_KEY:none}
api_url: "http://web:8080/llm-proxy/llm/v1"
embedder:
provider: "openai"
model: $${EMBEDDER_MODEL:nomic-embed-text}
dimensions: $${EMBEDDER_DIMENSIONS:768}
providers:
openai:
api_key: $${OPENAI_API_KEY:none}
api_url: "http://web:8080/llm-proxy/embedding/v1"
database:
provider: "neo4j"
providers:
neo4j:
uri: $${NEO4J_URI:bolt://neo4j:7687}
username: $${NEO4J_USER:neo4j}
password: $${NEO4J_PASSWORD:change-me}
database: $${NEO4J_DATABASE:neo4j}
graphiti:
group_id: $${GRAPHITI_GROUP_ID:friday}
user_id: $${USER_ID:friday}
entity_types:
- name: "Preference"
description: "User preferences, choices, opinions, or selections (PRIORITIZE over most other types except User/Assistant)"
- name: "Requirement"
description: "Specific needs, features, or functionality that must be fulfilled"
- name: "Procedure"
description: "Standard operating procedures and sequential instructions"
- name: "Location"
description: "Physical or virtual places where activities occur"
- name: "Event"
description: "Time-bound activities, occurrences, or experiences"
- name: "Organization"
description: "Companies, institutions, groups, or formal entities"
- name: "Document"
description: "Information content in various forms (books, articles, reports, etc.)"
- name: "Topic"
description: "Subject of conversation, interest, or knowledge domain (use as last resort)"
- name: "Object"
description: "Physical items, tools, devices, or possessions (use as last resort)"
# The Graphiti MCP server has a hardcoded DNS-rebinding check that only trusts a Host header of
# "localhost"/"127.0.0.1"/"::1" - it's not exposed as a config option. Reaching it by its Docker
# service name from the web container fails that check with a 421. This proxy sits in between
# purely to rewrite the Host header back to something the check accepts.
nginx_proxy_conf:
content: |
server {
listen 8000;
location / {
proxy_pass http://graphiti-mcp:8000;
proxy_set_header Host "localhost:8000";
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_redirect http://localhost:8000/ http://graphiti-proxy:8000/;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
}
}
docker compose up -dhttp://<your-server>:8080 and register an account - the first account created has no
special setup, registration is open by default. There's no email server configured, so the
confirmation link is shown directly on the page instead of being emailed - click it, then log in.http://graphiti-proxy:8000/mcp/)
for cross-conversation memory, and point the wake-word engine (Settings page) at
ws://wakeword-wyoming:10400 if you're using voice.Content type
Image
Digest
sha256:61a58fae1…
Size
104.5 MB
Last updated
about 14 hours ago
docker pull lanedfritz/friday