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.
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.
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:latestfor 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
PORTenvironment variable:-e PORT=9090 -p 9090:9090. - The database is configured with
HARBOR_DB_URL,HARBOR_DB_USERandHARBOR_DB_PASSWORD. Harbor creates and migrates its own schema on startup. - Login is configured with
HARBOR_OIDC_ISSUER_URI,HARBOR_OIDC_CLIENT_IDandHARBOR_OIDC_CLIENT_SECRET. Read Authentication before you deploy this: the issuer URL is the one thing here that is easy to get subtly wrong.
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.
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.targetharbor-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.targetharbor-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.targetharbor.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.targetContainerName 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-reloadsystemctl start harbor.serviceWith 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.
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.
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.
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.
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:latestComma-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.
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:latestThe 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.
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 runOpen 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 journeysBoth 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_testTwo 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.
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 packageBuild 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 bakeNo 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.
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.
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.