# Run with Docker

This page takes you from an empty Docker host to a running backup and a viewer you can sign in to. It also explains what the stock `docker-compose.yml` does.

On Unraid, install both containers from Community Applications instead; [Run on Unraid](<https://geiserx.github.io/Telegram-Archive/getting-started/unraid/index.md>) is that path.

## Before you start

You need:

- A Linux host with Docker Engine and a recent Docker Compose.
- An amd64 or arm64 machine. Both images ship for both architectures.
- A Telegram account and the phone that receives its login codes.
- An API id and API hash for that account. Open [my.telegram.org/apps](<https://my.telegram.org/apps>), sign in with your phone number, open **API development tools** and create an app. Copy `api_id` and `api_hash`. The id is a number.

The compose file uses the long `env_file` syntax with `path` and `required: false`. Older Compose releases reject it. If `docker compose up` complains about `env_file`, update Compose.

### Disk space

Nothing in the archive deletes data on its own, so the data directory only grows. Plan for:

- Media takes the most space: every photo, video, voice note, sticker and document from every chat you back up, up to `MAX_MEDIA_SIZE_MB`. The default limit is 100 MB. With the default `DEDUPLICATE_MEDIA=true`, a file shared by several chats of one account is stored once.
- The database grows with every message. The archive keeps the earlier text of every edited message. By default it also keeps deleted messages and only marks them as deleted.
- Thumbnails in `media/.thumbs` are a cache. You can delete them, and the viewer rebuilds them.
- An import copies the export's media into the archive, so it needs that much free space again.
- The upgrade from 7.x needs free space of three times the database file plus its `-wal` file.

To limit growth, use `MAX_MEDIA_SIZE_MB`, `DOWNLOAD_MEDIA_TYPES`, `DOWNLOAD_DOCUMENT_MIME_TYPES`, `SKIP_MEDIA_CHAT_IDS` and the chat filters. Read [Media downloads](<https://geiserx.github.io/Telegram-Archive/configuration/media/index.md>) before the first large run, because some of these skip files for good.

To see how much space you use, pick one:

- In the viewer, signed in with the master login, open the main menu, then Statistics or Archive status.
- Run `docker compose exec telegram-backup python -m telegram_archive stats`.
- Run `du -sh data/backups/media` on the host.

## Set it up

### 1\. Get the files

```
git clone https://github.com/GeiserX/Telegram-Archive.git
cd Telegram-Archive
```

The compose file pins both images, `drumsergio/telegram-archive` and `drumsergio/telegram-archive-viewer`, to the current release. Moving that pin is how you upgrade later.

### 2\. Write your settings

```
cp .env.example .env
```

Open `.env` and set these lines:

```
TELEGRAM_API_ID=12345678
TELEGRAM_API_HASH=0123456789abcdef0123456789abcdef
TELEGRAM_PHONE=+15551234567
VIEWER_USERNAME=admin
VIEWER_PASSWORD=choose-a-long-password
VIEWER_TIMEZONE=Europe/London
```

`TELEGRAM_PHONE` is in international format with the leading `+`. `VIEWER_TIMEZONE` takes a tz database name. Set it, because the code default is `Europe/Madrid`. Without `VIEWER_USERNAME` and `VIEWER_PASSWORD`, every viewer data route answers 503 `Viewer authentication is not configured`. See [The viewer starts closed](<https://geiserx.github.io/Telegram-Archive/viewer/access/#the-viewer-starts-closed>).

Two defaults to know before the first run:

- Bot chats are not backed up. Add `bots` to `CHAT_TYPES` if you want them. See [Choosing chats](<https://geiserx.github.io/Telegram-Archive/configuration/choosing-chats/index.md>).
- The backup skips media files over 100 MB. Set `MAX_MEDIA_SIZE_MB=0` to remove the limit.

### 3\. Create the data directory

```
mkdir -p data && sudo chown -R 1000:1000 data
```

Both containers run as uid 1000 and write the session, database and media under `./data`. The directory must belong to that uid. `chmod 755` does not fix a `Permission denied` here. On Podman, add `--userns=keep-id:uid=1000,gid=1000` to your run commands instead. On macOS with Colima, skip the `chown`: Colima shares the folder as your own user, so the containers can already write it, and a `chown` to 1000 makes it unwritable.

### 4\. Log in to Telegram

```
docker compose run --rm telegram-backup python -m telegram_archive auth
```

The login code arrives in your Telegram app. If it never does, run the same command with `--qr` and scan the code from your phone. See [The code never arrives](<https://geiserx.github.io/Telegram-Archive/getting-started/telegram-login/#the-code-never-arrives>). If the account has two-step verification, the command then asks for that password. It does not show the password as you type it. The session is saved under `data/session`. Treat that file like a password: it gives full access to your account. Everything else about login is in [Log in to Telegram](<https://geiserx.github.io/Telegram-Archive/getting-started/telegram-login/index.md>).

### 5\. Start both containers

```
docker compose up -d
```

`telegram-backup` migrates the database, runs a full backup straight away and starts the [real-time listener](<https://geiserx.github.io/Telegram-Archive/configuration/listener/index.md>), which saves new messages as they arrive. After that it runs a full pass on `SCHEDULE`, once a day at 03:00 by default. The scheduler reads the cron expression in the container's local time. That is UTC, because the image sets no `TZ`. `telegram-viewer` serves the web viewer. Watch the first backup with `docker compose logs -f telegram-backup`.

### 6\. Open the viewer

Open [http://127.0.0.1:8000](<http://127.0.0.1:8000>) on the Docker host and sign in with `VIEWER_USERNAME` and `VIEWER_PASSWORD`.

![The viewer's sign-in card, with a username field, a password field and a Sign in button](<https://geiserx.github.io/Telegram-Archive/images/screenshots/login.png>)

The compose file publishes the viewer on 127.0.0.1 only. From another machine, use an SSH tunnel and open the same address locally:

```
ssh -L 8000:127.0.0.1:8000 you@server
```

For access without a tunnel, put a reverse proxy in front. See [Exposing the viewer safely](<https://geiserx.github.io/Telegram-Archive/viewer/exposing/index.md>).

After the first backup the viewer shows your chats:

![The viewer's chat list after a backup, with folder tabs, private chats, groups, a forum and a channel](<https://geiserx.github.io/Telegram-Archive/images/screenshots/chat-list-desktop.png>)

### Next

- [Your first backup](<https://geiserx.github.io/Telegram-Archive/getting-started/first-backup/index.md>): what the first run does and how to follow it.
- [Choosing chats](<https://geiserx.github.io/Telegram-Archive/configuration/choosing-chats/index.md>): back up only the chats you want.
- [Real-time listener](<https://geiserx.github.io/Telegram-Archive/configuration/listener/index.md>): capture new messages, edits and deletions as they happen.

## What the stock compose file does

The file runs two services, `telegram-backup` and `telegram-viewer`, both pinned to the same version. It also holds three optional services, all commented out. Those are described at the end of this section.

### The telegram-backup service

It runs the Telegram client and the scheduler with `python -m telegram_archive schedule`.

It loads the whole `.env` through `env_file`, so every variable you put there reaches this container. It also has its own `environment:` block, and that block wins on conflict:

- `BACKUP_PATH` is hard-set to `/data/backups` in both services. `BACKUP_PATH` in `.env` does nothing.
- `CHAT_TYPES` is passed as `${CHAT_TYPES:-private,groups,channels}`. An empty `CHAT_TYPES=` in `.env` becomes that default.

The block also sets some defaults that differ from the code defaults:

| Variable | Compose default | Why |
| --- | --- | --- |
| `VIEWER_HOST` | `telegram-viewer` | Real-time updates on SQLite are pushed to the viewer container by name. |
| `VIEWER_PORT` | `8000` | The port the viewer listens on. |
| `POSTGRES_HOST` | `postgres` | The name of the optional PostgreSQL service. |

Compose also sets `DB_PATH` to `/data/backups/telegram_backup.db`. That is the image default, and setting it in both services keeps them on the same file.

### The telegram-viewer service

It serves the web viewer. It has no `env_file` on purpose, so your Telegram credentials and proxy passwords never enter the container that faces the network. It receives only the variables listed in its own `environment:` block:

| Area | Variables |
| --- | --- |
| Paths | `BACKUP_PATH` |
| Sign-in | `VIEWER_USERNAME`, `VIEWER_PASSWORD`, `ALLOW_ANONYMOUS_VIEWER`, `AUTH_SESSION_DAYS` |
| Display | `VIEWER_TIMEZONE`, `VIEWER_DEFAULT_THEME`, `VIEWER_CHAT_BACKGROUND`, `SHOW_STATS`, `DISPLAY_CHAT_IDS` |
| Security | `CORS_ORIGINS`, `SECURE_COOKIES`, `TRUST_PROXY_HEADERS`, `INTERNAL_PUSH_SECRET`, `AUTH_PROXY_HEADER`, `AUTH_PROXY_ADMIN_USERS`, `AUTH_PROXY_DEFAULT_ACCESS`, `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI`, `OIDC_SCOPES`, `OIDC_USERNAME_CLAIM`, `OIDC_ADMIN_USERS`, `OIDC_USER_MAP`, `OIDC_DEFAULT_ACCESS` |
| Notifications | `PUSH_NOTIFICATIONS`, `VAPID_PRIVATE_KEY`, `VAPID_PUBLIC_KEY`, `VAPID_CONTACT` |
| Database | `DATABASE_URL`, `DB_TYPE`, `DB_PATH`, `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` |
| Transcription | `TRANSCRIPTION_ENABLED`, `TRANSCRIPTION_URL` |
| Logging | `LOG_LEVEL` |

The viewer receives `LOG_LEVEL` but always logs at INFO. The variable only affects the backup container.

The viewer reads more variables than that. These do nothing from `.env` until you add them to the viewer's `environment:` block:

- `STATS_CALCULATION_HOUR`
- `THUMBNAIL_CACHE_DIR`
- `DATABASE_PATH`
- `DATABASE_DIR`
- `DATABASE_TIMEOUT`
- `DB_ECHO`
- `MAX_WS_CONNECTIONS`
- `MAX_WS_SUBSCRIPTIONS_PER_CONNECTION`
- `ENABLE_NOTIFICATIONS`
- `MEDIA_MAX_DOWNLOAD_ATTEMPTS`
- `TRANSCRIPTION_WEBHOOK_SECRET`: already in the block, commented out.
- `HEALTHCHECK_URL`: the image's healthcheck reads it. Set it only if you change the viewer's port.

To add one, put it under `telegram-viewer` like this:

```
  telegram-viewer:
    environment:
      STATS_CALCULATION_HOUR: ${STATS_CALCULATION_HOUR:-3}
```

> [!WARNING]
>
> **Keep the database settings the same in both services**
>
> `DATABASE_PATH` or `DATABASE_DIR` in `.env` moves the backup's SQLite file but not the viewer's, because the viewer never sees them. The viewer then opens a different, empty database and shows no chats. Add the same variable to both services, or use `DB_PATH`, which both already receive.

### Shared settings

Both services share the same hardening:

- a read-only root filesystem, with a tmpfs at `/tmp`
- all Linux capabilities dropped, and `no-new-privileges`
- `stop_grace_period: 90s`, so a backup that is stopped mid-run has time to finish its writes
- `json-file` logs capped at 10 MB, three files

`telegram-backup` also has resource limits of 1 CPU and 1 GB, present but commented out.

Both mount `./data:/data` read-write. On SQLite the viewer's mount must stay writable, for the database's WAL files, the shared `.push-secret` file and the thumbnail cache. Mount it `:ro` only when you use PostgreSQL.

The two services start in any order. The backup owns the schema and migrates it on start. The viewer never migrates.

> [!WARNING]
>
> **Upgrading from 7.x**
>
> Migration 022 rebuilds every table and refuses to run while another process holds the SQLite file. It exits without changing anything, and the container keeps restarting until you stop the viewer. Stop both containers, then start the backup first. See [Upgrading from 7.x](<https://geiserx.github.io/Telegram-Archive/operations/upgrading/#upgrading-from-7x>).

To apply a change, edit `.env` and run `docker compose up -d` again. Compose recreates only the containers whose settings changed.

### Optional services

The three commented-out services are:

- A PostgreSQL server. See [SQLite and PostgreSQL](<https://geiserx.github.io/Telegram-Archive/configuration/database/index.md>).
- An akou server for voice transcription. See [Voice transcription](<https://geiserx.github.io/Telegram-Archive/configuration/transcription/index.md>).
- A second viewer limited to a few chats with `DISPLAY_CHAT_IDS`. See [Logins, viewer accounts and share links](<https://geiserx.github.io/Telegram-Archive/viewer/access/index.md>).

Enabling PostgreSQL also means uncommenting the `volumes: postgres_data:` block at the end of the file.

> [!NOTE]
>
> **The stock docker-compose.yml**
>
> ```
> services:
>   telegram-backup:
>     image: drumsergio/telegram-archive:9.8.0
>     container_name: telegram-backup
>     restart: unless-stopped
>     # A stop during a long sweep needs time to finish in-flight writes and
>     # disconnect cleanly; without this docker waits 10s then SIGKILLs.
>     stop_grace_period: 90s
>     # Unattended deployments must not fill the host disk with container logs.
>     logging:
>       driver: json-file
>       options:
>         max-size: "10m"
>         max-file: "3"
>     command: ["python", "-m", "telegram_archive", "schedule"]
>
>     # Every variable documented in .env.example reaches the container. The
>     # environment: block below interpolates ${VAR} references for readable
>     # defaults, but compose injects nothing from .env into a container by
>     # itself — without this, a documented variable missing from that block
>     # (FILL_GAPS, PARALLEL_DOWNLOAD_*, LISTEN_* sub-toggles, …) was silently
>     # inert. Explicit environment: entries below still win on conflict.
>     env_file:
>       - path: .env
>         required: false
>
>     environment:
>       # Telegram Credentials (required)
>       TELEGRAM_API_ID: ${TELEGRAM_API_ID}
>       TELEGRAM_API_HASH: ${TELEGRAM_API_HASH}
>       TELEGRAM_PHONE: ${TELEGRAM_PHONE}
>       SESSION_NAME: ${SESSION_NAME:-telegram_backup}
>       TELEGRAM_PROXY_TYPE: ${TELEGRAM_PROXY_TYPE:-}
>       TELEGRAM_PROXY_ADDR: ${TELEGRAM_PROXY_ADDR:-}
>       TELEGRAM_PROXY_PORT: ${TELEGRAM_PROXY_PORT:-}
>       TELEGRAM_PROXY_USERNAME: ${TELEGRAM_PROXY_USERNAME:-}
>       TELEGRAM_PROXY_PASSWORD: ${TELEGRAM_PROXY_PASSWORD:-}
>       TELEGRAM_PROXY_SECRET: ${TELEGRAM_PROXY_SECRET:-}
>       TELEGRAM_PROXY_RDNS: ${TELEGRAM_PROXY_RDNS:-}
>
>       # Multiple accounts (v8.0.0+): archive several Telegram accounts into the
>       # same database and media store. Indexes start at 1 and are contiguous;
>       # declaring any TG_ACCOUNT_* variable makes the indexed set win over the
>       # legacy credentials above. See README → Multiple Accounts.
>       # TG_ACCOUNT_1_API_ID: ${TG_ACCOUNT_1_API_ID:-}
>       # TG_ACCOUNT_1_API_HASH: ${TG_ACCOUNT_1_API_HASH:-}
>       # TG_ACCOUNT_1_PHONE_NUMBER: ${TG_ACCOUNT_1_PHONE_NUMBER:-}
>       # TG_ACCOUNT_1_LABEL: ${TG_ACCOUNT_1_LABEL:-}
>       # TG_ACCOUNT_2_API_ID: ${TG_ACCOUNT_2_API_ID:-}
>       # TG_ACCOUNT_2_API_HASH: ${TG_ACCOUNT_2_API_HASH:-}
>       # TG_ACCOUNT_2_PHONE_NUMBER: ${TG_ACCOUNT_2_PHONE_NUMBER:-}
>       # TG_ACCOUNT_2_LABEL: ${TG_ACCOUNT_2_LABEL:-}
>       # TG_ACCOUNT_2_SESSION_NAME: ${TG_ACCOUNT_2_SESSION_NAME:-telegram_backup_account2}
>
>       # Full pass schedule (cron format). Empty uses the default: 0 3 * * *
>       # (daily) with the listener on, 0 */6 * * * with ENABLE_LISTENER=false.
>       # Hourly passes with the listener on are deprecated: the listener already
>       # saves messages as they arrive.
>       SCHEDULE: ${SCHEDULE:-}
>
>       # Backup Configuration
>       BACKUP_PATH: /data/backups
>       DOWNLOAD_MEDIA: ${DOWNLOAD_MEDIA:-true}
>       MAX_MEDIA_SIZE_MB: ${MAX_MEDIA_SIZE_MB:-100}
>       # Whitelist of media types worth downloading; empty downloads every type.
>       # DOWNLOAD_MEDIA_TYPES: ${DOWNLOAD_MEDIA_TYPES:-}
>       # Narrow the "document" type to specific MIME types, e.g. application/pdf.
>       # DOWNLOAD_DOCUMENT_MIME_TYPES: ${DOWNLOAD_DOCUMENT_MIME_TYPES:-}
>       DOWNLOAD_CHAT_DESCRIPTION: ${DOWNLOAD_CHAT_DESCRIPTION:-false}
>       # Absorb mid-download FloodWaits up to N seconds so large files resume
>       # in place instead of restarting from byte 0 (issue #232). 0 disables.
>       # MEDIA_FLOOD_SLEEP_THRESHOLD: ${MEDIA_FLOOD_SLEEP_THRESHOLD:-60}
>       # Absorb FloodWaits up to N seconds during get_dialogs()'s internal
>       # pagination so it resumes on the same page instead of the whole call
>       # restarting from page 1 (issue #295). 0 disables.
>       # DIALOG_FLOOD_SLEEP_THRESHOLD: ${DIALOG_FLOOD_SLEEP_THRESHOLD:-60}
>       BATCH_SIZE: ${BATCH_SIZE:-100}
>       CHECKPOINT_INTERVAL: ${CHECKPOINT_INTERVAL:-1}
>       # DEDUPLICATE_MEDIA: ${DEDUPLICATE_MEDIA:-true}
>       # PRIORITY_CHAT_IDS: ${PRIORITY_CHAT_IDS:-}
>       # SKIP_MEDIA_CHAT_IDS: ${SKIP_MEDIA_CHAT_IDS:-}
>       # SKIP_MEDIA_DELETE_EXISTING: ${SKIP_MEDIA_DELETE_EXISTING:-false}
>       SKIP_TOPIC_IDS: ${SKIP_TOPIC_IDS:-}
>       # STATS_CALCULATION_HOUR: ${STATS_CALCULATION_HOUR:-3}
>
>       # =======================================================================
>       # CHAT FILTERING - Choose ONE mode:
>       # =======================================================================
>       # MODE 1 (Simple): Set CHAT_IDS to backup ONLY specific chats
>       #   CHAT_IDS: "-100123456,-100789012"  # Only these chats, nothing else
>       #
>       # MODE 2 (Default): Use CHAT_TYPES to backup by type
>       #   CHAT_TYPES: "private,groups,channels"  # All chats of these types
>       # =======================================================================
>       CHAT_IDS: ${CHAT_IDS:-}
>       # If a CHAT_IDS entry can't be resolved (typically a DM on a fresh
>       # session), scan up to N dialogs once to warm the entity cache (#234).
>       # WHITELIST_RESOLVE_DIALOG_LIMIT: ${WHITELIST_RESOLVE_DIALOG_LIMIT:-1000}
>       CHAT_TYPES: ${CHAT_TYPES:-private,groups,channels}
>
>       # Granular Filtering (only used in type-based mode)
>       GLOBAL_INCLUDE_CHAT_IDS: ${GLOBAL_INCLUDE_CHAT_IDS:-}
>       GLOBAL_EXCLUDE_CHAT_IDS: ${GLOBAL_EXCLUDE_CHAT_IDS:-}
>       PRIVATE_INCLUDE_CHAT_IDS: ${PRIVATE_INCLUDE_CHAT_IDS:-}
>       PRIVATE_EXCLUDE_CHAT_IDS: ${PRIVATE_EXCLUDE_CHAT_IDS:-}
>       GROUPS_INCLUDE_CHAT_IDS: ${GROUPS_INCLUDE_CHAT_IDS:-}
>       GROUPS_EXCLUDE_CHAT_IDS: ${GROUPS_EXCLUDE_CHAT_IDS:-}
>       CHANNELS_INCLUDE_CHAT_IDS: ${CHANNELS_INCLUDE_CHAT_IDS:-}
>       CHANNELS_EXCLUDE_CHAT_IDS: ${CHANNELS_EXCLUDE_CHAT_IDS:-}
>       # EXCLUDE_DELETE_EXISTING: ${EXCLUDE_DELETE_EXISTING:-false}  # true DELETES archived data of excluded chats
>       # Adopt the new supergroup id when a tracked basic group migrates (#228).
>       # Default false = warn only; true = follow + capture automatically.
>       # FOLLOW_CHAT_MIGRATIONS: ${FOLLOW_CHAT_MIGRATIONS:-false}
>
>       # Database Configuration (v3.0+)
>       # Option 1: DATABASE_URL (takes priority)
>       DATABASE_URL: ${DATABASE_URL:-}
>       # Option 2: Individual settings
>       DB_TYPE: ${DB_TYPE:-sqlite}
>       DB_PATH: ${DB_PATH:-/data/backups/telegram_backup.db}
>       # PostgreSQL settings (when DB_TYPE=postgresql or using DATABASE_URL)
>       POSTGRES_HOST: ${POSTGRES_HOST:-postgres}
>       POSTGRES_PORT: ${POSTGRES_PORT:-5432}
>       POSTGRES_USER: ${POSTGRES_USER:-telegram}
>       POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-}
>       POSTGRES_DB: ${POSTGRES_DB:-telegram_backup}
>
>       # Real-time Listener (on by default since 9.2.0)
>       # ENABLE_LISTENER is the master switch — when false, all LISTEN_* vars are ignored
>       ENABLE_LISTENER: ${ENABLE_LISTENER:-true}
>       # Granular listener controls (only apply when ENABLE_LISTENER=true):
>       # LISTEN_EDITS: ${LISTEN_EDITS:-true}
>       # LISTEN_DELETIONS: ${LISTEN_DELETIONS:-false}
>       DELETION_MODE: ${DELETION_MODE:-soft}
>       # LISTEN_NEW_MESSAGES: ${LISTEN_NEW_MESSAGES:-true}
>       # LISTEN_NEW_MESSAGES_MEDIA: ${LISTEN_NEW_MESSAGES_MEDIA:-true}
>       # LISTEN_CHAT_ACTIONS: ${LISTEN_CHAT_ACTIONS:-true}
>       # LISTEN_REACTIONS: ${LISTEN_REACTIONS:-true}
>       # REACTION_DEBOUNCE_SECONDS: ${REACTION_DEBOUNCE_SECONDS:-1.5}
>       # REACTION_RESWEEP_DAYS: ${REACTION_RESWEEP_DAYS:-0}
>       # REACTION_RESWEEP_MAX_PER_CHAT: ${REACTION_RESWEEP_MAX_PER_CHAT:-500}
>       # REACTION_RESWEEP_BATCH_DELAY_SECONDS: ${REACTION_RESWEEP_BATCH_DELAY_SECONDS:-2}
>
>       # Mass operation protection (only applies when ENABLE_LISTENER=true).
>       # It also applies to deletions the listener replays after a restart (those need LISTEN_DELETIONS=true too).
>       # MASS_OPERATION_THRESHOLD: ${MASS_OPERATION_THRESHOLD:-10}
>       # MASS_OPERATION_WINDOW_SECONDS: ${MASS_OPERATION_WINDOW_SECONDS:-30}
>       # MASS_OPERATION_BUFFER_DELAY: ${MASS_OPERATION_BUFFER_DELAY:-2.0}
>
>       # Outbound event webhook (#336) — requires ENABLE_LISTENER=true
>       EVENT_WEBHOOK_ENABLED: ${EVENT_WEBHOOK_ENABLED:-false}
>       # EVENT_WEBHOOK_URL: ${EVENT_WEBHOOK_URL:-}
>       # EVENT_WEBHOOK_METHOD: ${EVENT_WEBHOOK_METHOD:-POST}
>       # EVENT_WEBHOOK_HEADERS: ${EVENT_WEBHOOK_HEADERS:-}
>       # EVENT_WEBHOOK_EVENTS: ${EVENT_WEBHOOK_EVENTS:-message_edited,message_deleted}
>       # EVENT_WEBHOOK_CHAT_IDS: ${EVENT_WEBHOOK_CHAT_IDS:-}
>       # EVENT_WEBHOOK_BODY_TEMPLATE: ${EVENT_WEBHOOK_BODY_TEMPLATE:-}
>
>       # Voice transcription (docs/TRANSCRIPTION.md): on by default, idle until
>       # TRANSCRIPTION_URL points at an akou server (see the optional service below)
>       TRANSCRIPTION_ENABLED: ${TRANSCRIPTION_ENABLED:-true}
>       TRANSCRIPTION_URL: ${TRANSCRIPTION_URL:-}
>       # TRANSCRIPTION_API_KEY: ${TRANSCRIPTION_API_KEY:-}
>       # TRANSCRIPTION_PROVIDER: ${TRANSCRIPTION_PROVIDER:-auto}
>       # TRANSCRIPTION_MODEL: ${TRANSCRIPTION_MODEL:-}
>       # TRANSCRIPTION_HOTWORDS: ${TRANSCRIPTION_HOTWORDS:-}
>       # TRANSCRIPTION_PRESET: ${TRANSCRIPTION_PRESET:-auto}
>       # TRANSCRIPTION_TYPES: ${TRANSCRIPTION_TYPES:-voice}
>       # TRANSCRIPTION_MAX_SECONDS: ${TRANSCRIPTION_MAX_SECONDS:-1800}
>       # TRANSCRIPTION_MAX_UPLOAD_MB: ${TRANSCRIPTION_MAX_UPLOAD_MB:-500}
>       # TRANSCRIPTION_LANGUAGE: ${TRANSCRIPTION_LANGUAGE:-}
>       # TRANSCRIPTION_DIARIZE: ${TRANSCRIPTION_DIARIZE:-false}
>       # TRANSCRIPTION_CALLBACK_URL: ${TRANSCRIPTION_CALLBACK_URL:-}
>       # TRANSCRIPTION_BACKFILL_PER_RUN: ${TRANSCRIPTION_BACKFILL_PER_RUN:-50}
>       # TRANSCRIPTION_DRAIN_INTERVAL_MINUTES: ${TRANSCRIPTION_DRAIN_INTERVAL_MINUTES:-15}
>       # TRANSCRIPTION_PRIORITY_CHAT_IDS: ${TRANSCRIPTION_PRIORITY_CHAT_IDS:-}
>
>       # Batch sync alternative (expensive — prefer ENABLE_LISTENER instead)
>       SYNC_DELETIONS_EDITS: ${SYNC_DELETIONS_EDITS:-false}
>       SYNC_DELETIONS_EDITS_DAYS: ${SYNC_DELETIONS_EDITS_DAYS:-0}
>       VERIFY_MEDIA: ${VERIFY_MEDIA:-false}
>       INTERNAL_PUSH_SECRET: ${INTERNAL_PUSH_SECRET:-}
>       # Split containers: realtime pushes must target the viewer SERVICE, not
>       # the in-container localhost the bare-metal default assumes — otherwise
>       # every push is refused and "real-time updates" silently degrades to
>       # refresh-to-see on the stock SQLite stack.
>       VIEWER_HOST: ${VIEWER_HOST:-telegram-viewer}
>       VIEWER_PORT: ${VIEWER_PORT:-8000}
>
>       # Logging
>       LOG_LEVEL: ${LOG_LEVEL:-INFO}
>       LOG_CHAT_TITLES: ${LOG_CHAT_TITLES:-false}
>
>     volumes:
>       # Persistent data storage
>       - ./data:/data
>     networks:
>       - telegram-network
>     # Security hardening
>     read_only: true
>     tmpfs:
>       - /tmp
>     cap_drop:
>       - ALL
>     security_opt:
>       - no-new-privileges:true
>     # Uncomment to use PostgreSQL
>     # depends_on:
>     #   postgres:
>     #     condition: service_healthy
>     # Optional: Resource limits
>     # deploy:
>     #   resources:
>     #     limits:
>     #       cpus: '1.0'
>     #       memory: 1G
>     #     reservations:
>     #       cpus: '0.5'
>     #       memory: 512M
>
>   telegram-viewer:
>     image: drumsergio/telegram-archive-viewer:9.8.0
>     container_name: telegram-viewer
>     restart: unless-stopped
>     # A stop during a long sweep needs time to finish in-flight writes and
>     # disconnect cleanly; without this docker waits 10s then SIGKILLs.
>     stop_grace_period: 90s
>     # Unattended deployments must not fill the host disk with container logs.
>     logging:
>       driver: json-file
>       options:
>         max-size: "10m"
>         max-file: "3"
>     ports:
>       # Bind to localhost by default. Put this behind a reverse proxy with auth
>       # configured before exposing it publicly.
>       - "127.0.0.1:8000:8000"
>
>     # DELIBERATE ASYMMETRY with telegram-backup: no env_file here. This is the
>     # internet-facing container, so it receives an explicit allowlist instead
>     # of the whole .env — capture credentials (TELEGRAM_API_HASH, proxy
>     # passwords) must never enter its environment. Every viewer-documented
>     # variable therefore needs an entry below; the ${VAR:-default} references
>     # are interpolated from the same .env.
>
>     environment:
>       BACKUP_PATH: /data/backups
>
>       # Authentication is required by default. To deliberately run a local
>       # anonymous viewer, set ALLOW_ANONYMOUS_VIEWER=true.
>       VIEWER_USERNAME: ${VIEWER_USERNAME:-}
>       VIEWER_PASSWORD: ${VIEWER_PASSWORD:-}
>       ALLOW_ANONYMOUS_VIEWER: ${ALLOW_ANONYMOUS_VIEWER:-false}
>       AUTH_SESSION_DAYS: ${AUTH_SESSION_DAYS:-30}
>
>       # Display
>       VIEWER_TIMEZONE: ${VIEWER_TIMEZONE:-Europe/Madrid}
>       # Default color theme for browsers with no saved choice; empty = system
>       # (system | telegram | night | iosnight | slate | minimal | graphite |
>       # amoled | forest | aubergine | day | paper); the in-app picker wins.
>       VIEWER_DEFAULT_THEME: ${VIEWER_DEFAULT_THEME:-}
>       # Wallpaper behind the messages: a file name served from /static, so the
>       # image must be mounted into the container (see the volumes block below).
>       VIEWER_CHAT_BACKGROUND: ${VIEWER_CHAT_BACKGROUND:-}
>       # MEDIA_OPEN_CMD and MEDIA_OPEN_PATH_CMD are not passed on purpose: they run a
>       # command on the machine that serves the viewer, and a container has no desktop.
>       SHOW_STATS: ${SHOW_STATS:-true}
>       # Restrict viewer to specific chats (optional)
>       DISPLAY_CHAT_IDS: ${DISPLAY_CHAT_IDS:-}
>
>       # Security
>       # CORS_ORIGINS: comma-separated allowed origins (default: * = allow all)
>       CORS_ORIGINS: ${CORS_ORIGINS:-*}
>       # SECURE_COOKIES: auto-detect from protocol; override with true/false
>       SECURE_COOKIES: ${SECURE_COOKIES:-}
>       TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false}
>       INTERNAL_PUSH_SECRET: ${INTERNAL_PUSH_SECRET:-}
>       # Reverse-proxy (Authelia/Authentik) identity integration
>       AUTH_PROXY_HEADER: ${AUTH_PROXY_HEADER:-}
>       AUTH_PROXY_ADMIN_USERS: ${AUTH_PROXY_ADMIN_USERS:-}
>       AUTH_PROXY_DEFAULT_ACCESS: ${AUTH_PROXY_DEFAULT_ACCESS:-none}
>       OIDC_ISSUER: ${OIDC_ISSUER:-}
>       OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
>       OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
>       OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-}
>       OIDC_SCOPES: ${OIDC_SCOPES:-openid email profile}
>       OIDC_USERNAME_CLAIM: ${OIDC_USERNAME_CLAIM:-email}
>       OIDC_ADMIN_USERS: ${OIDC_ADMIN_USERS:-}
>       OIDC_USER_MAP: ${OIDC_USER_MAP:-}
>       OIDC_DEFAULT_ACCESS: ${OIDC_DEFAULT_ACCESS:-none}
>
>       # Notifications
>       PUSH_NOTIFICATIONS: ${PUSH_NOTIFICATIONS:-basic}
>       VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
>       VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
>       VAPID_CONTACT: ${VAPID_CONTACT:-mailto:admin@example.com}
>
>       # Database Configuration — MUST MATCH telegram-backup service!
>       # If viewer shows no data, ensure these match the backup container
>       DATABASE_URL: ${DATABASE_URL:-}
>       DB_TYPE: ${DB_TYPE:-sqlite}
>       DB_PATH: ${DB_PATH:-/data/backups/telegram_backup.db}
>       POSTGRES_HOST: ${POSTGRES_HOST:-postgres}
>       POSTGRES_PORT: ${POSTGRES_PORT:-5432}
>       POSTGRES_USER: ${POSTGRES_USER:-telegram}
>       POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-}
>       POSTGRES_DB: ${POSTGRES_DB:-telegram_backup}
>
>       # Voice transcription (docs/TRANSCRIPTION.md). The viewer never calls the
>       # server: it shows TRANSCRIPTION_URL and receives the signed callback.
>       TRANSCRIPTION_ENABLED: ${TRANSCRIPTION_ENABLED:-true}
>       TRANSCRIPTION_URL: ${TRANSCRIPTION_URL:-}
>       # TRANSCRIPTION_WEBHOOK_SECRET: ${TRANSCRIPTION_WEBHOOK_SECRET:-}
>       TRANSCRIPTION_ASK_RATE_LIMIT: ${TRANSCRIPTION_ASK_RATE_LIMIT:-30}
>       TRANSCRIPTION_ASK_MAX_OPEN: ${TRANSCRIPTION_ASK_MAX_OPEN:-50}
>
>       # Logging
>       LOG_LEVEL: ${LOG_LEVEL:-INFO}
>
>     volumes:
>       # SQLite needs write access for WAL journal files (.db-wal, .db-shm).
>       # Use :ro only if you are using PostgreSQL as the database backend.
>       - ./data:/data
>       # Wallpaper behind the messages (VIEWER_CHAT_BACKGROUND). The name must
>       # match the variable, and the mount has to be re-stated after an image
>       # update — nothing written inside the container survives one, and this
>       # service runs with a read-only root filesystem, so a bind mount is the
>       # only way in. Mount the FILE: a directory mounted over the static one
>       # hides the app's own assets and the viewer comes up blank.
>       # - ./wallpaper.jpg:/app/telegram_archive/web/static/wallpaper.jpg:ro
>     networks:
>       - telegram-network
>     # Security hardening
>     read_only: true
>     tmpfs:
>       - /tmp
>     cap_drop:
>       - ALL
>     security_opt:
>       - no-new-privileges:true
>     # There is deliberately no ordering between this service and
>     # telegram-backup, and none is needed. telegram-backup owns the schema and
>     # migrates it on start; this image ships no migrations and cannot. It also no
>     # longer writes tables into a database that already has any (see
>     # _create_schema_if_absent in telegram_archive/db/base.py), so starting it never
>     # corrupts a migration. The ordering that matters is enforced from the
>     # other side: a migration that rebuilds tables refuses to start while any
>     # other process holds the SQLite file, and proceeds once it is released.
>     # Do not add depends_on here expecting it to wait for migrations:
>     # depends_on waits for the container, not for the work it does, so it
>     # would only look like a guarantee.
>     # Uncomment to use PostgreSQL
>     # depends_on:
>     #   postgres:
>     #     condition: service_healthy
>
>   # Example: Restricted Channel Viewer (optional)
>   # Use this to share specific channels publicly with authentication
>   # telegram-channel-viewer:
>   #   image: drumsergio/telegram-archive-viewer:9.8.0
>   #   container_name: telegram-channel-viewer
>   #   restart: unless-stopped
>   #   ports:
>   #     - "8001:8000"
>   #   environment:
>   #     BACKUP_PATH: /data/backups
>   #     DISPLAY_CHAT_IDS: 224091347,123456789  # Comma-separated chat IDs
>   #     VIEWER_USERNAME: public_viewer
>   #     VIEWER_PASSWORD: secure_password_here
>   #   volumes:
>   #     - ./data:/data
>   #   networks:
>   #     - telegram-network
>
>   # ================================
>   # akou speech-to-text (Optional)
>   # ================================
>   # Uncomment to transcribe voice messages on this host. akou can run on any
>   # host instead; TRANSCRIPTION_URL points at wherever it runs, here
>   # http://akou:8476. See https://github.com/GeiserX/akou for its server
>   # settings, the API key for TRANSCRIPTION_API_KEY and the whsec_ secret for
>   # TRANSCRIPTION_WEBHOOK_SECRET.
>   #
>   # The image runs as uid 1000, so give it the two folders first:
>   # mkdir -p akou/data akou/models && chown -R 1000:1000 akou. Pull the models
>   # once before the first start (docker run --rm -v ./akou/data:/data -v ./akou/models:/models
>   # drumsergio/akou:0.5.4 models pull fast). In a container akou must listen
>   # beyond loopback, and it refuses to unless its settings say a proxy fronts
>   # it: set server.behind_proxy to true (see akou's docs/install.md).
>   #
>   # akou:
>   #   image: drumsergio/akou:0.5.4
>   #   container_name: akou
>   #   restart: unless-stopped
>   #   volumes:
>   #     - ./akou/data:/data
>   #     - ./akou/models:/models
>   #   networks:
>   #     - telegram-network
>
>   # ================================
>   # PostgreSQL Database (Optional)
>   # ================================
>   # Uncomment to use PostgreSQL instead of SQLite
>   # Also set DB_TYPE=postgresql in your .env file
>   #
>   # postgres:
>   #   image: postgres:18-alpine
>   #   container_name: telegram-postgres
>   #   restart: unless-stopped
>   #   environment:
>   #     POSTGRES_USER: ${POSTGRES_USER:-telegram}
>   #     POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
>   #     POSTGRES_DB: ${POSTGRES_DB:-telegram_backup}
>   #   volumes:
>   #     - postgres_data:/var/lib/postgresql
>   #   networks:
>   #     - telegram-network
>   #   healthcheck:
>   #     test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-telegram} -d ${POSTGRES_DB:-telegram_backup}"]
>   #     interval: 10s
>   #     timeout: 5s
>   #     retries: 5
>
> networks:
>   telegram-network:
>     driver: bridge
>
> # Uncomment for PostgreSQL persistent storage
> # volumes:
> #   postgres_data:
> ```

## One-off commands

Run any command with a fresh container:

```
docker compose run --rm telegram-backup python -m telegram_archive <command>
```

`export`, `export-chat`, `stats`, `status` and `list-chats` never change archived data and never connect to Telegram, so you can also run them inside the running container:

```
docker compose exec telegram-backup python -m telegram_archive stats
docker compose exec telegram-backup python -m telegram_archive export -o /data/backups/export.json
docker compose exec telegram-backup python -m telegram_archive export-chat --chat @example_channel --format html -o /data/backups/chat.zip
```

The container's root filesystem is read-only, so any output file must go under `/data`. It then appears under `./data` on the host.

Commands that connect to Telegram use the scheduler's session file, so stop the scheduler first. See [One client per session](<https://geiserx.github.io/Telegram-Archive/getting-started/telegram-login/#one-client-per-session>).

```
docker compose stop telegram-backup
docker compose run --rm telegram-backup python -m telegram_archive fill-gaps
docker compose start telegram-backup
```

Always give the backup image a command. Without one it prints the help text and exits 0, and `restart: unless-stopped` turns that into a restart loop.

The full list of commands is in [Command line and Python API](<https://geiserx.github.io/Telegram-Archive/reference/cli/index.md>).

## Running without Compose

You can run the same setup with plain `docker run`. First create a network so the backup can push real-time updates to the viewer by name:

```
docker network create telegram-archive
```

In `.env`, uncomment `VIEWER_HOST=telegram-viewer` and `VIEWER_PORT=8000`.

Plain `docker run --env-file` keeps everything after `=`, inline comments included. Compose strips them, so the file works there as it is. Before you use `.env` outside Compose, delete the trailing `# ...` on the `MAX_MEDIA_SIZE_MB` and `TRANSCRIPTION_URL` lines, or move those comments to their own lines. Otherwise the backup exits at start with `MAX_MEDIA_SIZE_MB must be an integer`.

Log in once, then start the backup:

```
docker run -it --rm --env-file .env -v ./data:/data \
  drumsergio/telegram-archive:9.8.0 python -m telegram_archive auth

docker run -d --name telegram-backup --restart unless-stopped \
  --network telegram-archive \
  --env-file .env \
  -v ./data:/data \
  --read-only --tmpfs /tmp \
  --cap-drop ALL --security-opt no-new-privileges:true \
  --stop-timeout 90 \
  --log-opt max-size=10m --log-opt max-file=3 \
  drumsergio/telegram-archive:9.8.0 python -m telegram_archive schedule
```

The viewer gets explicit `-e` variables, never the whole `.env`. Its database settings must match the backup's. With the image defaults, both use `/data/backups/telegram_backup.db`:

```
docker run -d --name telegram-viewer --restart unless-stopped \
  --network telegram-archive \
  -p 127.0.0.1:8000:8000 \
  -v ./data:/data \
  --read-only --tmpfs /tmp \
  --cap-drop ALL --security-opt no-new-privileges:true \
  --stop-timeout 90 \
  --log-opt max-size=10m --log-opt max-file=3 \
  -e VIEWER_USERNAME=admin \
  -e VIEWER_PASSWORD=choose-a-long-password \
  -e VIEWER_TIMEZONE=Europe/London \
  -e DB_TYPE=sqlite \
  -e DB_PATH=/data/backups/telegram_backup.db \
  drumsergio/telegram-archive-viewer:9.8.0
```

For PostgreSQL, see [SQLite and PostgreSQL](<https://geiserx.github.io/Telegram-Archive/configuration/database/index.md>). What each setting means is in [Environment variables](<https://geiserx.github.io/Telegram-Archive/reference/environment-variables/index.md>).

## Removing the install

```
docker compose down
```

This removes the containers and the network and keeps `./data`. With the optional PostgreSQL service, `docker compose down -v` also removes the `postgres_data` volume.

The Telegram login stays valid until you end it. In Telegram, open **Settings \> Devices** and terminate the backup's session. Then delete `./data` if you no longer want the archive. To move the archive to another machine instead, copy `./data`, `.env` and `docker-compose.yml` as described in [Backing up the archive](<https://geiserx.github.io/Telegram-Archive/operations/backup-and-restore/index.md>), and never run both machines at once.
