# Perma: indelible links for citations in scholarly publishing

> Perma.cc is a web archiving service that lets authors and journals create permanent archived citations, and the harvard-lil/perma repository is the Django application behind it. The interesting engineering in the repository is the deployment machinery, with one Dockerfile, five targets and a staging-then-production promotion path, rather than the linking feature itself.

**harvard-lil/perma** — Websites change. Perma Links don't. 

- Repository: https://github.com/harvard-lil/perma
- Website: https://perma.cc
- Stars: 534 · Forks: 91
- Language: Python
- License: not declared
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/harvard-lil-perma

## What Perma does, in one line

Perma.cc helps authors and journals create permanent archived citations in their published work. That is the whole product statement, and the repository's own tagline compresses it further: websites change, Perma Links don't.

The gap it addresses is specific and old. A scholarly citation points at a URL, and the URL is a promise about content that the publisher no longer controls. A paper's supporting website, a dataset page, a policy document, any of them can move, change or disappear, and the citation silently rots. Perma captures the page at the moment of citation and gives the reader a link that points at the capture rather than the original.

The project belongs to the Harvard Library Innovation Lab, and the repository is harvard-lil/perma, written in Python with the Django application living in perma_web/. The service runs at perma.cc, and the README gives a contact address at info@perma.cc.

What the repository contains is worth naming, because it is not a small script. At the top level there is a Dockerfile, a docker-compose.yml, a docker-compose.ci.yml, a docker-compose.override.yml, a Brewfile and Brewfile.lock.json for the local development toolchain, a perma_web/ directory holding the Django project, a services/ directory holding the sidecar services, a developer.md, an errata.md, an ingest.sh, an init.sh, a make_cert.sh and a websites-change-square.svg. There is a .dockerignore and a .cursor/ directory, which suggests the project has adopted an editor-assistant workflow as well as the standard tooling.

The project is not archived and the last push to the repository was on 2026-10-14, so it is being worked on.

## The one Dockerfile and its five targets

The Dockerfile is the most informative file in the repository, and its opening comment explains the design in full. The header says one Dockerfile, five targets, in the shape of h2o's, and then names each one.

The base target is the shared Python and node runtime plus Perma's dependency set, with no app code, and it is not run directly. The assets target is the compiled webpack bundles, built from source in a node image. The prod target is the deployable image, which is base plus app code plus bundles plus gunicorn, non-root, and the comment says that every ECS role, meaning web, beat and each worker, runs this image, with the container command choosing the role. The test target is prod plus the test toolchain, and it is what CI runs the suite against. The dev target is base plus the test toolchain with no app code, because docker-compose bind-mounts the working tree at /perma.

The comment on the layout is the kind of detail that saves a new contributor an hour. The layout inside the image mirrors the repository, with /perma as the checkout, /perma/perma_web the Django project as the WORKDIR, and /perma/services the sidecar directories for js-wacz, cloudflare IP lists and the RDS CA bundle.

The tool images are pinned by digest rather than by tag. The uv image comes from ghcr.io/astral-sh/uv at version 0.12.7 with a sha256 digest, and node is node:24.20.0-trixie-slim with a digest, and the Python base is python:3.14-slim-trixie with a digest. The comment says the images are pinned and named once so every stage below agrees on them, and that uv is copied out of its image as a static binary while node is both the assets builder and the source of the Node runtime copied into base.

That last detail is the one that matters for anyone rebuilding the image. The base stage carries a Python runtime and a Node runtime, and the dependency layer is built with uv, so the image is a uv-managed Python application with a Node toolchain still present for the frontend build.

## The compose file, and the services a Perma deployment needs

The docker-compose.yml at the repository root shows the local topology, and the redis service carries a comment that explains a detail that would otherwise cost an afternoon.

```yaml
  perma-redis:
    image: redis:4.0.6
    volumes:
      - redis_data:/data:delegated
  db:
    image: postgres:16.6
    environment:
      POSTGRES_PASSWORD: password
      POSTGRES_DB: perma
      POSTGRES_USER: perma
    volumes:
      - postgres_data_16:/var/lib/postgresql/data:delegated
    networks:
      - default
      - superset
```

The comment above the redis service says to use Redis for caching by adding the caches setting found in settings_prod to your settings.py, with a LOCATION of redis://perma-redis:6379/0, and adds the warning not to clobber the Celery backend, which is at /1. So one Redis instance serves two purposes on two different databases, and mixing them up breaks the task queue.

The database service runs Postgres 16.6 with a named volume for the data directory, and it joins two networks, default and superset. The superset network is for Superset, the analytics tool, which means the database is reachable from a separate reporting stack without being exposed to everything else.

The web service's comment is about staleness rather than configuration. It is built locally and not pulled, and pull_policy: build means a plain docker compose up rebuilds when a build input changes and is a no-op when nothing has changed, so there is no image tag to bump and no way to end up running a stale image after pulling someone else's dependency change. The comment says Docker's layer cache is the staleness check, and that CI overrides the target in docker-compose.ci.yml.

That is a small design decision with a large payoff. In a conventional compose file, the web service pulls a tagged image, and the tag has to be bumped by hand, and if you forget, you deploy yesterday's code and find out from the logs. Here the build is the check, and the command is bash so that a developer can drop into the container.

The bind mounts are equally deliberate. The working tree is mounted at /perma, and the perma_web, services/celery, services/cloudflare and services/django directories are mounted individually, with a comment explaining that node_modules and the compiled bundles live in the bind-mounted tree, are gitignored, and are installed and built by perma_web/frontend_assets.py when they are missing or stale. A named volume carries a read-only scoop_access_key into the container at /tmp/scoop_access_key.

## How a release actually reaches production

The contributing and releases section of the README is short and it changes how you should think about the branches.

Open feature and fix pull requests go against main, which is the default integration branch. The develop branch is retained as a frozen historical branch, and existing pull requests targeting it will be moved in coordination with their authors. So develop is a dead branch kept for history, not a place to work.

The deployment path is the part that matters. Releases use merge-commit promotions from main to staging, then from staging to prod. Merging a promotion deploys that environment, and production uses the image already validated on staging. The README points at the release workflow section of developer.md for the details of the git and GitHub side of this.

That is a deliberately conservative arrangement. The two properties it buys are worth naming. First, nothing reaches production without having been deployed to staging first, and production uses the exact image that staging validated, so a build that works in staging is the build that ships. Second, because promotion is a merge and not a rebuild, the artefact under test is the artefact under production.

The three environment names, main, staging and prod, are the whole topology. There is no separate release branch, and the merge-commit style means the promotion commit carries the history of what was promoted rather than squashing it away.

The Docker section of the README makes the same point in image terms. The prod target comment says every ECS role, web, beat and each worker, runs this image, and the container command chooses the role, so one image serves the web process, the Celery beat scheduler and the Celery workers, and the difference between them is a command rather than a build. That is why a single Dockerfile with five targets is enough, and it is also why the request logging section talks about identifying the built commit through an environment variable rather than through a separate image per role.

## Request logging, and the proxy trust boundary

The request logging section is the most detailed piece of operational documentation in the README, and it reads like a set of lessons learned rather than a design document.

The ECS web process uses lil-request-logging for JSON access records on stdout, and ECS delivers these to CloudWatch alongside Django logs. The managed Perma dashboard at lil.grafana.net reads both the JSON and older combined access records, and durations are displayed in milliseconds. Access records use service=perma and the tier from APP_CONFIG, and the image sets SENTRY_RELEASE=perma@PERMA_VERSION, identifying the built commit for both the access logs and Sentry, including Celery errors. The release stays the same when that image moves from staging to production, which is the point of pinning the release identifier to the image rather than to the environment.

The proxy section is the security-relevant part, and it is specific about a boundary. Perma's WSGI proxy middleware sets REMOTE_ADDR for Django, but the access logger uses Gunicorn's original connection peer so that middleware cannot change which peers are trusted. Only loopback peers may supply the Cloudflare client IP, matching the task-local tunnel and lack of inbound task rules, and Django's proxy validation remains unchanged.

In other words, the logging path is deliberately insulated from the application path. If the middleware that sets REMOTE_ADDR were compromised or misconfigured, it could not influence which peers the access logger treats as trusted, because the logger never reads REMOTE_ADDR for that purpose. The Cloudflare client IP is only honoured from loopback, which is where the tunnel terminates, and there are no inbound task rules, so a task has no path to the service that could spoof a client address.

The rollout note at the end is also worth reading as a procedure. Grafana queries must support JSON before deploying this image, because existing ECS GUNICORN_CMD_ARGS text-format settings are harmless with the shared adapter and remain useful for rollback to an older image. The managed dashboard change is applied separately, and then request records and latency are verified in staging before production promotion.

## Where Perma is the wrong choice

Three cases point elsewhere. The first is a developer who wants a library for capturing and linking web archives. Perma is a Django application for a hosted service, with a Postgres database, Redis for caching and for the Celery backend on separate databases, Celery for background work, Cloudflare-related services and a js-wacz sidecar, and an ECS deployment with distinct web, beat and worker roles. There is no importable module here that captures a page and hands you a permalink.

The second is a team that wants its own archiving infrastructure but not a whole publishing service. The archiving side of Perma exists to support citations in published work, so the data model, the capture pipeline and the permissions are shaped around publishers and their users. A team that only needs to archive pages it controls is building a different product, and the pieces worth reusing are the capture code in the services/ directory rather than the application around it.

The third is an organisation that expects a self-hosted instance to be a light commitment. The repository shows a Postgres instance with a named volume, a Redis with two logical databases, a Celery stack with beat and workers, an image built for ECS with a specific IAM and networking story involving Cloudflare and a task-local tunnel, and a Grafana dashboard that lives outside the deployment. The licence makes it possible, since the project is dual licensed under the MIT license and the GPL license, and the copyright is held by The Harvard Library Innovation Lab, so a fork is legally straightforward.

A general-purpose web archive such as the Internet Archive's Wayback Machine is the other obvious reference point. The difference in approach is about who curates and who is accountable. Perma is a curated, publisher-operated service where a citation is created deliberately and the capture is a service-level promise with an accountable operator behind it, which is exactly what a journal wants from a citation link.

## Conclusion

Perma fits a library, journal or press that wants to give its authors durable citations and needs the archiving to be the publisher's own responsibility rather than a third party's. It does not fit a developer looking for a general web archiving library, because the repository is a whole Django application with a Postgres database, Redis, Celery and a set of sidecar services. Before you install it, read developer.md, since the README sends every installation and development question there, and read the release workflow section before touching a branch, because the promotion path from main to staging to prod is what actually deploys and the develop branch is frozen.

## FAQ

### What is Perma and what does Perma.cc do?

Perma.cc helps authors and journals create permanent archived citations in their published work, so a citation points at a captured page rather than at a URL the publisher no longer controls. The project is the Harvard Library Innovation Lab's service, and its tagline is that websites change while Perma Links do not.

### How do I install and develop Perma?

The README sends both questions to developer.md. For installation it points at the installation section of the developer doc, and for working on the code it points at the developer doc generally. The repository is a Docker Compose project with a Dockerfile, docker-compose.yml, docker-compose.ci.yml and docker-compose.override.yml, and a Brewfile for the local toolchain.

### What are the five Docker targets in the Perma Dockerfile?

They are base, the shared Python and node dependency layer with no app code, assets, the compiled webpack bundles, prod, the deployable image with app code, bundles and gunicorn running non-root, test, which is prod plus the test toolchain that CI runs, and dev, which is base plus the test toolchain with the working tree bind-mounted at /perma.

### How do Perma releases reach production?

Releases use merge-commit promotions from main to staging, then from staging to prod, and merging a promotion deploys that environment. Production uses the image already validated on staging, while the develop branch is retained as a frozen historical branch that pull requests will be moved away from.

### What licence is Perma released under?

The project is dual licensed under the MIT license and the GPL license, with the copyright held by The Harvard Library Innovation Lab. The MIT text is reproduced in the README, and the repository records no other licence classification.

## Sources

- [harvard-lil/perma on GitHub](https://github.com/harvard-lil/perma)
- [Issues](https://github.com/harvard-lil/perma/issues)
- [Project website](https://perma.cc)
- [README](https://github.com/harvard-lil/perma/blob/main/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/harvard-lil-perma
