Self-hosted service
ckolderup/postmarks avatar
ckolderup/postmarks

Postmarks: a bookmark site that lives on the Fediverse as an ActivityPub actor

a single-user bookmarking website designed to live on the Fediverse

544 stars43 forksJavaScriptMIT

At a glance

What is it?
Postmarks is a single-user bookmarking site you host yourself, and what makes it more than a personal links page is that it speaks ActivityPub, so your bookmarks are an actor other servers can follow and your site can federate with Mastodon and other Fediverse platforms. It runs on Express and SQLite in a single Node container, and the whole point of the design is that one person owns their data and their identity.
Who is it for?
Adopt Postmarks if you want a bookmark collection you own, hosted on your own server, that other Fediverse servers can follow, since a single-user ActivityPub bookmark site is a shape very few projects take. Do not adopt it for a team or multi-account use, because the whole design is one owner and one admin password, and do not expect federation to work from a local-only deployment, since ActivityPub servers reach each other by hostname over HTTPS.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 160 days ago.
What is it written in?
Mainly JavaScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

What the project is, and the Fediverse angle

Postmarks is a bookmarking site that you own yourself and can connect the Fediverse, interacting with other Postmarks sites as well as Mastodon or FireFish or any text-based ActivityPub platform. The topic list says the same thing in a different vocabulary: activitypub, activitypub-server, bookmark-manager, bookmarks-manager, mastodon, mastodon-compatible.

The word single-user in the description is the key architectural fact, and it explains most of the design. This is not a multi-tenant service where accounts are created and stored. It is one person's bookmarks, running on one person's server, and the ActivityPub side exists so that other people and other software can subscribe to that collection.

That is a small and unusual design choice, and it changes what the software has to do. A multi-user bookmarking service needs registration, password hashing, per-user isolation and an admin panel for managing users. Postmarks needs none of those. It needs one admin key, one account file, and a way to serve an ActivityPub actor and its outbox.

The author points at three notes for the background: a Getting Started note, an Ethos note and a Future Ideas note, all on the author's own site. The existence of an Ethos document is itself informative, because it suggests the project has a stated position rather than being a feature list.

The site allows the owner to add, edit and delete bookmarks, but only if a valid login is provided, and the setup instructions exist to explain how that login works.

The primary language is JavaScript, the licence is MIT, the default branch is main, and the homepage is a Glitch-hosted instance at postmarks.glitch.me. The last push was on 2026-05-15 with the project not archived, and the repository publishes no GitHub releases. The package version in the manifest is 0.0.1, so the versioning has not been formalised.

Setting up, and the four environment variables that matter

The setup section is a bullet list of things to do, and the whole configuration is four values.

bash
  - Create a `.env` file in the root of the project.
  - Add the line `PUBLIC_BASE_URL=<hostname>` to your .env where \<hostname\> is the hostname of your instance.
  - Add the line `ADMIN_KEY=<key>` to your .env where \<key\> is the password you'll enter when the browser prompts you, and another line for `SESSION_SECRET=<secret>` where \<secret\> is a random string used when hashing your session for use in a secure cookie.
  - Make a file called `account.json` in the project root. Copy the contents of `account.json.example` into it and edit the values to set your `@username`, display name, bio, and avatar.
    - If you don't set a username, your default actor name will be 'bookmarks', so people will find you on the fediverse `@bookmarks@<hostname>`.
    - You should update the avatar field in order to not hotlink to another server-- the value must be an absolute URL. If you change out the hostname and leave the existing filename, you will link to a statically hosted image on your server that was downloaded along with the rest of the Postmarks repo.

So the .env file needs a PUBLIC_BASE_URL holding the hostname of your instance, an ADMIN_KEY which is the password the browser will prompt for, and a SESSION_SECRET which is a random string used when hashing your session for use in a secure cookie.

The comment on the avatar field is the one that would bite a new user, so it is worth reading in full. You should update the avatar field in order to not hotlink to another server, and the value must be an absolute URL. And if you change the hostname and leave the existing filename, you will link to a statically hosted image on your server that was downloaded along with the rest of the repository.

That is a specific and slightly awkward consequence. The example account file points at an avatar hosted by the original author's server, so if you deploy with the example values your federated followers will see the author's avatar, and your instance will be making a request to someone else's server on every profile view. The fix is one line, but it is the kind of thing that is easy to leave in place.

The account file also decides your identity on the network. If you do not set a username, your default actor name will be bookmarks, so people will find you on the Fediverse at @bookmarks@<hostname>. The account.json file holds your @username, display name, bio and avatar, and the example file is copied and edited.

The session secret deserves emphasis as well. It is used when hashing the session for the secure cookie, and since this is a single-user site, that cookie is effectively the whole authentication story. A weak or reused secret is a weaker site, and there is nothing in the setup text warning about it, so treat it as the one value you must generate rather than invent.

Running it: Docker, npm, and the port behind a proxy

There are two ways to run the app and the documentation names both.

bash
If you are using Docker compose you can run `docker compose up` or `docker-compose up` (depending on the version of Docker you are using) and Postmarks should be running. Otherwise, run `npm run start` on the command line. You can also use `npm run start` in any kind of app/container-management tool or SaaS provider that lets you define an entrypoint.

By default, Postmarks runs on port 3000. You can change this with a `PORT=<number>` entry in your .env. If you're running Postmarks behind a reverse proxy, configure it to send traffic from your Postmarks hostname to this port.

If you are using Docker Compose you can run docker compose up or docker-compose up depending on the version of Docker you are using. Otherwise, run npm run start on the command line. You can also use npm run start in any kind of app or container-management tool or SaaS provider that lets you define an entrypoint.

The last sentence is the interesting one, because it is what makes this deployable to a PaaS. The entrypoint is a single command, the app is a Node process, and the state is a SQLite file, so almost any container platform can run it.

Port 3000 is the default, changed with a PORT=<number> entry in the .env. If you are running Postmarks behind a reverse proxy, configure it to send traffic from your Postmarks hostname to this port. And without a proxy to direct from the hostname, you add the port to the URL, so in basic local development mode it is available at http://localhost:3000.

That is the deployment model: a Node app, a port, and a reverse proxy in front. The reverse proxy is not optional in a federated setup, because ActivityPub servers reach each other by hostname over HTTPS, and a site reachable only on port 3000 by IP cannot be federated at all.

Once it is running, the login path is described in one paragraph. You click the Admin link in the footer and enter the password, which is whatever you set ADMIN_KEY to. You should then be logged in, at which point you can configure various settings, import bookmarks, and use the Add links in the header and footer, as well as the bookmarklet available in the Admin section, to save new bookmarks.

The bookmarklet is the detail that makes the site usable day to day. A bookmarklet is a small piece of JavaScript you keep in your browser's bookmarks bar, so saving a bookmark means clicking one button on any page, and the target page's metadata is picked up from where you are rather than from a form you have to fill in.

The Mastodon verification step is separate and optional. Setting MASTODON_ACCOUNT in the .env file causes a link to be added to the Postmarks home page that can be used for verification with your Mastodon account, following the Mastodon documentation for profile verification.

The stack, and why the dependency list reads the way it does

The package manifest describes the project as a bookmark store and linkblog, powered by Express, SQLite and ActivityPub, and the dependency list is the clearest evidence of how those three are put together.

json
  "dependencies": {
    "body-parser": "^1.20.2",
    "chalk": "^5.2.0",
    "connect-sqlite3": "^0.9.13",
    "cors": "^2.8.5",
    "csv-stringify": "^6.4.2",
    "dotenv": "^16.0.3",
    "es6-promisify": "^7.0.0",
    "express": "^4.18.2",
    "express-basic-auth": "^1.2.1",
    "express-handlebars": "^6.0.7",
    "express-session": "^1.17.3",
    "linkifyjs": "^4.1.1",
    "node-fetch": "^3.3.2",
    "open-graph-scraper": "^5.2.3",
    "sqlite": "^5.0.1",
    "sqlite3": "^5.1.5",
    "string-strip-html": "^13.4.2",
    "escape-html": "^1.0.3",
    "xml2js": "^0.6.2"

Express 4 is the server, with express-session for the session store and express-basic-auth for the admin password, which is what a single-password site needs. express-handlebars is the template engine, so the pages are server-rendered rather than a client-side app.

The SQLite side is connect-sqlite3 as the driver and sqlite3 as the native binding, with the sqlite package listed alongside. There is a .data/ directory in the repository tree, and the compose file mounts ./.data to /app/.data, so the database lives in a volume that survives container replacement. That is the only persistent state in the setup.

Two dependencies are about the Fediverse side of things in a way that is easy to miss. open-graph-scraper fetches the Open Graph metadata of a page, which is how a bookmark gets a title, a description and a preview image. And linkifyjs turns plain URLs in imported text into links, which is what makes an import of a links file work rather than producing a wall of unlinked URLs.

There is also csv-stringify, which tells you that import and export in CSV is a supported path, and xml2js, which is how the ActivityPub documents are parsed, since ActivityPub is XML-shaped JSON. And string-strip-html plus escape-html, which are the two ends of the same concern: stripping tags from imported text and escaping them on the way out.

Then a few small utilities that reveal the design. chalk for coloured console output, dotenv for reading the .env file, cors for cross-origin headers because the ActivityPub side needs them, body-parser for request bodies, es6-promisify for the older callback-based libraries the codebase inherited, and node-fetch for outbound HTTP to other servers when federating.

The engines field says node >=16.x, and the repository has a .node-version file, so the runtime floor is documented in two places.

The scripts are three: start runs node server.js, watch runs nodemon with the public directory ignored and npm start as the command, and lint runs eslint over .js files. The dev dependencies are the Airbnb base eslint config with prettier and the node plugin, which is a standard JavaScript setup and explains the .eslintrc.json and .prettierrc in the tree.

The container, and the federation groundwork

The Dockerfile is short and the ordering is deliberate.

dockerfile
FROM node:alpine

WORKDIR /app
COPY package.json /app/package.json
RUN npm install

COPY public /app/public
COPY server.js /app/server.js
COPY src /app/src

RUN npm install
EXPOSE 3000
ENTRYPOINT ["npm","run","start"]

It starts from node:alpine, sets the working directory, copies package.json on its own, runs npm install, and only then copies public, server.js and src. That ordering is a caching decision rather than an accident: the dependency layer is built before the application code is copied, so a change to your source files does not invalidate the installed packages. The npm install appears twice, once after the manifest and once after the source, which is redundant but harmless.

The final image exposes port 3000 and the entrypoint is npm run start, so the container runs the same command the manual instructions tell you to type.

The compose file adds the two things the Dockerfile cannot know about:

yaml
services:

   postmarks:
      build: .
      env_file: .env
      ports:
        - "127.0.0.1:3000:3000"
      volumes:
         - ./.data:/app/.data
         - ./account.json:/app/account.json:ro

So the environment comes from the .env file, the port mapping binds to 127.0.0.1 on the host rather than to every interface, the .data directory is mounted as a volume, and account.json is mounted read-only. That last one is a small piece of good hygiene: your account file is mounted as read-only into the container, so nothing running inside can rewrite it.

Binding to 127.0.0.1 is also deliberate and correct for this shape of deployment. The port is not meant to be reachable directly from the network, since the reverse proxy is the public face, and the compose file reflects that.

The repository has a FEDERATION.md file, which is where the ActivityPub side of the project would be described, and a .data/ directory checked in, plus public/ for the static assets, src/ for the application code, server.js at the root as the entry point, and a Dockerfile plus docker-compose.yml.

The acknowledgements section explains where the code actually came from, and it is unusually forthcoming about it. Much of the original frontend is lifted from the starter projects available on Glitch, and much of the original backend is based on Darius Kazemi's express-activitypub repository. The author adds a detail that is worth reading for what it reveals about the process: I made a point not to just clone his repo from the start, but then ended up retyping most of it as I learned how things work. While some pieces have been upgraded, much of Darius' work creates the foundation for Postmarks' ActivityPub functionality.

So the ActivityPub implementation, which is the most interesting and most effortful part of the project, is inherited work rather than original code, and the project says so. The name Postmarks was a gift from Casey C, who brainstormed dozens of alternatives, and the default icon is an edited version of Eynav Raphael's Postmark Stamp icon from The Noun Project.

How to evaluate it, and what to check first

There are four things to consider, and this is a project where the shape of the thing matters more than the feature list.

First, whether single-user is what you want. A personal bookmarking site that you host, with one admin password, is a good fit for one person with a lot of links. It is not a shared or team tool, and there is no multi-account support in the design, so if you need per-user accounts this is the wrong shape.

Second, whether you want federation. The ActivityPub support is the substance of the project, and it means your bookmarks become a collection other Fediverse servers can follow, and that you can interoperate with Mastodon, FireFish and other text-based ActivityPub platforms. If you only wanted a private links page, the ActivityPub half is complexity you do not need. The dependencies show what that half costs: XML parsing, outbound fetching to other servers, cross-origin headers, and an actor document to maintain.

Third, the deployment shape. A Node app on port 3000 with a SQLite file and a reverse proxy in front is easy to host, and the compose file gives you a working starting point with the database in a volume. The catch is that federation requires a public hostname over HTTPS, so a local-only deployment is fine for trying it out and useless for federating. The setup text says this plainly, that if you do not have a proxy to direct from the hostname you add the port to the URL.

Fourth, the two things to change before you expose it. The avatar in account.json must be an absolute URL pointing at your own server, or your profile view will hotlink to the original author's hosted image and you will be federating under someone else's avatar. And the SESSION_SECRET must be a random string of your own, because it is what hashes the cookie that is the entire authentication mechanism for a single-password site.

What you get is a small, self-hosted, Fediverse-native bookmark site with the ownership model in its favour, and a codebase whose ActivityPub foundation is inherited from a well-known open source project and improved in places. The honest caution is that the version number is still 0.0.1 and the last push was on 2026-05-15, so this is a project that runs, and that you should read the source of before you depend on it for anything you would miss.

Editorial conclusion

Adopt Postmarks if you want a bookmark collection you own, hosted on your own server, that other Fediverse servers can follow, since a single-user ActivityPub bookmark site is a shape very few projects take. Do not adopt it for a team or multi-account use, because the whole design is one owner and one admin password, and do not expect federation to work from a local-only deployment, since ActivityPub servers reach each other by hostname over HTTPS. Before you commit, change the avatar in account.json to an absolute URL on your own host, generate a random SESSION_SECRET rather than writing one yourself, and read FEDERATION.md and the ActivityPub portion of the source, because that layer is inherited from Darius Kazemi's express-activitypub project with the author's own upgrades, and it is the part that has to keep working as other servers change.

Frequently asked questions

What is Postmarks?

It is a bookmarking site you host yourself and can connect to the Fediverse, interacting with other Postmarks sites as well as Mastodon, FireFish or any text-based ActivityPub platform. The package describes itself as a bookmark store and linkblog powered by Express, SQLite and ActivityPub, and it is MIT licensed.

How do I set up Postmarks?

Create a .env file in the project root with PUBLIC_BASE_URL holding your hostname, ADMIN_KEY holding the admin password, and SESSION_SECRET holding a random string used to hash your session into a secure cookie. Then copy account.json.example to account.json and edit the username, display name, bio and avatar, and run the app with npm run start or docker compose up.

Why do I have to change the avatar in account.json?

The example file points at an image hosted on another server, and the value must be an absolute URL. If you change the hostname and leave the existing filename, your site will link to a statically hosted image on your server that was downloaded along with the rest of the repository, and until you update it your profile view hotlinks to someone else's server.

How does Postmarks handle authentication and sessions?

The site allows the owner to add, edit and delete bookmarks, but only if a valid login is provided, and because it is a single-user site that login is a single password set as ADMIN_KEY. The SESSION_SECRET is a random string used when hashing your session for use in a secure cookie, and the Admin link in the footer is where you enter the password.

Can Postmarks federate with Mastodon?

Yes. The project interacts with Mastodon, FireFish and any text-based ActivityPub platform, and setting MASTODON_ACCOUNT in the .env file causes a link to be added to the home page for verification with your Mastodon account, following the Mastodon profile verification documentation.

Official sources

  1. ckolderup/postmarks on GitHub
  2. Issues
  3. License: MIT
  4. Project website
  5. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/ckolderup-postmarks.svg)](https://hysenlabs.com/projects/ckolderup-postmarks)

Was this useful?