A self-hosted, single-user CMS — Flask + PostgreSQL, with a drag-and-drop page editor, public navbar, posts, calendar, store, forum, and more.
GitHub Repo: https://github.com/seastwood/uwebia
You can run it three ways:
Images are published to Docker Hub as setheastwood/uwebia on every push to main and on every vX.Y.Z tag.
docker compose ...).# 1. Grab the deploy files (the image bundles the app code; you only need
# docker-compose.yml + .env.example + Caddyfile from the repo).
mkdir uwebia && cd uwebia
curl -fsSLO https://raw.githubusercontent.com/seastwood/uwebia/main/docker-compose.yml
curl -fsSLO https://raw.githubusercontent.com/seastwood/uwebia/main/.env.example
curl -fsSLO https://raw.githubusercontent.com/seastwood/uwebia/main/Caddyfile
mv .env.example .env
ls -a
# 2. Edit .env — at minimum set POSTGRES_PASSWORD and SECRET_KEY.
# Generate a SECRET_KEY:
python3 -c 'import secrets; print(secrets.token_hex(32))'
# 3. Start it.
docker compose pull
docker compose up -d
docker compose logs -f app # ctrl-C when you see "Listening at: http://0.0.0.0:5772"
The app is now on http://<docker-host>:5772. The first admin account is created on first visit — register at /register.
The default config exposes port 5772 to the host. Point your proxy at <docker-host-ip>:5772. Make sure it forwards the standard headers (X-Forwarded-For, X-Forwarded-Proto, Host) so the app sees the real client IP and detects HTTPS.
For pfSense HAProxy: under the backend's Advanced settings, add option forwardfor and (for HTTPS frontends) http-request set-header X-Forwarded-Proto https if { ssl_fc }.
To bind the port to loopback only (proxy on the same host):
# in .env
LISTEN_ADDR=127.0.0.1
APP_HOST_PORT=5772
Skip the proxy section above and use the bundled Caddy service:
# in .env
SITE_ADDRESS=example.com
[email protected]
docker compose --profile caddy up -d
Caddy provisions a Let's Encrypt certificate automatically and proxies to the app. Don't publish port 5772 to the host if Caddy is the only thing reaching the app — comment out the ports: block under app in docker-compose.yml.
docker compose pull
docker compose up -d
The auto-migrator runs db.create_all() and adds any new columns on startup; no manual migration commands needed.
These named volumes survive docker compose down:
| Volume | Mounted at | Holds |
|---|---|---|
db_data |
/var/lib/postgresql/data |
Postgres data |
uploads_data |
/app/static/uploads |
User-uploaded images / assets |
config_data |
/app/config |
server.json, db_config.json (admin-tuned) |
caddy_data |
/data |
Caddy TLS certs (only if you use Caddy) |
caddy_config |
/config |
Caddy autosave config (Caddy only) |
To back up uploads and the database:
docker run --rm -v uwebia_uploads_data:/data -v "$PWD":/backup alpine \
tar czf /backup/uploads-$(date +%F).tgz -C /data .
docker compose exec db pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > db-$(date +%F).sql
Run docker compose down -v to destroy the volumes too (factory reset).
Same as above but clone the repo and let compose build locally — useful when you're modifying the code.
git clone https://github.com/seastwood/uwebia.git
cd uwebia
cp .env.example .env
# edit .env: POSTGRES_PASSWORD, SECRET_KEY, etc.
Then in docker-compose.yml, swap the default image: line for build: under the app service:
app:
# image: setheastwood/uwebia:latest ← comment this out
build: . # ← uncomment this
And run:
docker compose up -d --build
docker compose logs -f app
Every code change requires a rebuild: docker compose up -d --build. For faster iteration during development, mount the source as a bind volume and run the Flask dev server — see Development below.
For users who don't want Docker. Tested on Debian/Ubuntu; equivalent steps work on RHEL/Arch.
sudo apt update
sudo apt install -y \
python3 python3-venv python3-pip \
postgresql postgresql-contrib \
libpq-dev libjpeg-dev zlib1g-dev libfreetype6-dev \
libffi-dev libssl-dev \
build-essential git
sudo -u postgres psql <<'SQL'
CREATE USER uwebia WITH PASSWORD 'change-me';
CREATE DATABASE uwebia OWNER uwebia;
GRANT ALL PRIVILEGES ON DATABASE uwebia TO uwebia;
SQL
git clone https://github.com/seastwood/uwebia.git
cd uwebia
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
# Tell the app how to reach Postgres + give Flask a session key.
export DATABASE_URL='postgresql+psycopg2://uwebia:change-me@localhost:5432/uwebia'
export SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_hex(32))')
# Dev server (port 5772 by default — change in config/server.json).
python3 main.py
The auto-migrator creates the schema and adds new columns on every boot, so the first run sets everything up.
/etc/systemd/system/uwebia.service:
[Unit]
Description=Uwebia CMS
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=uwebia
WorkingDirectory=/opt/uwebia
Environment="DATABASE_URL=postgresql+psycopg2://uwebia:change-me@localhost:5432/uwebia"
Environment="SECRET_KEY=replace-with-64-hex-chars"
ExecStart=/opt/uwebia/venv/bin/gunicorn main:app \
--bind 0.0.0.0:5772 \
--workers 3 \
--timeout 120 \
--access-logfile - \
--error-logfile -
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Then:
sudo useradd --system --home /opt/uwebia uwebia
sudo cp -r . /opt/uwebia && sudo chown -R uwebia:uwebia /opt/uwebia
sudo systemctl daemon-reload
sudo systemctl enable --now uwebia
sudo systemctl status uwebia
journalctl -u uwebia -f # tail logs
Put Nginx / Caddy / Apache in front the same way as any other gunicorn-served WSGI app.
git clone https://github.com/seastwood/uwebia.git
cd uwebia
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
export DATABASE_URL='sqlite:///database/uwebia.db' # or your local Postgres URL
python3 main.py
The Flask dev server has auto-reload on. SQLite is fine for local hacking; switch to Postgres before deploying since some features rely on Postgres-specific behavior (server_default=false(), JSON columns, etc.).
docker build -t uwebia:dev .
docker run --rm -p 5772:5772 \
-e DATABASE_URL=postgresql+psycopg2://uwebia:[email protected]:5432/uwebia \
-e SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_hex(32))') \
uwebia:dev
GitHub Actions builds and pushes on every push to main and every vX.Y.Z tag. To cut a release:
git tag v1.2.3
git push origin v1.2.3
The workflow at .github/workflows/docker-publish.yml builds multi-arch (linux/amd64 + linux/arm64) and pushes setheastwood/uwebia:1.2.3, :1.2, :1, and :latest.
All settings can come from environment variables (preferred for Docker) or config/server.json / config/db_config.json (which the admin UI rewrites).
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
sqlite under database/ |
SQLAlchemy connection string |
SECRET_KEY |
(must be set) | Flask session / CSRF secret |
GUNICORN_WORKERS |
3 |
Worker process count (Docker only) |
GUNICORN_TIMEOUT |
120 |
Request timeout in seconds (Docker only) |
APP_HOST_PORT |
5772 |
Host-side port the app container listens on (compose only) |
LISTEN_ADDR |
0.0.0.0 |
Host bind address (use 127.0.0.1 to restrict to loopback) |
SITE_ADDRESS |
:80 |
Caddy site address (only with --profile caddy) |
ACME_EMAIL |
(empty) | Let's Encrypt registration email (Caddy only) |
POSTGRES_USER |
uwebia |
Postgres role used by the db service |
POSTGRES_PASSWORD |
(must be set) | Postgres password |
POSTGRES_DB |
uwebia |
Postgres database name |
Can't connect to the database. Check docker compose logs db for the actual Postgres error. The most common cause is a POSTGRES_PASSWORD change after the volume was initialized — Postgres uses the password from the first boot and ignores later env changes. Wipe the volume to reset: docker compose down && docker volume rm uwebia_db_data.
Uploads disappear after docker compose up. You're missing the uploads_data volume or have a bind-mount of the local ./static directory shadowing it. Check the volumes: block under the app service.
Admin UI changes (DB URL, server config) revert on rebuild. The config_data volume isn't mounted. The app writes config/server.json and config/db_config.json at runtime — they must persist.
Mobile URL bar shows the wrong color. This is theme-color getting cached by iOS Safari's bfcache. Reload without cache (long-press the reload button → Reload Without Content Blockers), or force-quit and reopen Safari.
Add a LICENSE file. (TBD.)