Skip to content

Repository files navigation

Harbor

A local-first research library — a Vaadin (Spring Boot) web app. Paste a link and Harbor reads the page for you: it pulls the title, description and the article text itself, then gives you a distraction-free reader with Markdown notes and highlights.

A quick look

Select any passage to keep it as a highlight — kept passages stay marked in the article and collect on their own screen. Notes sit beside the text and render as Markdown as you type.

What's in it:

Screen What it's for
All bookmarks Everything you've saved, as picture cards, wide rows, or a compact table
Read later The queue, for things you saved but haven't got to
Highlights Every passage you've kept, grouped under the article it came from
Reader The article text on its own, with notes and highlights beside it
Save a link Paste a URL; Harbor reads the page for the title, description, kind and body, and archives it as a PDF

Every saved page is archived as a PDF, and by default a page that cannot be archived is not saved. Harbor hands the URL to a headless Chromium, which loads the page for itself — running its scripts, applying its real stylesheets, loading its web fonts — and prints it. So the archive looks like the page a reader saw, with its pictures and its layout, and the text in it stays selectable and searchable. The reader offers it as View PDF, which opens in a new tab.

That browser is a required second service, and Harbor will not start without HARBOR_BROWSER_URL pointing at one. Archiving is the point of a library like this: a bookmark with no copy of its page is a link that will rot.

Rendering takes seconds, and by default the save waits for it. If you would rather file a link at once and let the copy follow, see Waiting for the archive below.

Search covers everything at once — titles, descriptions, sites, tags, your notes, the article body, and your highlights. Sort by recency, title, or reading time. Tags narrow together, so picking two shows only what carries both.

Your library lives in a PostgreSQL database you run. Bookmarks, notes and highlights are all kept there — self-hosted on your own machine, but no longer inside your browser. Earlier versions kept everything in localStorage; if you are upgrading, Harbor takes that library in the first time you open it and tells you how many bookmarks it brought over. The only thing still kept in the browser is whether you prefer light or dark.

Every library belongs to one reader. Harbor authenticates against any OpenID Connect provider — Keycloak, Authentik, Dex, Entra ID, Auth0, whatever you already run — and login is mandatory on every screen — there is no way to reach a bookmark, a note, a highlight or an archived PDF without signing in first, and what you see is only ever your own. That makes an identity provider a required third service alongside the database and the browser; see Authentication below.

Follows your system light/dark setting, or pin either one.


Run it (self-hosting)

A prebuilt, multi-architecture image (linux/amd64 + linux/arm64) is published to Docker Hub at binarycodes/harbor. No build and no configuration required.

Harbor needs a PostgreSQL, a browser and an identity provider to talk to, so the compose stack below is the shortest way in. Then open http://localhost:8080.

  • Use binarycodes/harbor:latest for the newest build, or pin a version tag — images are also tagged with the project's Maven version.
  • The app listens on port 8080. Override it with the PORT environment variable: -e PORT=9090 -p 9090:9090.
  • The database is configured with HARBOR_DB_URL, HARBOR_DB_USER and HARBOR_DB_PASSWORD. Harbor creates and migrates its own schema on startup.
  • Login is configured with HARBOR_OIDC_ISSUER_URI, HARBOR_OIDC_CLIENT_ID and HARBOR_OIDC_CLIENT_SECRET. Read Authentication before you deploy this: the issuer URL is the one thing here that is easy to get subtly wrong.

docker compose

Replace harbor.example.com with the address readers will actually type. It has to be the same in both places it appears — see Authentication.

services:
  harbor:
    image: binarycodes/harbor:latest
    ports:
      - "8080:8080"
    environment:
      - HARBOR_DB_URL=jdbc:postgresql://postgres:5432/harbor
      - HARBOR_DB_USER=harbor
      - HARBOR_DB_PASSWORD=change-me
      - HARBOR_BROWSER_URL=http://chromium:9222
      # The public URL of Keycloak, not http://keycloak:8080 — the same URL the
      # browser is sent to. See Authentication below; getting this wrong produces a
      # redirect loop that explains nothing.
      - HARBOR_OIDC_ISSUER_URI=https://harbor.example.com/auth/realms/harbor
      - HARBOR_OIDC_CLIENT_ID=harbor
      - HARBOR_OIDC_CLIENT_SECRET=change-me
      # Only if you need Harbor to reach private addresses; see below.
      # - HARBOR_ALLOWED_RANGES=192.168.1.50/32
    depends_on:
      postgres:
        condition: service_healthy
      chromium:
        condition: service_started
      keycloak:
        condition: service_started
    restart: unless-stopped

  # chromedp's headless-shell: a published, version-tagged Chromium that already
  # exposes DevTools on a reachable address. Chromium needs more than the default
  # 64 MB of /dev/shm, and wants a ceiling of its own.
  chromium:
    image: chromedp/headless-shell:151.0.7922.109
    shm_size: 512m
    mem_limit: 1g
    restart: unless-stopped

  # Your realm, your client, your users. Nothing here is imported for you: create the
  # realm and a confidential client called `harbor` in the admin console, and give the
  # client the exact redirect URI https://harbor.example.com/login/oauth2/code/oidc.
  #
  # KC_HOSTNAME is not decoration. Keycloak stamps it into the issuer claim of every
  # token, and Harbor rejects a token whose issuer is not the one it was configured
  # with — so this and HARBOR_OIDC_ISSUER_URI describe the same address or nothing
  # works.
  keycloak:
    image: quay.io/keycloak/keycloak:26.4
    command: ["start", "--optimized"]
    environment:
      - KC_HOSTNAME=https://harbor.example.com/auth
      - KC_PROXY_HEADERS=xforwarded
      - KC_HTTP_ENABLED=true
      - KC_DB=postgres
      - KC_DB_URL=jdbc:postgresql://postgres:5432/keycloak
      - KC_DB_USERNAME=harbor
      - KC_DB_PASSWORD=change-me
      - KC_BOOTSTRAP_ADMIN_USERNAME=admin
      - KC_BOOTSTRAP_ADMIN_PASSWORD=change-me
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:18-alpine
    environment:
      - POSTGRES_DB=harbor
      - POSTGRES_USER=harbor
      - POSTGRES_PASSWORD=change-me
    volumes:
      - harbor-data:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U harbor -d harbor"]
      interval: 5s
      timeout: 3s
      retries: 10
    restart: unless-stopped

volumes:
  harbor-data:

harbor-data is your library. Back it up. Keycloak wants a keycloak database of its own in that same server — createdb -U harbor keycloak, once — and its realm configuration lives there, so the backup covers your accounts too.

Podman Quadlet (systemd)

If you run Podman, a Quadlet lets systemd manage the containers declaratively — start on boot, restart on failure, and optional auto-updates — without a long-running daemon.

Harbor is four containers, so this is six unit files rather than one. They go in /etc/containers/systemd/ (rootful) or ~/.config/containers/systemd/ (rootless). A network of their own is what lets them find each other by name; containers on Podman's default network cannot.

harbor.network:

[Network]

harbor-data.volume:

[Volume]

harbor-postgres.container — your library lives here, so this volume is the thing to back up:

[Unit]
Description=Harbor's database

[Container]
ContainerName=harbor-postgres
Image=docker.io/postgres:18-alpine
Network=harbor.network
# The parent directory, not data/ — since 18 these images keep their data in a
# major-version subdirectory so pg_upgrade can work across one mount.
Volume=harbor-data.volume:/var/lib/postgresql
Environment=POSTGRES_DB=harbor
Environment=POSTGRES_USER=harbor
Environment=POSTGRES_PASSWORD=change-me
HealthCmd=pg_isready -U harbor -d harbor

[Service]
Restart=always

[Install]
WantedBy=multi-user.target default.target

harbor-chromium.container — the browser that renders the archives. Note it publishes no port: nothing outside this network needs to reach it:

[Unit]
Description=The browser Harbor archives with

[Container]
ContainerName=harbor-chromium
Image=docker.io/chromedp/headless-shell:151.0.7922.109
Network=harbor.network
# Chromium treats /dev/shm as its scratch space and the 64 MB default is too little.
# A browser rendering pages from the open web also wants a ceiling.
PodmanArgs=--shm-size=512m --memory=1g

[Service]
Restart=always

[Install]
WantedBy=multi-user.target default.target

harbor-keycloak.container — who Harbor asks about the reader. KC_HOSTNAME has to be the public URL, because Keycloak stamps it into the issuer claim of every token and Harbor validates against exactly that:

[Unit]
Description=The identity provider Harbor authenticates against
Requires=harbor-postgres.service
After=harbor-postgres.service

[Container]
ContainerName=harbor-keycloak
Image=quay.io/keycloak/keycloak:26.4
Network=harbor.network
Exec=start --optimized
Environment=KC_HOSTNAME=https://harbor.example.com/auth
Environment=KC_PROXY_HEADERS=xforwarded
Environment=KC_HTTP_ENABLED=true
# Its own database in the same server; createdb -U harbor keycloak once.
Environment=KC_DB=postgres
Environment=KC_DB_URL=jdbc:postgresql://harbor-postgres:5432/keycloak
Environment=KC_DB_USERNAME=harbor
Environment=KC_DB_PASSWORD=change-me
Environment=KC_BOOTSTRAP_ADMIN_USERNAME=admin
Environment=KC_BOOTSTRAP_ADMIN_PASSWORD=change-me

[Service]
Restart=always

[Install]
WantedBy=multi-user.target default.target

harbor.container:

[Unit]
Description=Harbor research library
# Harbor refuses to start without a browser configured, and cannot save without one.
# It also fetches the issuer's discovery document at startup, so a Keycloak that is
# not up yet is an application that does not start.
Requires=harbor-postgres.service harbor-chromium.service harbor-keycloak.service
After=harbor-postgres.service harbor-chromium.service harbor-keycloak.service network-online.target
Wants=network-online.target

[Container]
ContainerName=harbor
Image=docker.io/binarycodes/harbor:latest
Network=harbor.network
PublishPort=8080:8080
Environment=HARBOR_DB_URL=jdbc:postgresql://harbor-postgres:5432/harbor
Environment=HARBOR_DB_USER=harbor
Environment=HARBOR_DB_PASSWORD=change-me
Environment=HARBOR_BROWSER_URL=http://harbor-chromium:9222
# The public URL, matching KC_HOSTNAME above — not http://harbor-keycloak:8080.
Environment=HARBOR_OIDC_ISSUER_URI=https://harbor.example.com/auth/realms/harbor
Environment=HARBOR_OIDC_CLIENT_ID=harbor
Environment=HARBOR_OIDC_CLIENT_SECRET=change-me
# Only if you need Harbor to reach private addresses; see below.
# Environment=HARBOR_ALLOWED_RANGES=192.168.1.50/32
# Opt in to `podman auto-update` pulling newer :latest images.
AutoUpdate=registry

[Service]
Restart=always

[Install]
# multi-user.target for rootful; default.target for a rootless --user unit.
WantedBy=multi-user.target default.target

ContainerName is set explicitly in each because Quadlet otherwise names a container after its unit with a systemd- prefix, and those names are what the URLs above resolve.

Then reload systemd and start it (add --user for the rootless path). Starting Harbor pulls the other three in through Requires=:

systemctl daemon-reload
systemctl start harbor.service

With AutoUpdate=registry, enabling podman-auto-update.timer keeps the containers on the latest published images — though harbor-chromium and harbor-keycloak are pinned to versions, so updating either is a deliberate edit rather than automatic.

Authentication

Harbor is an OIDC client and nothing else. It has no login form, no user table and no password of its own: an unauthenticated request is redirected to your provider, and what comes back is a token whose sub claim becomes the owner of every row that reader writes. Signing out signs you out of the provider too, so the next visit asks again.

Nothing in Harbor names a particular product. It needs discovery, the authorization-code flow, an id token and RP-initiated logout — all of them plain OpenID Connect. The examples below use Keycloak because that is what the development stack ships; if you already run something else, point Harbor at it and skip that container.

Three settings, and the app fetches the issuer's discovery document at startup — so a Keycloak that is down or misconfigured is an application that refuses to start rather than one that boots into a broken login:

HARBOR_OIDC_ISSUER_URI the issuer, e.g. https://your-provider/realms/harbor for Keycloak
HARBOR_OIDC_CLIENT_ID the client id, harbor by default
HARBOR_OIDC_CLIENT_SECRET the client's secret — it is a confidential client

In Keycloak: a realm, a client with standard flow on and public client off, and a redirect URI of exactly https://your-harbor/login/oauth2/code/oidc. Add a valid post-logout redirect URI of https://your-harbor while you are there, or signing out lands on a Keycloak error page. There are no roles to assign — every authenticated reader gets their own library and nothing else.

The issuer has to match. This is the one that costs an evening.

Keycloak stamps a URL into the iss claim of every token it issues, and Harbor refuses a token whose issuer is not the one it was configured with. Those two have to be the same string.

They agree by accident in development, where the app runs on the host and reaches Keycloak at the same localhost:8081 the browser does. They disagree the moment Harbor moves into a container, where the browser sees a public URL and the app sees harbor-keycloak:8080 — and it is tempting to configure the app with the address it can actually reach.

Don't. Set KC_HOSTNAME to the public URL and give Harbor that same public URL as HARBOR_OIDC_ISSUER_URI, even though the app then resolves it back through your proxy. The container can reach it; that is what matters.

The symptom of getting this wrong is a redirect loop between Harbor and Keycloak that names nothing useful in either log. If you see one, compare curl -s https://your-keycloak/realms/harbor | grep issuer against HARBOR_OIDC_ISSUER_URI. They will differ.

Serve over HTTPS (recommended)

Run the app behind a TLS-terminating reverse proxy (Caddy, Traefik, nginx, your cloud load balancer, …). A minimal Caddy config (automatic HTTPS) looks like:

harbor.example.com {
    reverse_proxy harbor:8080
}

Behind such a proxy, also set FORWARD_HEADERS_STRATEGY=native on the container. The app then trusts the proxy's X-Forwarded-* headers and can tell that a request arrived over HTTPS, which is what lets it mark the session cookie Secure and send Strict-Transport-Security. Leave it unset when the app is reachable directly, where those headers are client-supplied and spoofable.

What the server is allowed to fetch

Saving a link makes the server request that URL, so the address it lands on is a deployment decision. Only http and https are followed, and by default only public internet addresses: loopback, the private blocks, link-local (and so the cloud metadata address at 169.254.169.254), and the reserved ranges are all refused — including when a public URL redirects into them, and including the IPv6 forms that carry an IPv4 address inside them.

If your own hosts are private — a NAS, an internal wiki — permit them by range:

docker run --rm -p 8080:8080 \
  -e HARBOR_ALLOWED_RANGES=192.168.1.50/32 \
  binarycodes/harbor:latest

Comma-separated, IPv4 or IPv6, and it overrides the refused ranges rather than replacing them, so permitting one machine leaves everything else refused. A range Harbor cannot read fails startup rather than silently never matching.

Worth being clear about what this does and does not do. The guard bounds where the server can reach; who is allowed to make it reach anywhere at all is a separate question, answered by Authentication above. Any signed-in reader can use the save box, and this is what bounds where that goes.

It also does not bound the archiving browser. Chromium does its own DNS and its own connections, so HARBOR_ALLOWED_RANGES and the refused ranges above say nothing about it — and they cannot, because a page it renders can ask for any address, at any depth, long after Harbor has stopped looking. Deciding what that container may reach is a network design decision, not an application setting. If internal hosts are reachable from it, they are reachable by any page you archive.

Waiting for the archive

Rendering a page is the slowest thing Harbor does — a whole browser, fetching the page again along with its images and its fonts. By default the save waits for it: the Save button stays disabled until the archive exists, and a page that will not render is not saved at all. That is what makes every bookmark has a copy of its page a guarantee rather than an intention, and it costs a few seconds on every save.

If you would rather not wait:

docker run --rm -p 8080:8080 \
  -e HARBOR_FORCE_ARCHIVE_BEFORE_SAVE=false \
  binarycodes/harbor:latest

The bookmark is then filed as soon as the page has been read, and the archive is rendered afterwards, one page at a time. The reader sees Archiving… on the article until the copy lands. Two things change with it:

  • A bookmark can exist without a copy of its page, and for a page that turns out never to render, permanently — where waiting would have refused the save and told you why.
  • The archive of a page you re-read replaces the old one in the background. The older copy stays readable until the new one is done.

Anything still rendering when Harbor stops is picked up at the next start, so a restart costs a re-render rather than the archive.


Develop locally

Requirements: JDK 21, and a container runtime — the development database, the archiving browser and the identity provider all run in one, and the tests start their own throwaway copies. Every task goes through ./run.sh, which pins JDK 21 (from SDKMAN if present, otherwise your JAVA_HOME) — a bare mvn under a newer JDK makes Lombok fail in confusing ways. Run ./run.sh with no arguments to list the tasks.

./run.sh env up
./run.sh run

Open http://localhost:8080 and sign in as reader / reader. Every default matches the development stack in environment/dev, so nothing needs configuring — including the realm, which a one-shot container creates over Keycloak's admin API the first time the stack comes up. ./run.sh env reset throws the data away and gives you a first-run empty library. The frontend is rebuilt on the fly in development mode; the first start downloads npm dependencies and takes a little longer.

Those credentials, the harbor-dev-secret client secret in version control and the client that will redirect anywhere are all fine on a laptop and nowhere else; environment/dev/README.md says which is which, and how to add a second reader for checking that one library really is invisible to another.

Run the tests:

./run.sh test      # unit + browserless view tests, with the coverage gate
./run.sh verify    # the above plus the Playwright end-to-end journeys

Both start their own containers, so neither needs the development stack running: a PostgreSQL for anything touching the library, a Chromium for the archiver, and — for the end-to-end journeys, which sign in through Keycloak's real login form — a Keycloak that the fixture sets up over the admin API. run.sh finds the container engine from your docker context, which is what makes this work on Colima and Rancher Desktop — their socket lives under your home directory, where Testcontainers does not look on its own.

Where containers cannot run at all, point the tests at your own instead. The unit tiers need only a database; the end-to-end journeys need all three and are not runnable without a container runtime:

./run.sh test -Dharbor.test.database=external -Dspring.datasource.url=jdbc:postgresql://host:5432/harbor_test

Two more tasks worth knowing: after changing a @CssImport(themeFor=…) or @JsModule, run ./run.sh bundle; after editing an @import-ed CSS partial, run ./run.sh styles so the browser stops serving the stale one.


Build your own image

The image build does not run the tests — they need a database, and a docker build has no way to start one. ./run.sh verify is what checks the code; the image build only packages it.

Production build (fat jar in target/):

./run.sh package

Build a container — the repo ships a multi-stage Dockerfile and a docker-bake.hcl for multi-arch builds:

# single-arch, local
docker build -t harbor:latest \
  --build-arg APP_NAME=harbor \
  --build-arg APP_VERSION=1.0.0-SNAPSHOT \
  --build-arg GIT_SHA="$(git rev-parse HEAD)" .

# multi-arch (amd64 + arm64) via Buildx Bake
GIT_SHA="$(git rev-parse HEAD)" docker buildx bake

No Vaadin license or secret is needed — the UI is built entirely from Vaadin's free components, so the build depends on vaadin-core.

APP_NAME must match the Maven artifactId: the Dockerfile copies the jar as target/${APP_NAME}-${APP_VERSION}.jar. GIT_SHA is required because .git is excluded from the build context.


How it's published

binarycodes/harbor on Docker Hub is built and pushed automatically by the GitHub Actions CI workflow on every push to main, only after mvn verify passes. Images are signed with cosign and ship with provenance + SBOM attestations.


License

This project is licensed under the GNU General Public License v3.0 — see LICENSE for the full text.

That covers Harbor's own source code. Vaadin, Spring Boot, jsoup and the other dependencies it builds against remain under their own licenses.

About

Self-hosted bookmark and read-later library - paste a link, get a distraction-free reader with Markdown notes and highlights, and every page archived as a real PDF. Vaadin + Spring Boot + PostgreSQL.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages