What you’ll accomplish: Deploy Open WebUI in a Podman container with native systemd integration, put nginx in front with SSL and WebSocket support, lock down the firewall, and have a working ChatGPT-like interface for your local models.
Why Podman, Not Docker
Every Open WebUI tutorial uses Docker. We’re using Podman instead, for three reasons:
- It ships with Rocky Linux. No third-party repos, no Docker CE installation dance, no daemon to manage.
dnf install podmanand you’re done. - Quadlet for systemd integration. Podman Quadlet lets you define containers as systemd units using
.containerfiles. The container starts on boot, restarts on failure, and integrates withsystemctlandjournalctl— nopodman generate systemdhacks, norestart=alwayswrapper scripts. - Rootless capable. We’re running root containers here (for simplicity with bind mounts), but Podman’s architecture doesn’t require a root daemon. Docker requires
dockerdrunning as root at all times.
The container images are the same — Podman pulls from the same registries as Docker. The commands are nearly identical. The operational model is better.
Installing Podman and Pulling the Image
sudo dnf install -y podman
Pull the Open WebUI image:
sudo podman pull ghcr.io/open-webui/open-webui:latest
Note: The initial pull is about 2 GB. This takes a few minutes depending on your bandwidth.
Setting Up Persistent Storage
Open WebUI stores chat history, user accounts, and settings in an SQLite database. That data needs to survive container restarts and updates:
sudo mkdir -p /opt/open-webui/data
The Quadlet Container File
This is the core of the deployment. Quadlet files live in /etc/containers/systemd/ and systemd picks them up automatically.
First, generate a secret key for session signing — save this somewhere, you’ll need it if you ever recreate the container:
openssl rand -hex 24
Copy the output, then create the Quadlet file:
sudo tee /etc/containers/systemd/open-webui.container > /dev/null << 'EOF'
[Unit]
Description=Open WebUI — local AI chat interface
After=ollama.service
Wants=ollama.service
[Container]
ContainerName=open-webui
Image=ghcr.io/open-webui/open-webui:latest
Network=host
Volume=/opt/open-webui/data:/app/backend/data:Z
Environment=OLLAMA_BASE_URL=http://127.0.0.1:11434
Environment=WEBUI_SECRET_KEY=<your-secret-key>
Environment=PORT=3000
Environment=ANONYMIZED_TELEMETRY=False
Environment=DO_NOT_TRACK=True
[Service]
Restart=always
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target
EOF
Now edit the file and replace <your-secret-key> on the WEBUI_SECRET_KEY line with the key you generated above.
Let’s break down the important parts:
After=ollama.service + Wants=ollama.service — After ensures ordering (Open WebUI starts after Ollama), while Wants tells systemd to actually start Ollama if it isn’t running. Without Wants, a cold boot could start Open WebUI with no Ollama to connect to.
Network=host — The container shares the host’s network namespace. This means Open WebUI can reach Ollama at 127.0.0.1:11434 without any network bridging. The alternative — Podman’s default bridge network — creates a separate network namespace where localhost inside the container doesn’t reach localhost on the host. That’s the #1 cause of “Ollama not reachable” errors (see Chapter 7).
Volume=/opt/open-webui/data:/app/backend/data:Z — Bind-mount the data directory. The :Z suffix tells Podman to relabel the directory with the correct SELinux context for the container. Without it, SELinux denials silently prevent the container from writing data.
OLLAMA_BASE_URL — Tells Open WebUI where to find Ollama. Since we’re using host networking, this is just localhost.
WEBUI_SECRET_KEY — Used for session signing and CSRF protection. Use a strong random string — openssl rand -hex 24 generates a good one. Replace <your-secret-key> in the Quadlet file with the output.
ANONYMIZED_TELEMETRY and DO_NOT_TRACK — Disables Open WebUI’s built-in usage telemetry. Your self-hosted instance shouldn’t be phoning home.
TimeoutStartSec=300 — Open WebUI’s first start takes 60-120 seconds while it initializes its SQLite database and pulls frontend assets. The default 90-second timeout isn’t always enough.
Deploy and Start
# Reload systemd to pick up the new Quadlet file
sudo systemctl daemon-reload
# Start the service
sudo systemctl start open-webui
Note: Don’t use
enable --nowhere — Quadlet auto-generates the service unit from the.containerfile, and generated units can’t beenabled. TheWantedBy=multi-user.targetin the Quadlet file handles boot startup automatically.
Watch the startup:
# Follow container logs
sudo podman logs -f open-webui
Wait for Started server process — that means Open WebUI is ready. The first start is slow (60-120 seconds) while it runs database migrations and downloads an embedding model. You’ll see a wall of alembic.runtime.migration lines and some warnings about CORS and HuggingFace tokens — those are normal. Subsequent starts take 10-15 seconds. Press Ctrl+C to exit the log viewer.
Verify Open WebUI
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/
Expected: 200. If you get 000 (connection refused), the container is still starting — wait 30 seconds and try again. If it persists, check sudo podman logs open-webui for errors.
nginx Reverse Proxy
Open WebUI is running on port 3000, but it’s only accessible on localhost. We need nginx to provide HTTPS access, WebSocket support for streaming, and security headers.
Install nginx
sudo dnf install -y nginx
SSL Certificate
For a home lab that lives entirely on your LAN, a self-signed certificate is fine — your browser will complain, but the traffic is still encrypted. If you’re exposing this externally (through a reverse proxy, Cloudflare Tunnel, or port forwarding), use a real certificate from Let’s Encrypt instead.
Option A: Self-signed (LAN only)
# Create the SSL directory
sudo mkdir -p /etc/nginx/ssl
# Generate a self-signed certificate
sudo openssl req -x509 -nodes -days 365 \
-newkey rsa:2048 \
-keyout /etc/nginx/ssl/ai-stack.key \
-out /etc/nginx/ssl/ai-stack.crt \
-subj "/CN=ai.example.com"
# Lock down the private key
sudo chmod 600 /etc/nginx/ssl/ai-stack.key
Using a real certificate instead? If you’re exposing this externally and want a trusted certificate (Let’s Encrypt, Cloudflare origin cert, etc.), place your cert and key at /etc/nginx/ssl/ai-stack.crt and /etc/nginx/ssl/ai-stack.key — or update the ssl_certificate and ssl_certificate_key paths in the nginx config below to point to your cert’s location. The rest of the guide works the same either way.
The nginx Configuration
This is where most tutorials fall apart. LLM inference responses stream over WebSocket connections and can take minutes on CPU. Generic proxy configs break this in two ways: no WebSocket upgrade handling, and a 60-second timeout that kills long responses.
sudo tee /etc/nginx/conf.d/ai-stack.conf > /dev/null << 'EOF'
# Map WebSocket upgrade header
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name ai.example.com;
ssl_certificate /etc/nginx/ssl/ai-stack.crt;
ssl_certificate_key /etc/nginx/ssl/ai-stack.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://127.0.0.1:3000;
# Standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support for streaming inference responses
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Long timeout for inference — models can take minutes on CPU
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
# Disable buffering so streaming tokens arrive immediately
proxy_buffering off;
proxy_cache off;
# Allow large file uploads (model imports, document uploads)
client_max_body_size 500M;
}
# Grafana monitoring dashboard (Chapter 6)
# This block is harmless until Grafana is deployed — nginx returns 502
# for /grafana/ until the service is running on port 3001
location /grafana/ {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support for Grafana Live
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name ai.example.com;
return 301 https://$host$request_uri;
}
EOF
The critical parts that generic nginx tutorials miss:
The map block and WebSocket headers. LLM streaming responses use WebSocket connections. Without Upgrade and Connection headers, the client gets the first response chunk and then the connection dies.
proxy_read_timeout 86400s. The default is 60 seconds. A 70B model on CPU can take 5+ minutes to generate a long response. Setting this to 24 hours (86400s) is safe — if the connection is actually dead, TCP keepalive handles it. The 60-second default silently kills every slow inference.
proxy_buffering off. With buffering enabled, nginx collects the entire response before sending it to the client. For a streaming LLM response, that means the user sees nothing until the entire response is generated — which defeats the purpose of streaming. Turning it off sends tokens to the browser as they’re generated.
client_max_body_size 500M. Open WebUI supports document uploads for context and model imports. The default 1 MB limit blocks this.
Remove the Default Server Block
sudo rm -f /etc/nginx/conf.d/default.conf
The SELinux Boolean
sudo setsebool -P httpd_can_network_connect on
This is the single most common deployment blocker on Rocky Linux. nginx runs under the httpd_t SELinux domain. By default, httpd_t cannot make outbound network connections — which means nginx can’t proxy to Open WebUI on port 3000 or anywhere else. Without this boolean, you get a 502 Bad Gateway with zero useful information in the default error log.
The -P flag makes it persistent across reboots.
Start nginx
sudo systemctl enable --now nginx
Verify the Proxy
# Test HTTPS access from the host itself (-k accepts self-signed cert)
curl -sk https://localhost/ -o /dev/null -w "%{http_code}"
Expected: 200. If you get 502, check the SELinux boolean (getsebool httpd_can_network_connect). If you get 000 (connection refused), check systemctl status nginx.
Note: We use
localhosthere instead ofai.example.combecause DNS may not be configured yet. Once you’ve set up DNS or added an/etc/hostsentry for your domain, you can verify with the domain name from a browser on your network.
Firewall Configuration
Lock down the firewall to expose only what’s needed. Rocky Linux 9 ships with firewalld installed and enabled by default — if yours isn’t running (some cloud images strip it out), install it first with sudo dnf install -y firewalld.
# Ensure firewalld is running (it should be on a stock Rocky install)
sudo systemctl enable --now firewalld
# Allow SSH (you need this)
sudo firewall-cmd --permanent --add-service=ssh
# Allow HTTPS (for nginx)
sudo firewall-cmd --permanent --add-service=https
# Allow HTTP (for HTTPS redirect)
sudo firewall-cmd --permanent --add-service=http
# Apply the rules
sudo firewall-cmd --reload
Ollama (11434), Open WebUI (3000), and all monitoring services (Prometheus 9090, Grafana 3001, nvidia_gpu_exporter 9400) are intentionally not opened — they stay on localhost. Grafana is proxied through nginx at /grafana/ (configured in Chapter 6), so you get browser access without exposing additional ports.
Warning: Do not open port 11434 (Ollama) to the network. Ollama has no authentication. Anyone who can reach that port can run models on your GPU, pull new models, and list your existing ones.
What Automation Looks Like
This chapter covered three major components — Open WebUI, nginx, and the firewall — across dozens of manual steps. Here’s what the playbook bundle handles in one run:
Open WebUI:
- Installs Podman
- Creates the data directory at
/opt/open-webui/data - Pulls the Open WebUI container image (idempotent — skips if image exists)
- Creates the Quadlet systemd directory
- Deploys the
.containerfile with your settings - Enables and starts the service
- Waits for Open WebUI to respond (retries 30 times at 5-second intervals — generous for first-start initialization)
nginx reverse proxy:
- Installs nginx
- Creates the SSL directory and generates a self-signed certificate (idempotent — skips if cert exists)
- Sets file permissions on cert and key
- Deploys the nginx config with your domain and ports
- Removes the default server block
- Sets the
httpd_can_network_connectSELinux boolean - Enables and starts nginx
Firewall:
- Installs and starts firewalld
- Opens SSH, HTTPS, and HTTP services — nothing else
All three components are idempotent. Updating a config value and re-running the playbook applies only the change — it doesn’t reinstall or restart services that haven’t changed. The companion playbook bundle is available at RavenForge Press.
Verification Checkpoint
Before moving to Chapter 6, confirm:
sudo podman psshows theopen-webuicontainer runninghttps://ai.example.comloads the Open WebUI interface in a browser (accept the self-signed cert warning)- You can send a chat message and see a streaming response from your model
sudo tail /var/log/nginx/access.logshows the proxied requestssudo firewall-cmd --list-allshows ssh, https, and http services — no monitoring portsgetsebool httpd_can_network_connectreturnson
You have a working AI chat interface with SSL. Now let’s add monitoring so you know what’s happening under the hood.