Private Remote Deployment
This pattern runs Libre WebUI, Ollama, and Cloudflare Tunnel on one Docker host without publishing the application or Ollama ports. Cloudflare Access is the outer identity boundary; Libre WebUI authentication remains the inner boundary. Work and Watchtower are separate, root-equivalent opt-ins.
Use deploy/private/docker-compose.yml
as the starting point. It defaults to the main image:
LIBRE_WEBUI_IMAGE=ghcr.io/libre-webui/libre-webui:main
The dev tag is suitable for an explicitly opted-in development instance, not
the client default.
Security model
- Cloudflare Access protects the entire hostname, including
/api/*and WebSocket upgrades. Do not add public bypass paths. - Libre WebUI requires a current account for application APIs. Model lifecycle and Work operations require the current database role to be administrator.
- The application, Ollama, and cloudflared only use a private Compose network. The host publishes no application ports.
- The app runs non-root with a read-only root filesystem, no Linux capabilities, no-new-privileges, and CPU, memory, and PID limits.
- Work is disabled unless
docker-compose.work.ymlis included. When enabled, its containers add their own read-only root filesystem, capability drop, resource limits, workspace volume, and default-deny network policy.
The base stack mounts no Docker socket. Adding either the Work or Watchtower override introduces the largest trust boundary: anyone who compromises a process that can issue Docker API calls can control the host. A read-only socket mount does not make Docker API access read-only.
Bootstrap
- Create a non-root sudo operator and verify key-based SSH login before disabling root SSH.
- Copy
deploy/private/.env.exampleto/opt/libre-webui/.env, set mode0600, and generate unique secrets. - If Work will be enabled, set
DOCKER_GIDto the numeric group that owns/var/run/docker.sock. - Store the Cloudflare tunnel token in
/opt/libre-webui/secrets/tunnel-tokenwith mode0640or stricter. - Create a Cloudflare Access self-hosted application for the complete
hostname, use a 24-hour session, and allow only the intended identities.
Enable Protect with Access on the tunnel route. If monitoring requires a
public health check, create a separate path-scoped application or policy for
/healthonly. Never add a blanket Bypass policy to the main application: matching Bypass policies defeat its Allow policy. - Leave
ENABLE_SIGNUP=false. Once the Access allowlist protects the hostname, create the first local administrator; an empty database permits that one bootstrap account automatically. Enable registration only for a deliberate later registration window. - Configure Turnstile hostname restrictions and set
TURNSTILE_EXPECTED_HOSTNAMEto the exact public hostname.
Start and verify:
cd /opt/libre-webui
docker compose config --quiet
docker compose up -d
docker compose ps
To enable Work, include its override deliberately:
docker compose -f docker-compose.yml -f docker-compose.work.yml up -d
Once Access is active, command-line smoke tests need a Cloudflare Access service token unless the exact path has a narrow bypass. Store the credentials outside shell history and send both headers:
curl --fail --silent --show-error \
-H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
-H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \
https://your-hostname.example/api/auth/system-info
An unauthenticated request to a protected application API must return 401:
curl --output /dev/null --write-out '%{http_code}\n' \
-H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
-H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \
https://your-hostname.example/api/work/tasks
Host hardening
The directory includes an sshd drop-in and fail2ban jail. Before applying the
sshd drop-in, verify a separate non-root sudo session in another terminal. Test
configuration with sshd -t before reloading SSH.
Use UFW (or an equivalent firewall) to default-deny inbound traffic and permit only rate-limited SSH. Docker publishes no service ports in this template:
ufw default deny incoming
ufw default allow outgoing
ufw limit OpenSSH
ufw enable
Keep unattended security upgrades enabled. Disable X11, agent, and TCP forwarding unless the deployment has a documented need for them.
Backups and recovery
Install the provided backup script and systemd units, then enable the timer:
install -m 0750 deploy/private/libre-webui-backup \
/usr/local/sbin/libre-webui-backup
install -m 0644 deploy/private/libre-webui-backup.{service,timer} \
/etc/systemd/system/
systemctl daemon-reload
systemctl enable --now libre-webui-backup.timer
The backup briefly stops the application to produce a consistent data-volume
archive. Copy archives off-host using encrypted storage and regularly test a
restore. Back up .env separately in a secrets manager; never place it inside
an unencrypted archive. Ollama models can be pulled again. Work task volumes
need a separate retention policy when their content matters.
Updates
Update pinned images manually after reviewing and backing up the deployment. If automatic updates are an accepted risk, add the socket-bearing Watchtower override explicitly:
docker compose \
-f docker-compose.yml \
-f docker-compose.watchtower.yml \
up -d
Watchtower then checks labelled application and Ollama images every 30 minutes.
A client deployment follows main; an experimental instance may override
LIBRE_WEBUI_IMAGE with :dev. Keep health checks and an off-host backup so a
bad update can be rolled back to a previously recorded digest.