Skip to content

πŸ•΅οΈ ClaudeNames

claudenames_banner

Rust β€” axum + tokio React β€” TypeScript + Vite PostgreSQL PWA β€” offline-first Version 1.0.0

ClaudeNames is a self-hosted website to play Codenames online with friends, in French. A player creates a room, shares its code, the others join with a nickname, split into the red and blue teams, and play on a 5Γ—5 board of character cards.

Introduction

The goal of ClaudeNames is to play the classic Codenames party game in the browser with no setup for the players: no accounts, no app β€” a room code and a nickname are enough. Spymasters see the key card and give one-word + number clues, guessers flip the cards, and the site keeps everyone's view in sync live; boards are dealt from picture packs of characters instead of plain words.

This page covers installing and running the application as a self-contained web service.

claudenames_front

Info

ClaudeNames is open-source and lives in this repository under src/ (Rust backend + React/TypeScript frontend), with the Podman packaging in build/, quadlet/ and nginx/.

Features

  • 🎴 Classic Codenames rules: two teams, 25 character cards, one-word + number clues, the assassin ends the game
  • πŸšͺ Anonymous rooms β€” create, share the 6-letter code, join with a nickname; no player accounts, French UI
  • ⚑ Live board over WebSockets: clues, flips and turn changes appear instantly on every player's screen
  • πŸ–ΌοΈ Character packs managed on the /admin page: drag-and-drop batch uploads with a live progress bar, untouched originals + generated WebP board thumbnails and grid previews, inline renames, per-series generation prompt
  • 🎯 Per-room cast: the room creator picks whole series and/or individual characters β€” with random (or admin-pinned) preview pictures on every series card β€” and the room's 25 characters are drawn there and then, frozen, and shown to every player in the lobby
  • πŸ•΅οΈ Dedicated spymaster view with color-ringed cards and a skull-marked assassin
  • πŸŒ“ Light "espionage bureau" and dark modern themes plus five typographic presets, switchable per browser
  • πŸ” Invite-only admin accounts with emailed activation links and optional TOTP 2FA β€” administration only, players never sign up, and each admin may delete their own account from the SΓ©curitΓ© tab, never the last one
  • πŸ“΄ Local-first PWA: signed-in admins keep the series and characters β€” rows and grid previews β€” on the device, open them instantly even offline, and stay current through the platform's live sync; playing needs a connection (offline & sync)

How it works

Web UI players: no sign-in admins: the pack, offline axum API serves the SPA per-room channels PostgreSQL rooms Β· games players Β· series characters Β· admins REST Β· sync SSE WebSocket /api/rooms/*/ws data/characters originals thumbnails Β· previews an admin's batch upload streams its progress over SSE
Games run over per-room WebSockets; the admins' pack syncs like any collection.

The backend binary serves both the API and the built React SPA. Rooms, games, players, admin accounts and the series/character collection live in PostgreSQL; the pictures are stored as files under the data directory β€” the untouched original next to its generated WebP thumbnail (board cards and zooms) and the lighter preview the character grids load, all three named after the character id β€” so the database stays small. Administrators fill the pack from the /admin page: batch uploads stream per-file conversion progress over SSE, renames only touch the database, and deleting a character (or a whole series) removes its files too. Every game mutation is pushed over per-room WebSocket channels, so spymasters and guessers each keep their own live view of the same board.


πŸ“₯ Installation

This section describes how to install and run Podman services using systemd Quadlet, enabling containers to restart automatically. It allows the service to run in its own isolated rootless environment with a dedicated Linux user.

πŸ“‹ Requirements

Info

ClaudeNames requires the installation of


βš™οΈ Configuration

πŸ“‹ Service Setup

Define Service Variables

# define the service name
SERVICE_NAME=claudenames
Initialize Service Environment
# built-in bash safety and SERVICE_NAME validation
set -euo pipefail; [ -n "${SERVICE_NAME:-}" ] || { echo "SERVICE_NAME is empty"; exit 1; }

# automatically generate variables
SERVICE_HOME="/media/ssd/podman-users/${SERVICE_NAME}"
SERVICE_DIR="/media/ssd/podman/${SERVICE_NAME}"
SERVICE_USER="${SERVICE_NAME}_svc"
SERVICE_ADMIN="${SERVICE_NAME}_admins"

πŸ” Service Isolation

Configure Rootless Podman UID/GID Namespace Mappings
# allocate rootless Podman UID/GID mappings if missing
SUBID_SIZE=65536
allocate_subids_if_missing() {
  local user="$1"
  local start
  if ! grep -q "^${user}:" /etc/subuid; then
    start="$(awk -F: 'BEGIN { max = 100000 } { end = $2 + $3; if (end > max) max = end } END { print max }' /etc/subuid)"
    sudo usermod --add-subuids "${start}-$((start + SUBID_SIZE - 1))" "$user"
  fi
  if ! grep -q "^${user}:" /etc/subgid; then
    start="$(awk -F: 'BEGIN { max = 100000 } { end = $2 + $3; if (end > max) max = end } END { print max }' /etc/subgid)"
    sudo usermod --add-subgids "${start}-$((start + SUBID_SIZE - 1))" "$user"
  fi
}
allocate_subids_if_missing "${SERVICE_USER}"

# verify rootless Podman UID/GID namespace mappings exist
grep -q "^${SERVICE_USER}:" /etc/subuid || { echo "ERROR: missing subuid mappings for ${SERVICE_USER}"; exit 1; }
grep -q "^${SERVICE_USER}:" /etc/subgid || { echo "ERROR: missing subgid mappings for ${SERVICE_USER}"; exit 1; }

# validate that all subordinate ID ranges in a file are non-overlapping
validate_subid_ranges() {
  local file="$1"
  awk -F: '
    {
      start = $2
      end   = $2 + $3 - 1
      for (i = 1; i <= count; i++) {
        if (start <= ends[i] && end >= starts[i]) {
          printf "ERROR: overlapping ranges in %s\n", FILENAME
          printf "  %s:%s:%s overlaps %s:%s:%s\n",
            $1, $2, $3,
            names[i], starts[i], sizes[i]
          exit 1
        }
      }
      count++
      names[count]  = $1
      starts[count] = start
      ends[count]   = end
      sizes[count]  = $3
    }
  ' "$file"
}
validate_subid_ranges /etc/subuid
validate_subid_ranges /etc/subgid

# configure the rootless storage driver BEFORE Podman first initializes its storage.
# fuse-overlayfs is a reliable rootless overlay backend and avoids kernel-version
# quirks of native rootless overlay on ARM/Armbian. It is optional with the default
# userns used here, but recommended; set it before the first storage init so no
# 'podman system reset' is needed later.
sudo -u "${SERVICE_USER}" mkdir -p "${SERVICE_HOME}/.config/containers"
sudo -u "${SERVICE_USER}" tee "${SERVICE_HOME}/.config/containers/storage.conf" >/dev/null <<'EOF'
[storage]
driver = "overlay"

[storage.options.overlay]
mount_program = "/usr/bin/fuse-overlayfs"
EOF

# rebuild rootless Podman state to ensure existing containers/storage use current namespace mappings
# use a login shell (-i) so HOME points at the service user's home
# (storage now initializes with fuse-overlayfs; no 'podman system reset' needed)
sudo -iu "${SERVICE_USER}" podman system migrate
Configure Secure Service Directory Permissions
# Apply the admin/service ACL policy to one directory tree, deterministically.
#   $1 = tree to treat
#   $2 = (optional) a sub-path to skip entirely (e.g. a container data subtree)
#
# Why setfacl, not chmod: on an ACL'd tree, chmod edits only the mask, never group::,
# so stale execute bits survive. setfacl sets every entry explicitly (mask included),
# lowercase β€” the result is reproducible and no execute bit can resurface.
apply_acl_tree() {
    local tree="$1"
    local skip="${2:-}"
    local prune=()
    [ -n "$skip" ] && prune=( -path "$skip" -prune -o )                                         # skip this subtree if a 2nd arg is given

    sudo find "$tree" "${prune[@]}" -exec chown "${SERVICE_USER}:${SERVICE_ADMIN}" {} +         # service user owns; admin group is the owning group

    # directories β€” access ACL (perms on the existing dirs)
    sudo find "$tree" "${prune[@]}" -type d -exec setfacl    -m g:${SERVICE_ADMIN}:rwx {} +     # admin group: access existing dirs
    sudo find "$tree" "${prune[@]}" -type d -exec setfacl    -m u:${SERVICE_USER}:rwx {} +      # service user: access existing dirs
    sudo find "$tree" "${prune[@]}" -type d -exec setfacl    -m u::rwx,g::rwx,o::-,m::rwx {} +  # owner/group rwx, deny others, pin mask rwx

    # directories β€” default ACL (inherited by NEW files/dirs created later)
    sudo find "$tree" "${prune[@]}" -type d -exec setfacl -d -m g:${SERVICE_ADMIN}:rwx {} +     # admin group: inherit access on new items
    sudo find "$tree" "${prune[@]}" -type d -exec setfacl -d -m u:${SERVICE_USER}:rwx {} +      # service user: inherit access on new items
    sudo find "$tree" "${prune[@]}" -type d -exec setfacl -d -m u::rwx,g::rwx,o::- {} +         # default owner/group/other for new items

    # files β€” access ACL (rw only, never executable)
    sudo find "$tree" "${prune[@]}" -type f -exec setfacl    -m g:${SERVICE_ADMIN}:rw {} +      # admin group: access existing files
    sudo find "$tree" "${prune[@]}" -type f -exec setfacl    -m u:${SERVICE_USER}:rw {} +       # service user: access existing files
    sudo find "$tree" "${prune[@]}" -type f -exec setfacl    -m u::rw,g::rw,o::-,m::rw {} +     # owner/group rw, deny others, pin mask rw

    sudo find "$tree" "${prune[@]}" -type d -exec chmod g+s {} +                                # setgid: new items inherit the group (a MODE bit; ACLs can't set it)
}

# --- SERVICE_HOME: Podman's private runtime home ------------------------------
# Own the home + set setgid, then ACL ONLY the .config subtree (containers.conf,
# storage.conf, and the *.network / *.container quadlets you hand-edit) so admins
# can edit them sudo-less.
#
# Leave ~/.local (storage) and ~/.cache to Podman: ACLs on the overlay store break
# the runtime ("OCI permission denied" on the `merged` mount). Admin reaches them via sudo.
sudo chown "${SERVICE_USER}:${SERVICE_ADMIN}" "${SERVICE_HOME}"                       # service user owns; admin group is the owning group
sudo chmod u=rwx,g=rwx,o=,g+s "${SERVICE_HOME}"                                       # owner/admin access; deny others; inherit group on new items
apply_acl_tree "${SERVICE_HOME}/.config"                                              # .config subtree: full sudo-less admin ACL treatment

# --- bind-mount data dirs: created + owned BEFORE the SERVICE_DIR ACL pass ---------
# Each container writes as a mapped subuid, so its data dir must be owned by that subuid
# (chown as root; only root crosses the id range). We create + own them FIRST so they
# neither inherit SERVICE_DIR's default ACL (inheritance happens at creation) nor get
# walked by apply_acl_tree (which prunes data/).
#
# No ACLs here: Postgres refuses a group-accessible PGDATA. Admin reaches them via sudo;
# back Postgres up with pg_dumpall, not a raw file copy.
SUBUID_BASE="$(awk -F: -v u="${SERVICE_USER}" '$1==u {print $2}' /etc/subuid)"
SUBGID_BASE="$(awk -F: -v u="${SERVICE_USER}" '$1==u {print $2}' /etc/subgid)"

# data subdir : in-container uid/gid it must be owned by
data_dirs=(
    "postgres-db:70"          # /var/lib/postgresql β€” postgres user (pinned User=70:70)
    "characters:1000"         # /data/characters    β€” pictures: originals/ + thumbnails/ + previews/ (image USER 1000)
)

mkdir -p "${SERVICE_DIR}/data"                                                        # the data/ parent: plain, no ACL
sudo chown "${SERVICE_USER}:${SERVICE_ADMIN}" "${SERVICE_DIR}/data"                   # owner/admin
sudo chmod 0750 "${SERVICE_DIR}/data"                                                 # children are set individually below
for entry in "${data_dirs[@]}"; do
    sub="${entry%%:*}"                                  # subdir name
    ids="${entry#*:}"                                   # "uid" or "uid:gid"
    uid="${ids%%:*}"                                    # uid
    gid="${ids#*:}"; [ "$gid" = "$ids" ] && gid="$uid"  # gid (defaults to uid)
    dir="${SERVICE_DIR}/data/${sub}"
    sudo mkdir -p "$dir"
    sudo chown -R "$((SUBUID_BASE + uid - 1)):$((SUBGID_BASE + gid - 1))" "$dir"   # map both to host subids
    sudo chmod 0700 "$dir"
done

# --- SERVICE_DIR: application data, env files, configuration -------------------
# Full treatment so the admin group gets sudo-less access AND the service user keeps
# access even on admin-created files. data/ is EXCLUDED: those dirs are owned by the
# containers' subuids and need engine-specific modes (Postgres rejects a group/other-
# accessible PGDATA), so they are handled separately just below.
apply_acl_tree "${SERVICE_DIR}" "${SERVICE_DIR}/data"                                 # treat everything EXCEPT data/
Configure Secure NGINX Static Assets Permissions
# Grant the NGINX worker user (www-data) read-only access to assets subtrees.
#   $1     = service directory β†’ traverse-only (x)
#   $2..$n = assets subtrees served directly by NGINX (configs, icons, favicon, manifest, ...)
apply_acl_nginx() {
    local tree="$1"; shift
    local assets

    sudo setfacl -m u:www-data:x "$tree"                                        # traverse only: pass through, no listing, no reading

    for assets in "$@"; do
        # assets directories β€” enter + list, existing and future
        sudo find "$assets" -type d -exec setfacl    -m u:www-data:rx {} +      # www-data: read existing dirs
        sudo find "$assets" -type d -exec setfacl -d -m u:www-data:rx {} +      # www-data: inherit read on new items

        # assets files β€” www-data read-only, owner/admin rw, never executable.
        # Entries AND mask are pinned explicitly: a bare `setfacl -m u:www-data:r`
        # would recalculate the mask to the union of all entries, resurfacing the
        # latent rwx inherited from the default ACL (files would show group rwx).
        sudo find "$assets" -type f -exec setfacl \
          -m u:www-data:r,u:${SERVICE_USER}:rw,g:${SERVICE_ADMIN}:rw,u::rw,g::rw,o::-,m::rw {} +
    done
}

# --- SERVICE_DIR/nginx: served directly by NGINX -----------
# Created AFTER the apply_acl_tree pass so they inherit the default ACL + setgid
# group (service user/admin keep full access); www-data is then layered on top
# as a read-only named entry.
sudo mkdir -p "${SERVICE_DIR}/nginx"
sudo chown "${SERVICE_USER}:${SERVICE_ADMIN}" "${SERVICE_DIR}/nginx"
apply_acl_nginx "${SERVICE_DIR}" "${SERVICE_DIR}/nginx"

πŸ›‘οΈ Rootless Podman Defaults

Configure rootless Podman per-user configuration directory
# configure rootless Podman defaults for this service user
# - store per-user Podman configuration under ~/.config/containers
sudo -u "${SERVICE_USER}" mkdir -p "${SERVICE_HOME}/.config/containers/systemd"
Configure rootless Podman defaults
# configure rootless Podman defaults for this service user
# - use k8s-file logging for easier log rotation and inspection
sudo -u "${SERVICE_USER}" tee "${SERVICE_HOME}/.config/containers/containers.conf" >/dev/null <<EOF
[containers]
log_driver = "k8s-file"

[engine]
healthcheck_events=false
EOF

πŸ”§ Service Configuration

Setup ClaudeNames Parameters

Before deploying, you need to define a few environment variables that will be used throughout the setup process.

  • BASE_URL: public URL where the web service is accessible (used to build the emailed activation links)
  • HOST_PORT: external port used by NGINX to route traffic to the service
  • ADMIN_EMAIL: the default admin account (email only β€” there is no admin password in the env)
  • SMTP settings: required so the admin and invited admins receive their activation links (startup aborts if the relay is unreachable)
###################################################################################
# NGINX Proxy Configuration
###################################################################################
HOST_PORT=10065
###################################################################################
# Postgres Configuration
###################################################################################
PG_VERSION=18-alpine
PG_DB=claudenames
PG_USER=claudenames
PG_PASSWORD="$(openssl rand -hex 32)"
###################################################################################
# ClaudeNames Configuration
###################################################################################
BASE_URL=https://claudenames.domain.fr
ADMIN_EMAIL=admin@domain.fr
###################################################################################
# SMTP Configuration
###################################################################################
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURITY=starttls
SMTP_USER=claudenames@domain.fr
SMTP_PASSWORD=your-smtp-password
SMTP_FROM=claudenames@domain.fr

Getting Your Gmail SMTP Password

To send emails via Gmail SMTP, you'll need to generate an App Password.
A special password used for third-party applications.
Visit myaccount.google.com/apppasswords to create one.

Use the generated password in the SMTP_PASSWORD field of your configuration.

Create the Environment file
sudo -u "${SERVICE_USER}" tee "${SERVICE_DIR}/postgres.env" >/dev/null <<EOF
###################################################################################
# Postgres Database Configuration
###################################################################################
POSTGRES_VERSION=${PG_VERSION}
POSTGRES_DB=${PG_DB}
POSTGRES_USER=${PG_USER}
POSTGRES_PASSWORD=${PG_PASSWORD}
EOF
sudo -u "${SERVICE_USER}" tee "${SERVICE_DIR}/claudenames.env" >/dev/null <<EOF
###################################################################################
# NGINX Proxy Configuration
###################################################################################
HOST_PORT=${HOST_PORT}

###################################################################################
# Base image versions (build)
###################################################################################
RUST_VERSION=1.98.0
NODE_VERSION=24
DEBIAN_TAG=trixie-slim

###################################################################################
# ClaudeNames Configuration
###################################################################################
CLAUDENAMES_BASE_URL=${BASE_URL}
CLAUDENAMES_COOKIE_SECURE=true

###################################################################################
# Default admin (seeded 'pending' on first start)
###################################################################################
CLAUDENAMES_ADMIN_EMAIL=${ADMIN_EMAIL}

###################################################################################
# SMTP (required β€” activation emails; startup fails without a reachable relay)
###################################################################################
SMTP_HOST=${SMTP_HOST}
SMTP_PORT=${SMTP_PORT}
SMTP_SECURITY=${SMTP_SECURITY}
SMTP_USER=${SMTP_USER}
SMTP_PASSWORD=${SMTP_PASSWORD}
SMTP_FROM=${SMTP_FROM}

# Optional (defaults in parentheses):
#   CLAUDENAMES_SESSION_TTL_HOURS (720)     admin session lifetime
#   CLAUDENAMES_ACTIVATION_TTL_HOURS (72)   activation link lifetime
#   CLAUDENAMES_MAX_UPLOAD_MB (10)          per-image upload size cap
EOF

Keep the .env files

All the secret informations will be stored in the .env files.

🧩 Quadlet Service

Install the Quadlet units
# the units live in the repository under quadlet/ β€” every .build unit already
# carries the platform build context:
#   PodmanArgs=--build-context platform=/media/ssd/podman/platform
# link them into the service user's systemd directory (the generator and
# podmanctl read them there). Re-runnable: every existing link into quadlet/
# is dropped first β€” stale or dangling ones included β€” then the current set
# is linked, replacing a plain file of the same name if one is left over.
# find runs from / : GNU find returns to its starting directory when done, and a
# directory the service user cannot enter (the admin's home, mode 700) fails it β€” AFTER
# the delete, so a `set -e` shell would stop before relinking
(cd / && sudo -u "${SERVICE_USER}" find "${SERVICE_HOME}/.config/containers/systemd" -maxdepth 1 -type l -lname "${SERVICE_DIR}/quadlet/*" -delete)
sudo -u "${SERVICE_USER}" ln -sf "${SERVICE_DIR}"/quadlet/* "${SERVICE_HOME}/.config/containers/systemd/"

Symlinks, so a pull that edits a unit is live at the next systemctl --user daemon-reload (podmanctl --update reloads after its pull). A pull that adds, renames or removes a unit needs the two lines again, then the reload β€” quadlet changes are rare and deliberate.

Enable and Start Quadlet Services
# open an interactive shell as the service user
sudo -iu "${SERVICE_USER}"

# reload systemd user units
systemctl --user daemon-reload

# build the claudenames image (first install; podmanctl --update rebuilds afterwards)
systemctl --user start claudenames-build.service

# start Podman Quadlet services
systemctl --user start claudenames-server.service

# verify service status
systemctl --user status claudenames-server.service

To follow the service logs in real time, run sudo journalctl _UID=$(id -u ${SERVICE_USER}) -f from the Debian account.


πŸš€ Deploy ClaudeNames

Install NGINX

NGINX needs to be installed, follow the NGINX section.

Configure NGINX

NGINX needs to be configured using a file in /etc/nginx/sites-enabled directory.
This configuration file specify the documentation path:

# Brute-force guard for the admin auth endpoints: login and activate are
# reachable unauthenticated and burn argon2 CPU, so cap them per client.
# $limit_key is the shared key from nginx.conf: the address for IPv4, the /64
# prefix for IPv6 β€” keying on a full IPv6 address would let one subscriber
# prefix mint 2^64 empty buckets and bypass the limit entirely.
limit_req_zone $limit_key zone=claudenames_auth:10m rate=10r/m;

# Per-client bucket for the liveness probe. Its own zone, NOT a share of the
# auth one: while offline the app probes /api/health every 15 s per tab, and at
# the auth zone's 10r/m that traffic ate the bucket a login needs β€” a household
# with a few tabs open could 429 its own sign-in. Generous on purpose (the app
# probes only while it believes it is offline); the point is a ceiling, not a brake.
limit_req_zone $limit_key zone=claudenames_health:10m rate=60r/m;

# Per-client connection buckets for the two platform SSE streams β€” long-lived by
# design (one per open tab), so they are capped per source IP rather than
# rate-limited. The app caps the total itself; these only stop one client from
# holding every worker connection.
limit_conn_zone $limit_key zone=claudenames_events:10m;
limit_conn_zone $limit_key zone=claudenames_sync_events:10m;

# The app container, published on the loopback by its Quadlet unit. It sits
# behind an upstream block for one reason: `keepalive`. Without it nginx opens β€”
# and immediately closes β€” a brand-new TCP connection for EVERY proxied request,
# and on this host each of those crosses rootless Podman's userspace port
# forwarder. One room board is 25 card pictures on top of the SPA assets and the
# API calls, so a single page load paid ~30 handshakes through that forwarder and
# the cards trickled in one at a time. `proxy_http_version 1.1` (already set in
# every location) and `proxy_set_header Connection ""` are what let a connection
# go back into this pool instead of being torn down.
upstream claudenames_app {
  server 127.0.0.1:10065;
  keepalive 32;
  keepalive_requests 1000;
  keepalive_timeout 60s;
}

server {
  server_name claudenames.domain.fr;

  # Security headers; `always` also covers the NGINX-served error pages. No
  # Content-Security-Policy here: the app serves its own on every response
  # (src/backend/src/web.rs). Browsers enforce EVERY CSP header they receive,
  # so a second, stricter proxy policy would silently break features β€” keep
  # the app's CSP the single source of truth.
  add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
  add_header X-Content-Type-Options "nosniff" always;
  add_header X-Frame-Options "DENY" always;
  add_header Referrer-Policy "strict-origin-when-cross-origin" always;

  # The app sends these four itself (platform-http's security headers, HSTS
  # once its cookies are Secure): hidden here, so each reaches the browser once,
  # with the values above. A browser keeps only the FIRST HSTS header it gets,
  # and the app's (one year) came first.
  proxy_hide_header Strict-Transport-Security;
  proxy_hide_header X-Content-Type-Options;
  proxy_hide_header X-Frame-Options;
  proxy_hide_header Referrer-Policy;

  # setup 404 error_page
  error_page 404 /404.html;
  include snippets/error-404.conf;

  # show maintenance page when backend is down
  error_page 502 503 504 /maintenance.html;
  include snippets/error-maintenance.conf;

  # The backend answers unknown /api/* paths with a JSON 404 and every other path
  # with the SPA (200), so the only 404 that ever reaches us from upstream is an
  # API error. proxy_intercept_errors is on globally, which would swap that JSON
  # body for the HTML page and leave the UI parsing markup as JSON. NGINX's own
  # errors are unaffected β€” an unreachable container still gets the maintenance
  # page, because NGINX generates that 502 itself rather than proxying it.
  proxy_intercept_errors off;

  # Room WebSockets: the live-update stream every player keeps open. The
  # Upgrade/Connection pair is required for the handshake, and the long read
  # timeout keeps idle (but healthy) sockets from being cut after 60s.
  location ~ ^/api/rooms/[^/]+/ws$ {
    proxy_pass http://claudenames_app;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    include proxy_params;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Port $server_port;
    proxy_read_timeout 1h;
    proxy_send_timeout 1h;
    proxy_redirect off;
  }

  # Default ceiling for the whole vhost. Every route but the batch upload and
  # the sync round trip below posts small JSON, so 1 MB is generous β€” and it matters that this is the default:
  # nginx buffers a request body to disk BEFORE opening the upstream connection,
  # so a server-wide 1g would let an unauthenticated client spool a gigabyte to
  # client_body_temp against any path and fill the proxy host's disk long before
  # the app returns 401.
  client_max_body_size 1m;

  # Admin batch image uploads: one multipart request can carry a whole series,
  # far beyond the default 1m body cap (the app enforces the real per-file
  # limit). `proxy_request_buffering off` streams it straight through, so the
  # admin check runs after a few kilobytes rather than after a whole gigabyte
  # has been written to the proxy's disk. Everything else keeps the strict default.
  location ~ ^/api/series/[^/]+/characters$ {
    client_max_body_size 1g;
    proxy_request_buffering off;
    proxy_pass http://claudenames_app;
    proxy_http_version 1.1;
    include proxy_params;
    # Empty Connection header: keeps the pooled upstream connection alive.
    proxy_set_header Connection "";
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Port $server_port;
    proxy_redirect off;
  }

  # Upload progress stream (SSE): buffering would hold the events back until
  # the response ends, defeating the live progress bar.
  location ~ ^/api/uploads/[^/]+/events$ {
    proxy_pass http://claudenames_app;
    proxy_http_version 1.1;
    include proxy_params;
    # Empty Connection header: keeps the pooled upstream connection alive.
    proxy_set_header Connection "";
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Port $server_port;
    proxy_buffering off;
    proxy_read_timeout 1h;
    proxy_send_timeout 1h;
    proxy_redirect off;
  }

  # SSE liveness stream β€” long-lived by design, so it is capped per source IP
  # and must not be buffered (buffering would swallow the pings and the app
  # would call itself offline). 16: a normal browser's tab count with room for
  # a reload overlapping the stream it replaces; under HTTP/2 nginx counts each
  # concurrent request as a connection.
  location = /api/events {
    limit_conn claudenames_events 16;
    limit_conn_status 429;

    proxy_pass http://claudenames_app;
    proxy_http_version 1.1;
    include proxy_params;
    # Empty Connection header: keeps the pooled upstream connection alive.
    proxy_set_header Connection "";
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Port $server_port;
    proxy_redirect off;
    proxy_buffering off;
    proxy_read_timeout 1h;
  }

  # SSE change stream (the platform's sync contract) β€” the same long-lived,
  # unbuffered treatment as the liveness stream (buffering would hold the
  # `sync` events until the connection closed, which is never), but
  # authenticated and on its own connection bucket. An exact-match location: it
  # would otherwise fall through to `location /` and be buffered there.
  location = /api/events/sync {
    limit_conn claudenames_sync_events 8;
    limit_conn_status 429;

    proxy_pass http://claudenames_app;
    proxy_http_version 1.1;
    include proxy_params;
    # Empty Connection header: keeps the pooled upstream connection alive.
    proxy_set_header Connection "";
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Port $server_port;
    proxy_redirect off;
    proxy_buffering off;
    proxy_read_timeout 1h;
  }

  # The sync round trip. The character pack's collections are read-only
  # mirrors, so today's pushes are empty and the vhost's 1 MB would do β€” this
  # location keeps the ceiling in step with the app's own (web.rs) so a future
  # writable collection's 500-change batch never meets a 413 that would count
  # as a failed round and keep a phone's outbox waiting.
  location = /api/sync {
    client_max_body_size 8m;

    proxy_pass http://claudenames_app;
    proxy_http_version 1.1;
    include proxy_params;
    # Empty Connection header: keeps the pooled upstream connection alive.
    proxy_set_header Connection "";
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Port $server_port;
    proxy_redirect off;
  }

  # Every unauthenticated auth endpoint the platform router mounts is listed
  # (contracts/auth.md: a public route the router gains is added here), and
  # rate-limited (see the zone above): 429 beyond 10 req/min + burst 5 per IP,
  # the proxy's layer in front of the engine's own lockouts.
  # `activation/<token>` is included and is why the pattern has no trailing `$`
  # β€” it carries the token in the path, runs a DB query per call and returns
  # the invitee's email on a hit, and the app applies no throttle of its own
  # there. Any other route left out of this list has no nginx limit at all.
  # This regex location wins over `location /`.
  location ~ ^/api/auth/(login|activate|forgot-password|confirm-email|activation/) {
    limit_req zone=claudenames_auth burst=5 nodelay;
    limit_req_status 429;
    proxy_pass http://claudenames_app;
    proxy_http_version 1.1;
    include proxy_params;
    # Empty Connection header: keeps the pooled upstream connection alive.
    proxy_set_header Connection "";
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Port $server_port;
    proxy_redirect off;
  }

  # Liveness probe β€” public because the UI seeds its status dot with it, but
  # every hit runs a DB query against a 5-connection pool, so it is rate-limited:
  # a flood would otherwise saturate the pool and stall real requests. The
  # container healthcheck probes the same route with HealthOnFailure=kill, so a
  # saturated pool means podman kills the container, Restart=on-failure brings it
  # back into the same flood, and after StartLimitBurst cycles the unit stays down.
  # Its own zone rather than the auth one it used to share β€” see that zone's
  # comment: the app's own offline probing was spending the login budget.
  location = /api/health {
    limit_req zone=claudenames_health burst=20 nodelay;
    limit_req_status 429;

    proxy_pass http://claudenames_app;
    proxy_http_version 1.1;
    include proxy_params;
    # Empty Connection header: keeps the pooled upstream connection alive.
    proxy_set_header Connection "";
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Port $server_port;
    proxy_redirect off;
  }

  # reverse proxy
  location / {
    proxy_pass http://claudenames_app;

    # keep it HTTP/1.1
    proxy_http_version 1.1;

    # forwarded headers. proxy_params APPENDS to the client's own
    # X-Forwarded-For, so a caller can prepend whatever hops it likes; NGINX is
    # the edge here and there is no upstream chain worth keeping, so overwrite
    # it with the real peer and the app can never be handed a forged hop.
    include proxy_params;
    # Empty Connection header: keeps the pooled upstream connection alive.
    proxy_set_header Connection "";
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Port $server_port;
    proxy_redirect off;
  }
}
# restart nginx
sudo nginx -t && sudo service nginx restart

Replace claudenames.domain.fr by the name of your website, and 10065 by HOST_PORT.

Activate HTTPS

To activate HTTPS protocol, follow the Let's Encrypt section.

First login β€” activate the admin account

Players never sign up β€” a room code and a nickname are all they need. The admin account (used to manage the series/characters packs on /admin) is created pending on first start (from CLAUDENAMES_ADMIN_EMAIL).
There is no admin password in claudenames.env. To activate it:

  1. On first start, the server e-mails an activation link to CLAUDENAMES_ADMIN_EMAIL (SMTP must be reachable β€” startup aborts otherwise).
  2. Open the /activate/:token link from the e-mail, choose a password, and you are signed in.
  3. The link is single-use and expires after CLAUDENAMES_ACTIVATION_TTL_HOURS (72 h by default).

Accounts are invite-only and admin-only β€” there is no self-registration and no player accounts. Further admins are invited from the Comptes tab of the /admin page the same way (activation link by e-mail), and a pending account's link can be re-sent from there at any time. The character packs are then managed on the SΓ©ries tab: create a series, drag-and-drop its images (one image = one character, named after the file), and pin up to 4 pictures as the series' home-page preview.


⬆️ Updating ClaudeNames

ClaudeNames deploys from a dev machine: ./deploy.ps1 builds the production image for the server's architecture, publishes it to the fleet registry, then pushes the commit: that order is the whole invariant.

That push fires the bare repo's hook, which calls podmanctl --deploy claudenames <short-sha>: it acknowledges the push, then deploys detached. Nothing is built here β€” it pulls <registry>/claudenames:<app sha>.<platform sha>, checks its revision labels against the commit just checked out and starts it; with nothing published it refuses before stopping anything. Follow a deploy with sudo tail -f /var/log/claudenames-deploy.log.

πŸš€ Deploying a change

From the app's clone on a dev machine, with the platform clone beside it:

git add -u && git commit -m "..."
./deploy.ps1

deploy.ps1 does everything in order: build β†’ push the image β†’ git push. Never push first β€” the hook would fire before the image exists and the deploy would be refused.

It refuses early rather than wasting a build when the tree is dirty or has diverged from the server. With nothing left to push it asks the app's /api/version what production runs: HEAD itself β†’ it exits 3 (skipped); no answer β†’ exit 3 marked unconfirmed, with the reason; another commit β†’ it publishes the image the server is missing and exits 4, naming the podmanctl --deploy left to run there. After a push it waits until /api/version reports the new commit (-NoWait skips the wait).

πŸͺ One-time setup β€” hook and clone wiring

The deploy entry point is central and installed once for the whole fleet β€” see Podman β€” Push deploys for podmanctl --deploy and its single sudoers rule. Only the two steps below are per-repository.

# install into the bare repo's hooks/ β€” fires on every push
# ${SERVICE_NAME} expands now; the rest stays literal (\$…) for git to expand
sudo tee /media/ssd/git/podman/${SERVICE_NAME}.git/hooks/post-receive >/dev/null <<EOF
#!/bin/sh
set -eu
DEPLOY_BRANCH=refs/heads/master
while read -r oldrev newrev refname; do
  [ "\$refname" = "\$DEPLOY_BRANCH" ] || continue          # only the deploy branch
  sudo /usr/local/bin/podmanctl --deploy ${SERVICE_NAME} "\$(git rev-parse --short "\$newrev")" </dev/null
done
EOF
# normalize the hook ownership
sudo chown www-data:debian /media/ssd/git/podman/${SERVICE_NAME}.git/hooks/post-receive
sudo chmod 0750            /media/ssd/git/podman/${SERVICE_NAME}.git/hooks/post-receive

# git seeded hooks/ with executable *.sample files β€” inert, but strip the bit
sudo chmod -x /media/ssd/git/podman/${SERVICE_NAME}.git/hooks/*.sample

The commit comes from the just-pushed ref (the bare repo already holds it) and only names the deploy in its log. Nothing else is baked in: podmanctl derives the service directory, user, admin group, env file and log path from the name it is given.

# deploys pull non-interactively as root: pull from the co-located bare repo over a
# LOCAL path (the HTTPS origin needs a credential prompt + TTY, which a hook lacks)
git -C ${SERVICE_DIR} remote set-url origin /media/ssd/git/podman/${SERVICE_NAME}.git

# trust both repos for root so the root-run `git pull` doesn't refuse with
# "detected dubious ownership"; --system applies whatever HOME the hook runs with
sudo git config --system --add safe.directory ${SERVICE_DIR}
sudo git config --system --add safe.directory /media/ssd/git/podman/${SERVICE_NAME}.git

Without the local origin the pull dies with could not read Username for 'https://…'; without safe.directory it dies with detected dubious ownership. Either way the worker aborts before podmanctl --update and leaves the service running on its previous build.

🧱 Upgrading the base images

Every image the build starts FROM is pinned in claudenames.env β€” rust:${RUST_VERSION}-slim-trixie, node:${NODE_VERSION}-alpine, debian:${DEBIAN_TAG}, alongside the stack's postgres:${POSTGRES_VERSION}. The .build units pass them as --build-arg and build/Dockerfile carries matching ARG defaults, so nothing moves under the fleet on its own. ClaudeNames builds on a dev machine, so the bases are pulled there; here --upgrade still refreshes postgres:${POSTGRES_VERSION}. Refreshing is a deliberate step:

podmanctl --list --check            # UPDATE column shows when an upstream image moved
podmanctl --upgrade claudenames     # pull bases + registry images, then rebuild + restart

The env files are gitignored: add a new key on the server before the push that needs it, or a quadlet expanding ${RUST_VERSION} against a missing key builds rust:-slim-trixie and fails. A bumped RUST_VERSION invalidates the whole Rust layer chain β€” toolchain, every dependency crate (a fresh cargo cache) β€” so that rebuild runs 15 min+ on the ARM host: schedule it, don't treat it as a routine deploy. Edit and push a pin first (build/Dockerfile, claudenames.env, postgres.env), then --upgrade.

βœ… Platform conformance

ClaudeNames consumes the shared platform β€” the platform-* crates and @platform/* packages its image is built from β€” so the platform's conformance suites must stay green for it. They run on the local stack only, never against production (2026-10-01): from the shared-core checkout, before every deploy,

./scripts/fleet-check.ps1 -Apps claudenames -UseVsClang

It starts run-local.ps1 -Detach -ResetDevAdmin, runs the HTTP suite, the browser suite and this app's own e2e/ through the stack's own nginx (https://127.0.0.1:8450), checks that nginx itself, then stops the stack. The sync protocol (/api/sync, /api/events/sync, the worker and the manifest) is checked too; its two collections are server-written mirrors, so no credentialed round trip applies. The whole fleet at once: Release & rollout β€” Fleet conformance.