Skip to content

Container Deployment

FenLiu ships with a production-ready Containerfile for Podman (or Docker). The multi-stage build produces a minimal image (~207 MB) based on python:3.13-slim-bookworm.

Prerequisites

  • Podman (recommended) or Docker
  • The FenLiu source repository

Building the Image

# With Podman (recommended)
podman build -t fenliu -f Containerfile .

# Or with Docker
docker build -t fenliu -f Containerfile .

The build uses two stages:

  1. Builder stage — installs uv, syncs dependencies, and installs FenLiu as an editable package.
  2. Runtime stage — copies only the virtual environment and application source into a clean image. No build tools are present in the final image.

Configuration

FenLiu is configured via environment variables. Copy the example file and edit it before running:

cp .env.example .env
# Edit .env with your settings

The most important variables to set for production:

Variable Default Description
SECRET_KEY your-secret-key-change-in-production Session signing key — must be changed
DATABASE_URL sqlite:////app/data/fenliu.db Database location (container default uses /app/data)
DEBUG false Enable verbose logging
UI_AUTH_ENABLED true Session-based web UI auth. Set false only for personal/private-LAN deployments
DEFAULT_INSTANCE mastodon.social Default Fediverse instance for hashtag monitoring
API_TIMEOUT 30 HTTP timeout (seconds) for Fediverse API calls
MAX_POSTS_PER_FETCH 20 Posts fetched per hashtag stream request
RATE_LIMIT_DELAY 1.0 Delay between API requests (seconds)
POSTS_PER_PAGE 20 Posts shown per page in the web UI
REFRESH_INTERVAL 300 UI auto-refresh interval (seconds, 0 = disabled)

Generate a secure SECRET_KEY:

python -c "import secrets; print(secrets.token_urlsafe(32))"

See Configuration for the full reference.

Running the Container

podman run -d \
  --name fenliu \
  -p 8000:8000 \
  -v fenliu-data:/app/data \
  -v fenliu-logs:/app/logs \
  --env-file .env \
  fenliu

With explicit environment variables

podman run -d \
  --name fenliu \
  -p 8000:8000 \
  -v fenliu-data:/app/data \
  -v fenliu-logs:/app/logs \
  -e DATABASE_URL="sqlite:////app/data/fenliu.db" \
  -e SECRET_KEY="your-production-secret-key" \
  fenliu

Then open http://localhost:8000 in your browser.

Volumes

Two volumes provide persistence across container restarts:

Mount point Purpose
/app/data SQLite database file
/app/logs Application log files

Use named volumes or bind mounts

Without persistent volumes the database is lost when the container is removed.

Bind mount example (host directory)

mkdir -p ~/fenliu/data ~/fenliu/logs

podman run -d \
  --name fenliu \
  -p 8000:8000 \
  -v ~/fenliu/data:/app/data \
  -v ~/fenliu/logs:/app/logs \
  --env-file .env \
  fenliu

Compose Example

Save as compose.yml (works with both podman-compose and docker compose):

services:
  fenliu:
    build: .
    ports:
      - "8000:8000"
    volumes:
      - fenliu-data:/app/data
      - fenliu-logs:/app/logs
    env_file: .env
    restart: unless-stopped

volumes:
  fenliu-data:
  fenliu-logs:

Start:

podman-compose up -d
# or
docker compose up -d

Podman Quadlet

Quadlets are the recommended way to run containers as systemd user services. Because quadlets don't support --env-file directly as a CLI flag, provide secrets via EnvironmentFile=.

1. Create an env file for secrets

mkdir -p ~/.config/containers/systemd
# Generate a secure SECRET_KEY
python -c "import secrets; print(secrets.token_urlsafe(32))"

Write the output to an env file — keep it readable only by your user:

cat > ~/.config/containers/systemd/fenliu.env <<'EOF'
SECRET_KEY=<paste-generated-key-here>
EOF
chmod 600 ~/.config/containers/systemd/fenliu.env

2. Reference the env file in your quadlet

~/.config/containers/systemd/fenliu.container:

[Container]
ContainerName=fenliu
Image=codeberg.org/marvinsmastodontools/fenliu:latest
EnvironmentFile=%h/.config/containers/systemd/fenliu.env
Volume=fenliu-data.volume:/app/data
Volume=fenliu-logs.volume:/app/logs
Pod=fenliu.pod

[Service]
Restart=on-failure
TimeoutStopSec=70

%h expands to your home directory. Reload and restart:

systemctl --user daemon-reload
systemctl --user restart fenliu.service

SECRET_KEY is required

FenLiu refuses to start if SECRET_KEY is not set or is still the default placeholder. The container will exit immediately and the structured log will show a CRITICAL message with the generation command.

First-Run Authentication

FenLiu defaults to UI_AUTH_ENABLED=true. On first start, the database has no password stored, so all web UI requests redirect to /setup.

  1. Start the container as normal and open http://host:8000 in a browser.
  2. The app detects no password and redirects to /setup.
  3. Read the guidance on the setup page, then enter and confirm a password for the admin account.
  4. On submit the password is hashed and stored in the database, then you are redirected to /login.
  5. Log in. The DB volume persists the hash across container restarts — no re-setup needed.

To reset credentials: stop the container, delete or wipe the DB volume, and restart (redirects to /setup again). Alternatively set UI_AUTH_ENABLED=false temporarily to access the app and change the password via Settings → Change Password, then re-enable auth and restart.

Health checks

HTTP health checks against /health are unaffected — that endpoint is always public. Health checks that target the web root (/) will see 302 redirects until setup is complete.

Upgrading from a Version Without Web UI Authentication

Breaking change

UI_AUTH_ENABLED defaults to true. On first start after upgrading, the app finds no ui_password_hash in the DB and redirects all web UI requests to /setup. The API, /health, and /setup itself remain accessible.

Interactive upgrade path: upgrade → start → open browser → complete /setup → log in → done.

Hands-off / unattended upgrade path: add UI_AUTH_ENABLED=false to the env file before upgrading to keep existing behaviour; access /setup at your convenience to set a password; then set UI_AUTH_ENABLED=true and restart.

No database migration is required — ui_password_hash is stored as a new AppSetting row.

Startup Behaviour

The container entrypoint (entrypoint.sh) runs as root briefly to:

  1. Create /app/data and /app/logs directories if missing.
  2. Set permissions so the application can write to them regardless of the host UID.
  3. Drop privileges to the fenliu user (UID 1000) before starting the app.

FenLiu then runs alembic upgrade head automatically on startup to apply any pending database migrations before the web server begins accepting requests.

Backups

The container includes the sqlite3 CLI, which uses SQLite's online backup API to produce a consistent snapshot without stopping the application.

Manual backup

podman exec fenliu sqlite3 /app/data/fenliu.db \
  ".backup '/app/data/fenliu-backup.db'"
podman cp fenliu:/app/data/fenliu-backup.db \
  ./fenliu-$(date +%Y%m%d).db
podman exec fenliu rm /app/data/fenliu-backup.db

This gives you a clean .db file you can inspect, query, or restore directly.

Restore

Stop the container, replace the database file, then restart:

systemctl --user stop fenliu.service
podman cp ./fenliu-20260312.db fenliu:/app/data/fenliu.db
systemctl --user start fenliu.service

Automated backup with a systemd timer

~/.config/containers/systemd/fenliu-backup.service:

[Unit]
Description=Backup FenLiu database
After=fenliu.service

[Service]
Type=oneshot
ExecStart=/bin/bash -c '\
  podman exec fenliu sqlite3 /app/data/fenliu.db \
    ".backup /app/data/fenliu-backup.db" && \
  podman cp fenliu:/app/data/fenliu-backup.db \
    %h/backups/fenliu-$(date +%%Y%%m%%d).db && \
  podman exec fenliu rm /app/data/fenliu-backup.db'

~/.config/containers/systemd/fenliu-backup.timer:

[Unit]
Description=Daily FenLiu backup

[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target
mkdir -p ~/backups
systemctl --user daemon-reload
systemctl --user enable --now fenliu-backup.timer

Security Notes

  • The container runs as a non-root user (fenliu, UID 1000) after the initial setup.
  • /app/data is world-writable (0o777) so host bind-mounts with any UID can write to it — consider using named volumes instead if this is a concern.
  • Always set a strong, unique SECRET_KEY in production.
  • Use HTTPS in front of the container (nginx, Caddy, Traefik, etc.) in production.

Troubleshooting

Container exits immediately

Check logs:

podman logs fenliu

Common causes:

  • Missing or invalid SECRET_KEY
  • DATABASE_URL pointing to a path that isn't mounted
  • Port 8000 already in use (change with -p 8001:8000)

Permission denied on /app/data

Ensure the volume is mounted correctly and the host directory is accessible. Named volumes are recommended over bind mounts for simplicity.

Database migration errors

Migrations run automatically on startup. If they fail, the logs will show the Alembic error. Running the container again after fixing the issue will re-attempt migrations.

Next Steps