INSTALL

Running in one command.

Free, self-hosted, no signup. You will have a working control plane before you have decided whether you like it.

quickstart
cp .env.example .env && docker compose up -d

Three values to fill in first. The file tells you the commands to generate them.

PREREQUISITES

Docker + Compose v2

Docker Engine with the compose plugin. No source checkout, no Node, no toolchain.

4 GB RAM

For the control plane itself. Jobs run on your servers, not here, so it stays flat as the fleet grows.

A Linux host

Any distro. It needs outbound SSH to the machines you intend to manage, and nothing inbound but your browser.

WHAT YOU ARE ABOUT TO RUN

Four public images. No build step.

Two of ours and two upstream. Nothing is compiled on your machine, there is no registry login, and you can inspect any of them before you start.

talosfleet/talos-backend
API, job queue and the Sentinel worker
on Docker Hub ↗
talosfleet/talos-frontend
The console, and the /api proxy to the backend
on Docker Hub ↗

plus pgvector/pgvector:pg16 · redis:7-alpine — unmodified upstream

inspect before you run anything
# what is actually in the image you are about to start
docker pull talosfleet/talos-backend:latest
docker image inspect talosfleet/talos-backend:latest

THE COMPOSE FILE

All 133 lines of it.

Copy this into docker-compose.yml. It is the whole deployment — there is no second file and nothing hidden behind an installer script. This is the same file served at /install-kit/docker-compose.yml if you would rather curl it.

docker-compose.yml
# Talos — self-hosted deployment
#
# This is the file we ship to customers. It runs PRE-BUILT images from a registry, so
# there is no source checkout, no build step, and no toolchain to install.
#
# 1. cp .env.example .env
# 2. follow the three instructions in .env
# 3. docker compose up -d
# 4. open http://localhost:3000
#
# Deliberately NOT the repository's root docker-compose.yml. That one builds from source
# and exposes every SaaS, billing and platform knob — handing it to a customer is handing
# them ninety lines of configuration to be confused by. This file has three variables.
#
# DEPLOYMENT_MODE is absent on purpose: it is baked into the image
# (TALOS_DISTRIBUTION=self-hosted), which also pins the mode and makes weak secrets fatal
# rather than a warning. See docs/deployment/MODES_DATABASES_AND_DISTRIBUTION.md.
 
services:
postgres:
# pgvector, not plain postgres:16 — the AI knowledge base needs the `vector`
# extension. Drop-in replacement, same data directory.
image: pgvector/pgvector:pg16
restart: unless-stopped
environment:
POSTGRES_DB: talos
POSTGRES_USER: talos
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes:
- talos_postgres:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U talos -d talos']
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
 
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- talos_redis:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 5s
retries: 5
 
backend:
image: ${TALOS_REGISTRY:-talosfleet}/talos-backend:${TALOS_VERSION:-latest}
restart: unless-stopped
ports:
- '${BACKEND_PORT:-3001}:3001'
environment:
DATABASE_URL: postgresql://talos:${POSTGRES_PASSWORD}@postgres:5432/talos?schema=public
# `redis`, not `localhost` — inside the compose network the hostname is the service
# name. A stale `localhost` here makes the backend hang at boot and login fail with
# an empty response, which is not an obvious symptom.
REDIS_URL: redis://redis:6379
 
# The only two secrets. The backend REFUSES TO BOOT if either is missing or is the
# published default — see .env for why, and for how to generate them.
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env}
 
# Public URL of the frontend, used in the links inside emails.
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
 
# ── Optional, all defaulted ──
# Every one of these must be listed here, not just documented in .env: an unlisted
# variable is silently dropped by Compose, so a customer would set it and nothing
# would happen — a failure with no error message anywhere.
ALLOW_SELF_REGISTRATION: ${ALLOW_SELF_REGISTRATION:-false}
ALLOW_MULTI_ORG: ${ALLOW_MULTI_ORG:-false}
 
# Sign-in with Google / GitHub. The backend refuses to boot if a provider is
# enabled without credentials, rather than showing a button that cannot work.
OAUTH_CALLBACK_BASE_URL: ${OAUTH_CALLBACK_BASE_URL:-}
OAUTH_GOOGLE_ENABLED: ${OAUTH_GOOGLE_ENABLED:-false}
OAUTH_GOOGLE_CLIENT_ID: ${OAUTH_GOOGLE_CLIENT_ID:-}
OAUTH_GOOGLE_CLIENT_SECRET: ${OAUTH_GOOGLE_CLIENT_SECRET:-}
OAUTH_GITHUB_ENABLED: ${OAUTH_GITHUB_ENABLED:-false}
OAUTH_GITHUB_CLIENT_ID: ${OAUTH_GITHUB_CLIENT_ID:-}
OAUTH_GITHUB_CLIENT_SECRET: ${OAUTH_GITHUB_CLIENT_SECRET:-}
 
# Outbound email. Defaults to `log`, which writes invitation and reset links to
# `docker compose logs backend` — workable for one admin, awkward for a team.
MAIL_TRANSPORT: ${MAIL_TRANSPORT:-log}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
MAIL_FROM: ${MAIL_FROM:-}
volumes:
# Terraform workspaces and generated state. Persisted because losing a state file
# orphans real cloud resources that Terraform can then neither see nor destroy.
- talos_terraform:/app/infra/terraform/workspaces
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
healthcheck:
test: ['CMD', 'wget', '-qO-', 'http://localhost:3001/api/health']
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
 
frontend:
image: ${TALOS_REGISTRY:-talosfleet}/talos-frontend:${TALOS_VERSION:-latest}
restart: unless-stopped
ports:
- '${FRONTEND_PORT:-3000}:3000'
environment:
# There is deliberately no API URL to set here.
#
# Next.js compiles NEXT_PUBLIC_* into the client bundle at BUILD time, so a prebuilt
# image cannot be told its API address at run time — this block used to pass
# NEXT_PUBLIC_API_URL and it silently did nothing, leaving every install that was not
# reached at localhost with a UI that could not talk to its own API.
#
# The browser now calls this container on a relative /api, and the Next server
# forwards to the backend below. So Talos works at whatever hostname you put in front
# of it, with nothing to configure and no CORS to get wrong.
BACKEND_INTERNAL_URL: http://backend:3001
depends_on:
- backend
 
volumes:
talos_postgres:
talos_redis:
talos_terraform:

THE ENVIRONMENT FILE

Three values you must set.

Copy this to .env beside the compose file and fill in the first three. Everything below them has a working default and can stay commented out.

.env
# Talos — self-hosted configuration
#
# cp .env.example .env
#
# Three values. Everything else has a working default.
 
 
# ─── 1. Database password ─────────────────────────────────────────────────────
# Any strong value. Used only inside the Docker network, never exposed.
#
# openssl rand -base64 24
#
POSTGRES_PASSWORD=
 
 
# ─── 2 & 3. The two secrets ───────────────────────────────────────────────────
# Talos REFUSES TO START if either is missing or left at a default. That is
# deliberate: both fallbacks are published in a public repository, so an install using
# them has forgeable login tokens and every stored credential — SSH keys, cloud keys,
# database passwords — encrypted under a value anyone can look up.
#
# Generate each ONCE:
#
# openssl rand -base64 32 # JWT_SECRET
# openssl rand -base64 24 # ENCRYPTION_KEY
#
# On Windows PowerShell, if you have no openssl:
# [Convert]::ToBase64String((1..32 | % { Get-Random -Max 256 }))
#
JWT_SECRET=
ENCRYPTION_KEY=
 
# ⚠ BACK UP ENCRYPTION_KEY WITH YOUR DATABASE. ⚠
#
# It cannot be changed once data exists. Every SSH key, cloud credential and database
# password is encrypted with it, and there is no recovery path — a database backup
# without this key is unreadable. Losing it means re-entering every credential by hand.
# Losing it *and* the credentials means starting over.
 
 
# ─── Optional ─────────────────────────────────────────────────────────────────
 
# Where users reach Talos in a browser. Used for the links inside invitation and
# password-reset emails, so set it if you are not on localhost — otherwise you will send
# people a link to their own machine.
#
# There is no API URL to configure: the browser talks to the frontend on a relative /api
# and the frontend forwards to the backend inside the Docker network. Talos therefore works
# at whatever hostname you put in front of it. (An earlier version of this file had a
# PUBLIC_API_URL here; it never had any effect, because that value is compiled into the
# frontend at build time and cannot be supplied to a prebuilt image at run time.)
# FRONTEND_URL=http://localhost:3000
 
# Host ports, if 3000/3001 are taken.
# FRONTEND_PORT=3000
# BACKEND_PORT=3001
 
# Pin a version instead of tracking `latest`. Recommended for production: an
# unattended `latest` means an upgrade whenever you happen to pull.
# TALOS_VERSION=latest
 
# Sign-in with Google or GitHub. Off unless configured; users sign in with a password.
# Register the app first and set the callback to
# <your API URL>/auth/oauth/<provider>/callback
# exactly — a mismatch is the most common cause of a flow that fails with no useful
# message. See docs/oauth-social-login/PLAN.md §8.
# OAUTH_CALLBACK_BASE_URL=http://localhost:3001
# OAUTH_GOOGLE_ENABLED=false
# OAUTH_GOOGLE_CLIENT_ID=
# OAUTH_GOOGLE_CLIENT_SECRET=
# OAUTH_GITHUB_ENABLED=false
# OAUTH_GITHUB_CLIENT_ID=
# OAUTH_GITHUB_CLIENT_SECRET=
 
# Outbound email, for invitations and password resets. Without it those links are
# written to the container log instead (`docker compose logs backend`), which works for
# a single-admin install but is awkward for a team.
# MAIL_TRANSPORT=smtp
# SMTP_HOST=
# SMTP_PORT=587
# SMTP_USER=
# SMTP_PASSWORD=
# MAIL_FROM=talos@yourcompany.com
 
# By default one organization, and no self-service signup — you create accounts by
# invitation. Turn these on to run several teams on one install.
# ALLOW_SELF_REGISTRATION=false
# ALLOW_MULTI_ORG=false
generate the three secrets
openssl rand -base64 24 # POSTGRES_PASSWORD
openssl rand -base64 32 # JWT_SECRET
openssl rand -base64 24 # ENCRYPTION_KEY

START IT

One command.

run
docker compose up -d
what a good first boot looks like
docker compose logs -f backend
 
✓ postgres healthy
✓ redis healthy
✓ backend [Mode] SELF_HOSTED (self-hosted)
✓ frontend listening
→ open http://localhost:3000 and complete first-run setup

The first start takes a minute or two: it applies the database schema and loads the permission catalogue before the API accepts traffic. You are ready when you see the mode line.

OTHER WAYS THROUGH IT

Production, and getting your data back out.

Production
# pin a version — an unattended `latest` upgrades whenever you pull
TALOS_VERSION=1.0.0
 
# tell Talos where users actually reach it, or invitation and
# password-reset emails will link people to their own machine
FRONTEND_URL=https://talos.example.com
 
docker compose up -d
 
✓ migrations applied
✓ permission catalogue loaded
·Terminate TLS at your own proxy and point FRONTEND_URL at the public hostname.
·Host ports are configurable with FRONTEND_PORT and BACKEND_PORT if 3000/3001 are taken.
·Take a database dump before every upgrade. Migrations are idempotent and safe to repeat, but a backup costs nothing.

YOUR FIRST FIVE MINUTES

From empty console to a streaming job.

01

Complete first-run setup

Open the frontend and create your administrator account and organization. Everything — servers, keys, jobs, audit — is scoped to that organization.

→ http://localhost:3000/setup
organization: acme-ops
✓ created · permission catalogue loaded
02

Add a server

Hostname, port, user. No agent to install — Talos connects over plain SSH.

host: 10.0.4.21 user: ops port: 22
→ testing reachability …
✓ reachable
03

Add an SSH key

Pasted once and encrypted at rest under your ENCRYPTION_KEY. It is decrypted in memory for the duration of a job and never written to a log line or a job payload.

key: talos_ed25519 (ed25519)
✓ encrypted at rest
✓ fingerprint SHA256:9tK…c1
04

Run discovery

Read-only inventory: OS, kernel, runtimes, running containers, database engines. It changes nothing on the host.

$ discover 10.0.4.21
→ ubuntu 24.04 · kernel 6.8.0
→ docker 27.3 · 6 containers
✓ discovery complete
05

Watch the first job stream

Every line arrives live. What you watch is exactly what lands in the audit ledger.

$ jobs follow 1
→ [00:00.2] connecting …
→ [00:03.1] gathering facts
✓ [00:08.4] job #1 succeeded

ENVIRONMENT VARIABLES

What you must set.

VARIABLEREQUIRED?WHAT IT DOES
POSTGRES_PASSWORDrequiredAny strong value. Used only inside the Docker network and never exposed.
JWT_SECRETrequiredSigns session tokens. The fallback is published in a public repository, so an install using it has forgeable logins.
ENCRYPTION_KEYrequiredEncrypts every stored credential. Cannot be changed once data exists — there is no recovery path.
FRONTEND_URLoptionalWhere users reach Talos in a browser. Used for links inside invitation and password-reset emails. Set it if you are not on localhost.
FRONTEND_PORT / BACKEND_PORToptionalHost ports, if 3000 and 3001 are already taken.
TALOS_VERSIONoptionalPin an image version instead of tracking latest. Recommended for production.
generate the three secrets
openssl rand -base64 24 # POSTGRES_PASSWORD
openssl rand -base64 32 # JWT_SECRET
openssl rand -base64 24 # ENCRYPTION_KEY
 
# Windows PowerShell, without openssl:
[Convert]::ToBase64String((1..32 | % { Get-Random -Max 256 }))

THIS IS DELIBERATE

Talos refuses to start with missing or default secrets. There is no fallback value and no warning-and-continue path. The check runs before anything opens a network connection, so a refused boot has written nothing. An instance holding your root SSH keys should never come up with a key an attacker can read out of a public repository.

BACK UP ENCRYPTION_KEY WITH YOUR DATABASE

It cannot be changed once data exists. Every SSH key, cloud credential and database password is encrypted with it, and there is no recovery path — a database backup without this key is unreadable.

UPGRADING

Pull, restart, done.

Migrations run automatically at startup and are idempotent, so an upgrade is safe to repeat and safe to interrupt. Take a database dump first anyway — and in production, pin TALOS_VERSION rather than tracking latest, or you upgrade whenever you happen to pull.

upgrade
docker compose exec postgres pg_dump -U talos talos > talos-$(date +%F).sql
docker compose pull && docker compose up -d
✓ migrations applied

TROUBLESHOOTING

When it does not come up.

The backend exits immediately with "Refusing to start".
JWT_SECRET or ENCRYPTION_KEY is missing, or still set to the published default. The check runs before anything opens a network connection, so nothing has been written yet — generate both with the openssl commands above and start again. This is deliberate: an instance holding root SSH keys should never come up with a signing key anyone can look up in a public repository.
I lost ENCRYPTION_KEY. Can I reset it?
No. Every SSH key, cloud credential and database password is encrypted with it and there is no recovery path — not for you and not for us. You would re-enter every credential by hand against a fresh install. This is the single thing on this page worth acting on before you have any data: back the key up somewhere separate from the database dump.
The UI loads but jobs never leave the queue.
The backend both produces and consumes the job queue in-process, so a stuck queue usually means it cannot reach Redis. Check REDIS_URL points at the compose service name rather than localhost — a stale localhost value makes the backend hang at boot, which shows up in a browser as a network error rather than an obvious failure.
Discovery fails with "permission denied (publickey)".
The private key in Talos does not match a public key in the target host’s authorized_keys, or the SSH user is wrong. Test the same key from your own shell first: ssh -i ~/.ssh/talos_ed25519 ops@host. If that fails, Talos will too.
Logs stream for a few seconds then stop.
Almost always a reverse proxy buffering the response. In nginx, set proxy_buffering off and a long proxy_read_timeout for the streaming path. The job itself is still running — only the stream died.

It's running. Now add your fleet.

Discovery is read-only, so the first server you connect cannot be broken by connecting it.

Read the security model