Kevin Veen-Birkenbach 793e2de899 build(docker): ship only the runtime assets, without node or dev packages
The image ran npm install without --omit=dev, so every published tag
carried Cypress with its downloaded binary, ESLint and the whole dev
tree next to nodejs and npm. The npm install layer alone was 229 MB of
the 349 MB compressed 2.0.0 image, and all seven npm audit findings
(extract-zip, form-data, tmp, qs, uuid via @cypress/request) came from
it, although the app only serves five vendored asset directories.

A node:22-slim stage now runs npm install --omit=dev, whose postinstall
writes static/vendor, and the final runtime stage copies just that
directory onto the Python base. A dev stage keeps nodejs and npm for
docker-compose, which bind-mounts app/ and runs npm install on start;
compose now builds that target. .dockerignore keeps a local
app/node_modules and app/static/vendor out of COPY app/.

Measured on make build: the runtime image is 142 MB uncompressed, has
no node, npm, node_modules or Cypress cache, and serves /, /de/ and
every vendored asset with 200. The dev stage has node 20.19.2 and npm
9.2.0. make test passes, hadolint included, with 107 Cypress tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:17:05 +02:00
2026-09-10 16:07:26 +02:00
2020-10-08 15:06:16 +02:00
2026-03-30 10:46:39 +02:00
2026-09-10 16:07:26 +02:00

PortUI 🖥️

GitHub Sponsors Patreon Buy Me a Coffee PayPal

A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes.

PortUI screenshot

🚀 You can also pair PortUI with JavaScript for sleek, web-based desktop-style interfaces.
💻 Example in action: CyMaIS.Cloud (demo)
🌐 Another live example: veen.world (Kevins personal site)


Key Features

  • Dynamic Navigation
    Create dropdowns & nested menus with ease.
  • Customizable Cards
    Highlight skills, projects, or services—with icons, titles, and links.
  • Smart Cache Management
    Auto-cache assets for lightning-fast loading.
  • Responsive Design
    Built on Bootstrap; looks great on desktop, tablet & mobile.
  • 184 Languages
    Every ISO 639-1 code, browser-negotiated, RTL-aware, with machine translation for your own content.
  • YAML-Driven
    All content & structure defined in a simple config.yaml.
  • CLI Control
    Manage Docker containers via the portfolio command.

🌐 Quick Access


🏁 Getting Started

🔧 Prerequisites

  • Docker & Docker Compose
  • Basic Python & YAML knowledge

🛠️ Installation via Git

  1. Clone & enter repo

    git clone <repository_url>
    cd <repository_directory>
    
  2. Configure Copy config.sample.yamlconfig.yaml & customize.

  3. Build & run

    docker-compose up --build
    
  4. Browse Open http://localhost:5000

📦 Installation via Kevins Package Manager

pkgmgr install portui

Once installed, the portui CLI is available system-wide.


🖥️ CLI Commands

portui --help
  • buildBuild the Docker image
  • upStart containers (with build)
  • downStop & remove containers
  • run-devDev mode (hot-reload)
  • run-prodProduction mode
  • logsView container logs
  • devDocker-Compose dev environment
  • prodDocker-Compose prod environment
  • cleanupPrune stopped containers

🔧 YAML Configuration Guide

Define your sites structure in config.yaml:

accounts:
  name: Online Accounts
  description: Discover my online presence.
  icon:
    class: fa-solid fa-users
  children:
    - name: Channels
      description: Platforms where I share content.
      icon:
        class: fas fa-newspaper
      children:
        - name: Mastodon
          description: Follow me on Mastodon.
          icon:
            class: fa-brands fa-mastodon
          url: https://microblog.veen.world/@kevinveenbirkenbach
          identifier: "@kevinveenbirkenbach@microblog.veen.world"
  cards:
    - icon:
        source: https://cloud.veen.world/s/logo_agile_coach_512x512/download
      title: Agile Coach
      text: I lead agile transformations and improve team dynamics through Scrum and Agile Coaching.
      url: https://www.agile-coach.world
      link_text: www.agile-coach.world

company:
  title: Kevin Veen-Birkenbach
  subtitle: Consulting & Coaching Solutions
  logo:
    source: https://cloud.veen.world/s/logo_face_512x512/download
  favicon:
    source: https://cloud.veen.world/s/veen_world_favicon/download
  address:
    street: Afrikanische Straße 43
    postal_code: DE-13351
    city: Berlin
    country: Germany
  imprint_url: https://s.veen.world/imprint
  • children enables multi-level menus.
  • link references other YAML paths to avoid duplication.

🌍 Languages

Every ISO 639-1 language — all 184 two-letter codes — has a URL, a display name in its own script and a writing direction. The interface ships translated for 29 of them; the rest fall back to English string by string until a catalogue is filled. / serves the best match for the visitor's Accept-Language header, /<code>/ forces one, and a switcher in the navbar lists them all. The ten right-to-left languages get dir="rtl" and Bootstrap's RTL stylesheet automatically.

Translations live in two catalogues, both keyed by the English source string:

Path Tracked Holds
app/i18n/ui/<code>.yaml yes Interface strings. Shipped for 29 languages; English is the source and has no file.
app/i18n/content/<code>.yaml no Your config.yaml prose, generated per deployment.

A string with no catalogue entry falls back to English, so a half-filled catalogue degrades instead of breaking.

Fill the content catalogues from a LibreTranslate instance — set LIBRETRANSLATE_URL in .env, then:

make i18n

This fills the interface strings of the languages that ship no catalogue as well. Existing entries are never overwritten, and a string the shipped catalogue already covers is never requested, so corrections you make by hand survive later runs.

name, title, description, text, warning, info and subtitel are translated; url, link_text, identifier and icon classes never are.

A machine cannot tell the menu label "Pictures" from the brand "Mastodon", so list the brands in app/i18n/keep.txt, one per line — they are then stored as themselves in every language and cost no request:

# Strings utils/i18n_sync.py stores as themselves instead of translating.
Mastodon
Nextcloud
freelancermap.de

Add one-off entries with --keep Foo Bar, or point somewhere else with --keep-file. A protected string never replaces an entry you already wrote by hand.


🚢 Production Deployment

  • Use a reverse proxy (NGINX/Apache).
  • Secure with SSL/TLS.
  • Swap to a production database if needed.

Because every page carries a canonical URL and 184 hreflang alternates, two details of the proxy setup now matter:

  • Set TRUSTED_HOSTS in .env to your public hostname(s), comma-separated. Left empty, the app reflects whatever Host header arrives into its canonical, hreflang and redirect URLs — so a shared cache in front of it can be made to store a redirect pointing somewhere else.
  • Have the proxy send X-Forwarded-Proto. Without it the app cannot know TLS terminated upstream and every canonical URL claims http://. X-Forwarded-Host is deliberately not trusted; set Host to the public name instead.

📜 License

Licensed under GNU AGPLv3. See LICENSE for details.


✍️ Author

Created by Kevin Veen-Birkenbach

Enjoy building your portfolio! 🌟

Description
No description provided
Readme AGPL-3.0 5.4 MiB
Languages
Python 53.5%
JavaScript 33.4%
Jinja 5.8%
Makefile 3.4%
CSS 2.4%
Other 1.5%