Deploy

Build a Chatbot: From API Call to Deployed Bot

Chapter 12 ยท Deployment and Capstone: A Bot That Stays Up

A deployed bot is the same code in a place you are not sitting at. What changes is everything around it: settings that must not live in the source, a database that holds other people's conversations, a process that must start by itself, restart after a crash, and stop without losing a reply, and a path from the internet that is encrypted. This chapter assembles the twelve chapters into one service and prepares it for a Linux server. It also closes the gap chapter 9 left open: Telegram's developer terms ask for user data to be encrypted at rest, and until now the SQLite file was plain.

What was run, and what was not (read this first)
  • Run here, on Windows with Python 3.14.0: the whole service as a real process (py -m bot.app) in production mode with the echo provider; a live smoke test against it; the encryption, rotation and backup code; the configuration checks; the shutdown sequence (in-process). The project now has 290 passing tests.
  • Run here, narrowly: systemd-analyze verify (systemd 252, Debian 12 under WSL) on the three unit files. It checks syntax and directive names, not that the service works.
  • Not run here: the Docker build (Docker Desktop's engine was not running and I did not start it), nginx, TLS certificates, a real systemd start, a real SIGTERM, a real provider or any chat platform. Those files are written from documentation and general knowledge and are labelled again where they appear. Treat them as a starting point to test on your own server, not as verified.
  • No server of yours was touched and nothing was installed in your Linux distributions.

One Service, Configured From the Environment

Until now each channel had its own small script. A deployment wants one process that starts everything that is configured: the web chat and the Slack route on the web server, Telegram polling (or its webhook), the Discord connection, plus two background jobs (the retention purge from chapter 6 and the alert check from chapter 11). All of it is driven by environment variables, validated at start-up. The rules, in order of how much grief they save:

  • Secrets never go in the code, the Docker image or git. They arrive in the environment (here from a file only root can read, /etc/chatbot/chatbot.env).
  • Fail fast. A bad configuration should stop the start-up with a clear message, not surface three days later as a cookie that cannot be signed. In production the check demands every secret and refuses the fake provider.
  • Report every problem at once, not one per attempt.
  • Never print a secret. The summary shows set or missing.
"""All configuration comes from environment variables, is checked at start-up, and is never printed with its secrets.""" import os import pathlib from dataclasses import dataclass, field PROVIDERS = { "fake": ("fake", "", "", ""), "echo": ("echo", "", "", ""), "claude": ("claude", "", "", "ANTHROPIC_API_KEY"), "grok": ("openai-compatible", "", "https://api.x.ai/v1", "XAI_API_KEY"), "openai": ("openai-compatible", "", "https://api.openai.com/v1", "OPENAI_API_KEY"), "ollama": ("openai-compatible", "", "http://localhost:11434/v1", ""), } @dataclass(frozen=True) class Settings: env: str = "development" data_dir: pathlib.Path = pathlib.Path("data") host: str = "127.0.0.1" port: int = 8000 provider: str = "fake" model: str = "" base_url: str = "" web_secret: str = "" allowed_origins: tuple[str, ...] = () secure_cookie: bool = False content_keys: tuple[str, ...] = () log_key: str = "" log_level: str = "INFO" retention_days: int = 30 persona_file: str = "persona.toml" telegram_token: str = "" telegram_webhook_secret: str = "" discord_token: str = "" slack_token: str = "" slack_signing_secret: str = "" problems: tuple[str, ...] = field(default=(), compare=False) @property def production(self) -> bool: return self.env == "production" @property def db_path(self) -> str: return str(self.data_dir / "chatbot.db") def summary(self) -> dict[str, str]: """What is configured, with every secret shown only as 'set' or 'missing'.""" def flag(value) -> str: return "set" if value else "missing" return { "environment": self.env, "listen": f"{self.host}:{self.port}", "provider": self.provider, "model": self.model or "(provider default)", "data directory": str(self.data_dir), "retention days": str(self.retention_days), "content encryption": f"{len(self.content_keys)} key(s)" if self.content_keys else "OFF (plain text on disk)", "web session secret": flag(self.web_secret), "allowed origins": ", ".join(self.allowed_origins) or "(none)", "log pseudonym key": flag(self.log_key), "secure cookie": str(self.secure_cookie), "telegram": ("webhook" if self.telegram_webhook_secret else "polling") if self.telegram_token else "off", "discord": "on" if self.discord_token else "off", "slack": "on" if (self.slack_token and self.slack_signing_secret) else "off", } def load_settings(env: dict[str, str] | None = None) -> Settings: """Read the environment and collect every problem in one pass, so a bad configuration is fixed in one go.""" e = dict(os.environ if env is None else env) problems: list[str] = [] def get(name: str, default: str = "") -> str: return e.get(name, default).strip() def number(name: str, default: int) -> int: try: return int(get(name, str(default))) except ValueError: problems.append(f"{name} must be a whole number") return default environment = get("CHATBOT_ENV", "development") if environment not in ("development", "production"): problems.append("CHATBOT_ENV must be 'development' or 'production'") provider = get("CHATBOT_PROVIDER", "fake") if provider not in PROVIDERS: problems.append(f"CHATBOT_PROVIDER must be one of {', '.join(PROVIDERS)}") else: key_env = PROVIDERS[provider][3] if key_env and not get(key_env): problems.append(f"{key_env} is required for provider '{provider}'") if provider not in ("fake", "echo") and not get("CHATBOT_MODEL"): problems.append("CHATBOT_MODEL is required: read the current model name from your provider's model list") keys = tuple(k.strip() for k in get("CHATBOT_CONTENT_KEYS").split(",") if k.strip()) web_secret, log_key = get("CHATBOT_WEB_SECRET"), get("CHATBOT_LOG_KEY") origins = tuple(o.strip() for o in get("CHATBOT_ALLOWED_ORIGINS").split(",") if o.strip()) if environment == "production": if not keys: problems.append("CHATBOT_CONTENT_KEYS is required in production (encrypts messages on disk; generate one with: py -m bot.crypto_keygen)") if len(web_secret) < 32: problems.append("CHATBOT_WEB_SECRET must be at least 32 characters in production") if len(log_key) < 16: problems.append("CHATBOT_LOG_KEY must be at least 16 characters in production") if not origins: problems.append("CHATBOT_ALLOWED_ORIGINS is required in production (for example https://chat.example.org)") if provider == "fake": problems.append("CHATBOT_PROVIDER=fake is not allowed in production") for key in keys: try: from cryptography.fernet import Fernet Fernet(key.encode()) except Exception: # noqa: BLE001 - any failure to build a Fernet from the text means it is not a valid key problems.append("CHATBOT_CONTENT_KEYS contains a value that is not a valid Fernet key") break if bool(get("SLACK_BOT_TOKEN")) != bool(get("SLACK_SIGNING_SECRET")): problems.append("SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET must be set together") return Settings( env=environment, data_dir=pathlib.Path(get("CHATBOT_DATA_DIR", "data")), host=get("CHATBOT_HOST", "127.0.0.1"), port=number("CHATBOT_PORT", 8000), provider=provider, model=get("CHATBOT_MODEL"), base_url=get("CHATBOT_BASE_URL"), web_secret=web_secret or ("" if environment == "production" else "development-only-secret-do-not-use-in-production"), allowed_origins=origins or (() if environment == "production" else ("http://127.0.0.1:8000",)), secure_cookie=get("CHATBOT_SECURE_COOKIE", "1" if environment == "production" else "0") == "1", content_keys=keys, log_key=log_key or ("" if environment == "production" else "development-log-key"), log_level=get("CHATBOT_LOG_LEVEL", "INFO").upper(), retention_days=number("CHATBOT_RETENTION_DAYS", 30), persona_file=get("CHATBOT_PERSONA_FILE", "persona.toml"), telegram_token=get("TELEGRAM_BOT_TOKEN"), telegram_webhook_secret=get("TELEGRAM_WEBHOOK_SECRET"), discord_token=get("DISCORD_BOT_TOKEN"), slack_token=get("SLACK_BOT_TOKEN"), slack_signing_secret=get("SLACK_SIGNING_SECRET"), problems=tuple(problems), )

The same program, in three situations. This is real output (the keys were generated for the demonstration, the Claude key and Telegram token are made up):

$ py -m bot.app --check (development, nothing set) environment development listen 127.0.0.1:8000 provider fake model (provider default) data directory data retention days 30 content encryption OFF (plain text on disk) web session secret set allowed origins http://127.0.0.1:8000 log pseudonym key set secure cookie False telegram off discord off slack off exit code: 0 $ CHATBOT_ENV=production py -m bot.app --check (nothing else set) environment production listen 127.0.0.1:8000 provider fake model (provider default) data directory data retention days 30 content encryption OFF (plain text on disk) web session secret missing allowed origins (none) log pseudonym key missing secure cookie True telegram off discord off slack off PROBLEM: CHATBOT_CONTENT_KEYS is required in production (encrypts messages on disk; generate one with: py -m bot.crypto_keygen) PROBLEM: CHATBOT_WEB_SECRET must be at least 32 characters in production PROBLEM: CHATBOT_LOG_KEY must be at least 16 characters in production PROBLEM: CHATBOT_ALLOWED_ORIGINS is required in production (for example https://chat.example.org) PROBLEM: CHATBOT_PROVIDER=fake is not allowed in production exit code: 1 $ a complete production configuration (keys generated just now, values not shown) environment production listen 127.0.0.1:8000 provider claude model example-model data directory data retention days 30 content encryption 1 key(s) web session secret set allowed origins https://chat.example.org log pseudonym key set secure cookie True telegram polling discord off slack off exit code: 0

Note the middle run. In production, a missing web secret is a problem; in development it gets a default, which is the dangerous kind of default, so the production summary must not claim it is "set". My first version did exactly that: --check in production reported the development defaults as set, because the defaults were filled in before the summary was built. Reading the output instead of just the exit code found it.

Encrypting Messages on Disk

Chapter 9 recorded that Telegram's Developer Terms ask for user data to be encrypted at rest and that the SQLite file was plain. There are two ways to close that, and they protect against different things:

Disk or volume encryption (LUKS, a cloud volume)Encrypting the content in the application
Protects againstA stolen disk or an old discarded driveA copied database file, a leaked backup, someone browsing the data directory
Does not protect againstAnyone who can read files while the machine is running, including backups copied off itSomeone who gets the running process, or the key
EffortServer setup; no codeCode (below), plus key management

I used the second, and you can still add the first; they do not conflict. The text of messages, summaries and remembered notes is encrypted with Fernet from the cryptography package, which is authenticated encryption (it detects tampering) with a simple interface. What I know about Fernet comes from the library's documented design and general knowledge; I did not re-read its specification for this chapter. MultiFernet holds several keys: it encrypts with the first and tries all of them to decrypt, which is what makes key rotation possible.

"""Encrypting what people said before it is written to disk, with a key that is kept somewhere else.""" from cryptography.fernet import Fernet, InvalidToken, MultiFernet TOKEN_PREFIX = "gAAAA" # every Fernet token starts like this (a version byte, base64-encoded), which lets us tell encrypted from plain class WrongKey(Exception): """The stored text cannot be decrypted with the keys we hold (wrong or missing key, or the file was altered).""" def generate_key() -> str: return Fernet.generate_key().decode() class ContentCipher: """Fernet (AES-128-CBC with an HMAC-SHA256 check, per the cryptography library's documentation) with key rotation. Pass several keys, newest first: new text is encrypted with the first, and any listed key can still decrypt. To retire an old key, add the new one at the front, run Store.rotate(), then remove the old one.""" def __init__(self, keys: list[str]) -> None: if not keys: raise ValueError("at least one key is required") self._fernet = MultiFernet([Fernet(k.encode() if isinstance(k, str) else k) for k in keys]) def encrypt(self, text: str) -> str: return self._fernet.encrypt(text.encode()).decode() def decrypt(self, token: str) -> str: try: return self._fernet.decrypt(token.encode()).decode() except InvalidToken: raise WrongKey("stored text could not be decrypted with the configured key(s)") from None def rotate(self, token: str) -> str: """Re-encrypt a token under the newest key.""" try: return self._fernet.rotate(token.encode()).decode() except InvalidToken: raise WrongKey("stored text could not be decrypted with the configured key(s)") from None @staticmethod def is_encrypted(value: str) -> bool: return value.startswith(TOKEN_PREFIX)

The store seals text on the way in and opens it on the way out; a value that is not encrypted passes through unchanged, so a database can be switched over gradually. The real output of switching an existing database over, backing it up, rotating the key and retiring the old one:

1. A database that was running WITHOUT encryption 'Leeds' readable in the file: True 2. Switch encryption on and encrypt what is already there values encrypted: 2 (a second run: 0 ) 'Leeds' readable in the file: False (VACUUM removed the old copy) the bot still reads it: My card went missing in Leeds 3. Back it up while open backup file: chatbot-20261010-162934.db 'Leeds' readable in the backup: False 4. Rotate: new key first, old key kept so old rows still open rows re-encrypted under the new key: 2 5. Retire the old key readable with only the new key: My card went missing in Leeds the OLD backup still needs the OLD key: WrongKey raised: stored text could not be decrypted with the configured key(s) 6. Start with the wrong key WrongKey raised: stored text could not be decrypted with the configured key(s)

And the actual bytes of the database written by the running production process during the smoke test below. The rows are tokens starting gAAAA; the question and the reply are nowhere in the file:

plain text 'Say hello in five words' in the file: False plain text 'You said' in the file: False row 1 user gAAAAABqymcJQzkIN3O62zZ3wfh-BKrYX6CJb2j6X2Yl... (120 chars) row 2 assistant gAAAAABqymcJEF-hZHadGLjDlH0A656-91L6R28DB7PV... (140 chars) row 3 user gAAAAABqymcPR5SG7K2CVvIprG1n1S_7Xq0Vcu9PymsV... (120 chars) row 4 assistant gAAAAABqymcPOTgCWd441vrHwdMQqtCJVMkRItcBokEG... (140 chars) readable columns beside them: ['id', 'session_id', 'role', 'content', 'created_at']
What this does and does not give you
  • The key is the whole protection. Keep it out of the data directory and out of backups. A backup stored beside the key is not protected at all. Lose the key and the messages are gone for good: that is deliberate, and a reason to store it in a password manager as well as on the server.
  • Metadata stays readable: session ids, timestamps, roles, and the length of each message (a 120-character token above means roughly 24 characters of text). Fernet tokens also carry a plain timestamp.
  • The old text lingers unless you rewrite the file. My first test of "switch encryption on for an existing database" failed: after encrypting every row, the old plain text was still inside the file, in pages SQLite had freed but not erased. The fix is a VACUUM, which rewrites the file; the demonstration above shows Leeds gone afterwards. The same applies to rotating keys: without it, text encrypted under a retired key can remain in the file.
  • Old backups keep the old key's text. After a rotation, a backup made before it still needs the old key (shown above). Decide how long you keep both, and delete old backups.
  • It does not hide anything from the running bot, a root user on the server, or the model provider, who receive the conversation in the clear (chapters 2 and 6).
  • Whether this satisfies Telegram's terms, or the GDPR's security duties, is not something I can tell you. It is a reasonable technical measure; the legal judgement is yours.

Assembling the Service

build_service wires everything together from the settings, and its lifespan function owns start-up and shutdown. The shutdown order matters: signal the background tasks to stop and cancel them, give in-flight replies a bounded time to finish (drain, from chapters 9 and 10), close the HTTP clients to the platforms, and only then close the database. Closing the database first would turn every reply still in flight into an error.

def build_provider(s: Settings, logger: logging.Logger, metrics: Metrics, env: dict | None = None): kind, _, default_base, key_env = PROVIDERS[s.provider] config = ProviderConfig(kind, model=s.model, base_url=s.base_url or default_base, api_key_env=key_env) return BreakerProvider(RetryingProvider(LoggedProvider(make_provider(config, env=env), logger, metrics)))
@app.get("/readyz") async def ready(): try: store.db.execute("SELECT 1").fetchone() except Exception: # noqa: BLE001 - readiness is a yes/no answer from fastapi.responses import JSONResponse return JSONResponse({"ready": False, "reason": "database"}, status_code=503) return {"ready": True, "channels": sorted(k for k in channels if not k.endswith("_gateway"))} async def purge_loop(): while True: await asyncio.sleep(purge_every) counts = await bot.purge_expired() logger.info("retention_purge", extra=counts) async def alert_loop(): last: set[str] = set() while True: await asyncio.sleep(alerts_every) now = set(check_alerts(metrics)) for alert in now - last: # say it once when it starts, not every minute logger.warning("alert", extra={"alert": alert}) last = now @contextlib.asynccontextmanager async def lifespan(_app): stop = asyncio.Event() tasks = [asyncio.create_task(factory(stop)) for factory in background] tasks += [asyncio.create_task(purge_loop()), asyncio.create_task(alert_loop())] logger.info("started", extra={"settings": s.summary()}) try: yield finally: logger.info("stopping") stop.set() gateway = channels.get("discord_gateway") if gateway: gateway.stop() for task in tasks: task.cancel() await asyncio.gather(*tasks, return_exceptions=True) for name in ("telegram", "discord", "slack"): # let in-flight replies finish, but not forever if name in channels: with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for(channels[name][1].drain(), 10) for name in ("telegram", "discord", "slack"): if name in channels: await channels[name][0].aclose() store.close() logger.info("stopped")

Two endpoints serve the platform around the bot. /healthz says the process is up and serving (nothing more, so a slow database cannot make an orchestrator kill a healthy process); /readyz also touches the database and returns 503 if it fails. Use the first for "restart me" and the second for "do not send me traffic". The run command adds proxy_headers for the one address allowed to say who the client was (the local reverse proxy, never *), turns the per-request access log off (so message paths and addresses do not pile up in a second log), and allows 20 seconds for open connections to finish on shutdown.

Shutdown, Demonstrated

ready while running: {'ready': True, 'channels': []} after the with-block (shutdown ran): database closed: Cannot operate on a closed database. log: started log: stopping log: stopped

This runs the same lifespan code in-process (it is Exercise 3). It shows the order of events, not a real signal: I did not send a real SIGTERM to a real process, and on Windows that is not a fair test of what systemd does on Linux. Test it on the server: systemctl stop chatbot while a streamed reply is running, and read the log for stopping and stopped.

Backups

Copying a SQLite file while the bot is writing to it can give you a torn copy that will not open. SQLite's online backup interface takes a consistent snapshot of a live database; Python exposes it as Connection.backup. The script opens the source read-only (a backup must never modify the live database; a test checks it does not even create one by mistake), checks the copy with PRAGMA integrity_check, keeps the newest N, and sets the files private.

"""Consistent backups of the SQLite database. py -m bot.backup /var/lib/chatbot/chatbot.db /var/backups/chatbot --keep 7 Copying a SQLite file while the bot is writing to it can produce a torn, unusable copy. SQLite's online backup API takes a consistent snapshot of a live database. Because message text is encrypted inside the database (when a content key is configured), the backup file is encrypted too: keep the KEY somewhere else, or the backup protects nothing. """ import argparse import pathlib import sqlite3 import sys import time def backup_database(source: str, dest_dir: str, *, keep: int = 7, now: float | None = None) -> pathlib.Path: """Write a timestamped copy of the database to dest_dir, check it, and delete all but the newest `keep` copies.""" dest = pathlib.Path(dest_dir) dest.mkdir(parents=True, exist_ok=True) stamp = time.strftime("%Y%m%d-%H%M%S", time.gmtime(now if now is not None else time.time())) target = dest / f"chatbot-{stamp}.db" src = sqlite3.connect(f"file:{source}?mode=ro", uri=True) # read-only: a backup must never modify the live database try: out = sqlite3.connect(target) try: src.backup(out) finally: out.close() finally: src.close() check = sqlite3.connect(target) try: result = check.execute("PRAGMA integrity_check").fetchone()[0] finally: check.close() if result != "ok": target.unlink() raise RuntimeError(f"the backup failed its integrity check: {result}") target.chmod(0o600) for old in sorted(dest.glob("chatbot-*.db"))[:-keep] if keep > 0 else []: old.unlink() return target def main(argv: list[str]) -> int: parser = argparse.ArgumentParser(description="Back up the chatbot database.") parser.add_argument("source") parser.add_argument("dest_dir") parser.add_argument("--keep", type=int, default=7) args = parser.parse_args(argv) try: print(backup_database(args.source, args.dest_dir, keep=args.keep)) except (sqlite3.Error, RuntimeError, OSError) as e: print(f"backup failed: {e}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))

Three decisions that are yours. Where the copies go: the same disk is not a backup against a dead disk; copy them to another machine or storage. How long: a backup can hold conversations the bot has already deleted, so keep backups no longer than the retention period you told users (the seven daily copies here fit inside 30 days); and be honest that a /deleteme request does not reach copies already made, which is something to say in the privacy notice. Whether it works: Exercise 2 is a restore drill. A backup you have never restored is a hope, not a backup.

Docker

Not run here (the engine was not running); written from Docker's documented instructions. A container gives you the same environment everywhere and an easy way to limit what the bot can touch. The choices in this file are the ones that matter: install from a pinned list (the versions installed in this project), run as an ordinary user, keep data in a volume so it survives the container, and give the platform a health check that needs no extra tools (Python is already there). I did not verify that the base image tag exists; check it on Docker Hub, and rebuild regularly to receive security fixes.

FROM python:3.14-slim ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 CHATBOT_ENV=production CHATBOT_DATA_DIR=/data CHATBOT_HOST=0.0.0.0 WORKDIR /app COPY deploy/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY bot ./bot COPY persona.toml . # run as an ordinary user, never as root RUN useradd --system --uid 10001 --no-create-home chatbot && mkdir /data && chown chatbot /data USER chatbot VOLUME /data EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3).status == 200 else 1)" CMD ["python", "-m", "bot.app"]
fastapi==0.141.1 uvicorn==0.53.0 starlette==1.6.0 httpx==0.28.1 websockets==17.2 cryptography==49.0.0

Run it with the same environment file, and mount a volume; secrets arrive at run time and are never baked in:

docker build -f deploy/Dockerfile -t chatbot . docker run -d --name chatbot --restart unless-stopped \ --env-file /etc/chatbot/chatbot.env \ -v chatbot-data:/data -p 127.0.0.1:8000:8000 chatbot

127.0.0.1:8000:8000 publishes the port only to the machine itself, so the reverse proxy below is the only way in.

systemd on Debian

Without Docker, systemd starts the bot at boot, restarts it on failure and stops it on request (the manager itself is covered in systemd in Depth). Run the bot as its own account (useradd --system --home /opt/chatbot --shell /usr/sbin/nologin chatbot), install the code in /opt/chatbot with a virtual environment, and keep the environment file readable by root only. The hardening directives limit the damage if the bot is ever compromised: no new privileges, a read-only file system except its own state directory, no access to home directories, a private /tmp, and no capabilities. The line ExecStartPre runs the configuration check first, so a bad configuration stops the start with the messages from earlier in this chapter in the journal.

[Unit] Description=Chatbot service After=network-online.target Wants=network-online.target [Service] Type=exec User=chatbot Group=chatbot WorkingDirectory=/opt/chatbot EnvironmentFile=/etc/chatbot/chatbot.env ExecStartPre=/opt/chatbot/venv/bin/python -m bot.app --check ExecStart=/opt/chatbot/venv/bin/python -m bot.app Restart=on-failure RestartSec=5 TimeoutStopSec=30 StateDirectory=chatbot StateDirectoryMode=0700 NoNewPrivileges=true ProtectSystem=strict ProtectHome=true PrivateTmp=true PrivateDevices=true ProtectKernelTunables=true ProtectKernelModules=true ProtectControlGroups=true RestrictSUIDSGID=true RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX LockPersonality=true SystemCallArchitectures=native CapabilityBoundingSet= UMask=0077 [Install] WantedBy=multi-user.target

A daily backup is a second service and a timer; Persistent=true runs a missed backup after the machine was off:

[Unit] Description=Back up the chatbot database [Service] Type=oneshot User=chatbot Group=chatbot WorkingDirectory=/opt/chatbot ExecStart=/opt/chatbot/venv/bin/python -m bot.backup /var/lib/chatbot/chatbot.db /var/backups/chatbot --keep 7 NoNewPrivileges=true ProtectSystem=strict ProtectHome=true PrivateTmp=true ReadWritePaths=/var/backups/chatbot UMask=0077
[Unit] Description=Daily chatbot database backup [Timer] OnCalendar=*-*-* 03:15:00 Persistent=true RandomizedDelaySec=10min [Install] WantedBy=timers.target

The one check I could run:

$ systemd-analyze verify ./chatbot.service ./chatbot-backup.service ./chatbot-backup.timer (systemd 252, Debian 12 under WSL, copies in /tmp) Configuration file /tmp/units/chatbot.service is marked executable. Please remove executable permission bits. Proceeding anyway. chatbot.service: Command /opt/chatbot/venv/bin/python is not executable: No such file or directory chatbot-backup.service: Command /opt/chatbot/venv/bin/python is not executable: No such file or directory (the executable-bit lines are an artefact of copying from the Windows drive; the "No such file" lines are expected, because nothing is installed at /opt/chatbot here. No unknown directives or syntax errors were reported.)

That verification is narrow. It says the files parse, not that the sandbox lets the bot work. The usual surprise with ProtectSystem=strict is a write the bot did not know it was making: the backup unit needed its destination named in ReadWritePaths, which I added deliberately. After installing, run systemd-analyze security chatbot to see how exposed it is, and journalctl -u chatbot to see what it logged. The environment file template:

# Copy to /etc/chatbot/chatbot.env, chmod 600, owner root. Never commit the real file. CHATBOT_ENV=production CHATBOT_PROVIDER=claude CHATBOT_MODEL=<read the current model name from your provider's model list> ANTHROPIC_API_KEY=<your key> # generate with: python -m bot.crypto_keygen (several keys, comma separated, NEWEST FIRST) CHATBOT_CONTENT_KEYS=<fernet key> # generate with: python -c "import secrets; print(secrets.token_urlsafe(48))" CHATBOT_WEB_SECRET=<48+ random characters> CHATBOT_LOG_KEY=<24+ random characters> CHATBOT_ALLOWED_ORIGINS=https://chat.example.org CHATBOT_RETENTION_DAYS=30 CHATBOT_DATA_DIR=/var/lib/chatbot CHATBOT_PERSONA_FILE=/opt/chatbot/persona.toml # optional channels # TELEGRAM_BOT_TOKEN= # TELEGRAM_WEBHOOK_SECRET= # DISCORD_BOT_TOKEN= # SLACK_BOT_TOKEN= # SLACK_SIGNING_SECRET=

Fill in CHATBOT_MODEL yourself from your provider's current list: model names change, and a name I wrote here would be a guess. Create it with install -m 600 -o root so it is never world-readable, even for a moment.

HTTPS and the Reverse Proxy

The bot speaks plain HTTP on the local machine; a reverse proxy faces the internet, holds the certificate, and forwards. This is necessary, not decoration: Telegram's webhook needs HTTPS (chapter 9), Slack's request URL needs HTTPS (chapter 10), and the session cookie is marked Secure so browsers send it only over HTTPS. Not run here: this nginx file was not tested, because nginx is not installed and I did not install it; run nginx -t before reloading. Certificates from Let's Encrypt (certbot) are the usual free route; I did not obtain one.

# /etc/nginx/sites-available/chatbot (certificate paths assume Let's Encrypt / certbot) limit_req_zone $binary_remote_addr zone=chat:10m rate=30r/m; server { listen 80; server_name chat.example.org; return 301 https://$host$request_uri; } server { listen 443 ssl; http2 on; server_name chat.example.org; ssl_certificate /etc/letsencrypt/live/chat.example.org/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/chat.example.org/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; add_header Strict-Transport-Security "max-age=31536000" always; client_max_body_size 64k; location /api/chat { limit_req zone=chat burst=10 nodelay; proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_buffering off; # streamed replies must reach the browser piece by piece proxy_read_timeout 120s; } location / { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } }
  • Streaming. A proxy that buffers responses holds back a streamed reply until it is complete, so the typing effect from chapter 8 disappears. proxy_buffering off on the chat location is the fix, with a long proxy_read_timeout. The smoke test below prints a warning if a reply arrives as a single event.
  • Client address. The proxy sets the forwarding headers from the real connection ($remote_addr), never by passing along what the client claimed, and the bot trusts them only from 127.0.0.1.
  • Limits. limit_req rate-limits per address before a request even reaches the bot (a second layer beside chapter 7's limits), and client_max_body_size rejects oversized bodies early.
  • The firewall should allow 80 and 443 only. The bot's port is not published.

A Smoke Test

The last layer of chapter 11's list: does the deployed bot answer at all? The script checks both health endpoints, loads the page (which sets the cookie), and sends one real chat message the way a browser does. Here it ran against the real service started with py -m bot.app in production mode with the echo provider (a provider that repeats what you say, so no key was needed), on 127.0.0.1 without a proxy. That proves the assembled service works; it proves nothing about your server, the proxy, TLS or a real model.

"""A live smoke test: does the DEPLOYED bot answer at all? py deploy/smoke_test.py https://chat.example.org Checks the health endpoints, loads the page (which sets the session cookie), and sends one chat message through the same path a browser uses. It proves the wiring (proxy, TLS, service, provider) works; it says nothing about answer quality. One real message costs a few tokens on a real provider. """ import json import sys import httpx def main(base: str) -> int: base = base.rstrip("/") failures = [] with httpx.Client(base_url=base, timeout=60, headers={"Origin": base}) as client: for path in ("/healthz", "/readyz"): r = client.get(path) print(f"{path:<10} {r.status_code} {r.text.strip()[:80]}") if r.status_code != 200: failures.append(path) page = client.get("/") print(f"{'/':<10} {page.status_code} CSP: {'yes' if 'content-security-policy' in page.headers else 'MISSING'} cookie: {'yes' if client.cookies else 'MISSING'}") if page.status_code != 200 or not client.cookies: failures.append("page/cookie") pieces = 0 text = "" with client.stream("POST", "/api/chat", json={"message": "Say hello in five words."}) as r: print(f"/api/chat {r.status_code} content-type: {r.headers.get('content-type')}") for line in r.iter_lines(): if line.startswith("data: "): event = json.loads(line[6:]) pieces += 1 text = event.get("text", text) print(f" {pieces} streamed event(s); final reply: {text[:80]!r}") if r.status_code != 200 or not text: failures.append("chat") if pieces < 2: print(" WARNING: the reply arrived as a single event; a proxy may be buffering the stream") print("SMOKE TEST", "FAILED: " + ", ".join(failures) if failures else "PASSED") return 1 if failures else 0 if __name__ == "__main__": sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8000"))
/healthz 200 {"status":"ok"} /readyz 200 {"ready":true,"channels":[]} / 200 CSP: yes cookie: yes /api/chat 200 content-type: text/event-stream; charset=utf-8 2 streamed event(s); final reply: "I'm an AI assistant, not a person. Type /privacy to see what I store.\n\nYou said:" SMOKE TEST PASSED

After a deployment, run it against the public address. It costs a few tokens on a real provider; run it after each deployment, not every minute.

The Runbook

A short document for the day something is wrong, written while you are calm:

SituationFirst lookUsual cause and action
The bot will not startjournalctl -u chatbot -n 50The configuration check printed its problems; fix the environment file. A WrongKey means the content key does not match the database: restore the right key, never "start fresh" over the data.
Users get errorsThe log: provider_call lines with error and statusProvider outage or key or quota problem. The breaker and fallback from chapter 7 should be answering; check the provider's status page.
Replies arrive all at onceThe smoke test warningA proxy buffering the stream; see the nginx section.
Slack or Telegram stopped deliveringThe platform's own delivery view; /readyzCertificate expired, the signing secret changed, or the webhook URL changed. Renew or reset and run --set-webhook.
Cost spikealert lines; token totalsThe daily budget (chapter 7) limits the damage; find the user (by pseudonym) and decide.
Database damaged or deletedPRAGMA integrity_checkStop the service, restore the newest good backup (Exercise 2), start, run the smoke test.
A key or token leakedWhen and whereRevoke the platform token first and issue a new one; rotate the content key (put the new key first, run rotate, then retire the old one). If personal data may have been exposed, the breach duties in your jurisdiction apply: that is a decision for you and, probably, an adviser.
A user asks for their dataThe commands from chapter 6/privacy, the export, /deleteme. Remember copies in backups and pseudonymised log lines expire on their own schedule.
Mistakes while building this
  • "Encrypted" rows, plain bytes. Described above: the old plain text survived inside the file until a VACUUM. What went wrong: I assumed that rewriting each value replaced it on disk. How to avoid it: test security properties against the raw bytes of the file, not against what the API returns; the test that looked at the file caught it.
  • A summary that told the truth about the wrong thing. The production summary showed development defaults as "set". Avoid it by deriving what you print from the same values the program will use, and by reading the output, not just the exit code.
  • A test that left a file open. One service test built a service it never started, so nothing closed its database, and on Windows the temporary directory could not be deleted. Takeaway: whatever opens a resource in a test also closes it, and the failure was a useful reminder that on Linux the same leak would be silent.
  • Killing the wrong thing. After the smoke test I looked for the process I had started and first used the process id the shell reported, which is not the Windows one, so the stop failed with "cannot find a process". I then matched it by its start time. Takeaway: identify a process by something you can verify (here, its start time and path) before stopping it, and never stop processes by name.

Checklist: Verified and Not

ItemStatus in this course
Core logic, memory, guardrails, limits, reliability, observability290 tests, fakes and local servers
Web chat in a real browser (headless Chrome)Run, chapter 8
Assembled service as a real process, smoke testRun, this chapter
Encryption, rotation, backup and restoreRun and tested, this chapter
systemd unit syntaxChecked with systemd-analyze verify; not started
Dockerfile, nginx, TLSNot run here
Real SIGTERM shutdown on LinuxNot run here
Any real model (Claude, Grok, OpenAI, Ollama)Not run here: no keys or local model were available; the code follows each vendor's documentation as I read it in the earlier chapters
Telegram, Discord and Slack against the real platformsNot run here: simulated with local servers and recorded-shape messages
Legal compliance (UK GDPR, platform terms)Technical measures only; not legal advice

Hands-On Exercises

Exercise 1

Write a deliberately broken production environment (a bad port, a bad content key, a short web secret, Slack half-configured, a real provider with no key) and show every problem reported in one pass. Then fix them in groups and show the count fall to zero.

๐Ÿ“„ View solution
Exercise 2

Run a restore drill: fill an encrypted database, back it up, delete the live file, restore from the backup, check integrity and message count, read a message with the right key, and show what the wrong key does.

๐Ÿ“„ View solution
Exercise 3

Prove the shutdown sequence: run the assembled service in-process, send it a request, leave the block, then show that the database is closed and the log contains started, stopping and stopped in order. Say what this does not prove.

๐Ÿ“„ View solution

Chapter 12 Quick Reference

  • Configuration from environment variables; validate at start-up, report all problems, refuse insecure production settings, never print secrets (py -m bot.app --check)
  • Encrypt message text in the application (Fernet, MultiFernet for rotation); key kept apart from data and backups; metadata and lengths stay visible; VACUUM after rewriting; test against the raw file
  • /healthz = process alive (restart me); /readyz = database reachable (send me traffic)
  • Shutdown order: stop tasks, drain in-flight replies (bounded), close clients, close the database last
  • Backups: SQLite online backup, integrity check, keep N, shorter than your retention promise, stored elsewhere, restore drill
  • Docker: pinned dependencies, non-root, volume for data, health check, bind the port to localhost
  • systemd: own user, EnvironmentFile (mode 600), ExecStartPre config check, hardening directives, restart on failure, timer for backups
  • Proxy: TLS, forwarding headers, proxy_buffering off for streams, rate and body limits, firewall to 80/443 only
  • After every deploy: smoke test. Keep a runbook. Label what you have not tested

Course Complete: What You Can Now Do

ChapterYou can now…
1 Anatomy of a BotExplain the parts a bot adds to a model call and how a message travels through them
2 Provider InterfacePut Claude and OpenAI-compatible models (Grok, OpenAI, Ollama) behind one interface with typed errors and streaming
3 Local ModelsReason about running a model locally and what it costs you
4 MemoryKeep history, a token budget, summaries and user facts, safely
5 Persona and GuardrailsWrite a persona and test that it holds, with input and output guards
6 Moderation and PrivacyModerate, handle crisis messages, honour privacy requests and retention
7 Cost and ReliabilityLimit usage and spending; retry, break and fall back
8 Web ChatServe a streaming chat page with sessions and CSRF protection
9 TelegramRun the same core on Telegram, with polling or webhook
10 Discord and SlackAdd two more channels without touching the core
11 Test and ObserveLog without leaking, measure, alert, and test with golden conversations and simulations
12 DeployConfigure, encrypt, back up, containerise, run as a service, put it behind HTTPS, and say honestly what you have verified

Where to go next: run chapter 12's checks on your own server and fix what the "not run here" items turn up; try the real-model scenarios from chapter 5 with a real key and look at the failure rate; and read the evaluation methods in Advanced AI Workflows & the Claude API 9, since nothing in this course says whether the bot's answers are any good. For the service layer, systemd in Depth and the Docker and web-server courses cover what this chapter only used.