A certificate expiry board. Someone uploads a certificate, everyone else sees a plain-language page saying how long is left, and the right people get an email, a Teams message and a calendar invite before it runs out.
It was written for the certificates that middleware teams renew by hand and track nowhere — AS2 signing and encryption certificates, TLS server certificates, partner and client certificates. NotAfter never connects to the systems that use those certificates. It is only fed the files.
No After is a register of expiry dates, not a key store. It never receives, parses, stores, logs or transmits a private key. Everything else in the design gives way to that.
- Self-hosted and MIT licensed. A private repository, not a public project.
- One container, SQLite, no cloud dependency.
- Server-rendered pages; the only JavaScript is the bundle that reads
.pfxfiles inside your browser. - No external assets at run time: no CDN, no web fonts, no analytics.
This section is for anyone reviewing NotAfter who does not work with certificates every day.
A certificate file comes in two shapes. A certificate on its own is public
information — it is what a server hands to anyone who connects to it, and it
says who the certificate is for, who issued it, and when it expires. A
keystore (a .pfx or .p12 file) is a certificate plus its private
key — the secret half, which proves ownership. Private keys must never be
copied around.
For each certificate, exactly these fields:
| Field | Example | Why |
|---|---|---|
| Label | Integration PROD |
The name people recognise |
| Environment, owner, notes | PROD, owner@example.org |
Typed in by a person |
| Subject common name | edi.example.org |
Which system it is for |
| Subject and issuer | CN=edi.example.org,O=… |
Who it is for, who issued it |
| Serial number | 18374… |
Identifies it to the issuer |
| Valid from / valid until | 8 April 2027 |
The point of the whole app |
| SHA-256 fingerprint | A1:B2:… |
Spots a re-upload of the same file |
| Subject alternative names | DNS:edi.example.org |
Other names it covers |
| Key algorithm and size | RSA, 2048 bits |
Describes the key; is not the key |
| The public certificate (PEM) | -----BEGIN CERTIFICATE-----… |
Public; used to re-check and de-duplicate |
Plus who registered it and when, which notifications went out, and an audit line for every change.
- Private keys. No column in the database can hold one. There is no code path that reads one.
- The uploaded file. Nothing is written to disk or to a temporary directory. Uploads are parsed in memory and dropped.
- The password of a
.pfxfile. It is typed into the page and used by your browser. It is never sent to the server. - Request bodies, in any log. Log lines are filtered so that PEM blocks, passwords, tokens and webhook URLs are masked even if a stack trace would otherwise print them.
- You pick the file. It is read by your browser, not uploaded.
- If it is encrypted, the page asks for the password. That happens in the tab; nothing has left your machine yet.
- The browser opens only the certificate bags of the file. The key bags are
skipped — they are never decrypted or read. Files from Windows,
keytooland older OpenSSL use RC2-40 or Triple DES, which browsers have no built-in support for, so those are decrypted in JavaScript by node-forge. - The page shows you what it found and sends the server only the public certificate, as text.
- The file bytes and the password are discarded.
The server independently refuses anything that could carry a key. Send it a
.pfx directly — or a PEM file with a PRIVATE KEY block in it — and it
answers 400 before parsing anything, and writes nothing anywhere. There are
tests for each of those cases.
The settings page holds the defaults: which days before expiry send an email
and a Teams card, how far ahead the renewal event sits, what alarms attendees
get, how often an expired certificate re-alerts Teams, where amber and red
fall, and who is notified. The only timing fixed by the environment is what
time of day the job runs (DAILY_RUN_TIME).
Any certificate can depart from those defaults on its own page — its own reminder days, its own calendar lead time and alarms, its own list of people, or a mute. Each is an override: leave it empty and the certificate follows the global setting, so a certificate differs only where somebody said it should. Its page states which of the two it is following.
One message, not one each. Everyone on a certificate's list is addressed on a single email, and they are the attendees on that certificate's two calendar events — so people can see who else knows, and a reply reaches them.
Teams stays global. A channel is somewhere people are invited in Teams; which of them should hear about which certificate is not something this application can or should decide. Per-certificate control over Teams is therefore the mute, and nothing finer.
| Page | Who | What |
|---|---|---|
/ |
everyone | The board: every certificate, soonest expiry first |
/certificates/{id} |
everyone | One certificate, its history and its notifications |
/certificates/new |
editors | Upload a file, or type in an expiry date |
/settings |
editors | Recipients, thresholds, Teams webhook, test messages |
/audit |
editors | Every change anyone has made |
/healthz |
the container | Scheduler state and the last job result |
Status colours. Green above 60 days, amber at 60 or fewer, red at 30 or fewer, red once expired. Both thresholds are configurable. "Days left" is never stored — it is worked out from the expiry date every time a page is rendered or a notification is considered.
Who did what. Access authenticates every request, so there is no login
page — the first request carrying a new Access token is the sign-in, and it
is recorded as auth.signin in the audit trail with the email from the JWT.
Subsequent requests on the same token are the same session, so this is one
line per person per session rather than one per request. Every change is
recorded against the same email. Neither record holds the token, a cookie or
an IP address.
Notifications. The job runs once a day, at DAILY_RUN_TIME. Each
certificate gets at most one message per run, on every configured channel —
so email and Teams carry the same reminder, on the same day.
Which day? The nearest reminder it has just crossed — 60, 30, 14, 7 and 1 day before expiry by default, plus the expiry day itself, which is always notified whatever the list says. Only the nearest one: a certificate registered with 20 days left gets the 30-day reminder, not the 60-day one as well. Each is sent exactly once, ever — a unique constraint in the database means a restart, a second run or a manual run cannot repeat one.
So over a certificate's last two months that is six messages per channel, on six separate days.
Once a certificate has expired the two channels part company. Email stays on the daily run. Teams escalates: a second job runs every hour, on the hour, and re-posts the alert for every expired certificate until it is renewed, archived or muted — the card itself says so, so nobody has to work out how to make it stop. The interval is on the settings page ("Repeat the Teams alert every … hours once expired"); 0 puts Teams back on the daily run. Both jobs are idempotent, so a restart or a catch-up run inside the same hour sends nothing extra.
Failures are retried on the next run and shown on the settings page and
/healthz.
Calendar invites. Registering a certificate sends one invitation: an all-day event on the expiry date, with everyone who should know as attendees, carrying every reminder as an alarm — 30 days ahead to start the renewal, then 7 days and 1 day, all configurable globally and per certificate. "Start renewing" and "this expires soon" are the same event seen from different distances, so a second invitation would only mean a second thing to accept and keep in step.
The UID is stable, so replacing or archiving the certificate updates or cancels the event people already have in Outlook or Google Calendar rather than leaving it behind. Changing a timing re-sends the invitation for the same reason: a calendar only moves an event when it receives an update for that UID, so without it the new setting would apply to future registrations only and quietly disagree with what is already out there.
- A Linux host with Docker and the Compose plugin.
- An existing host-level
cloudflaredservice, already connected to your Cloudflare account. - A Cloudflare Access application in front of the hostname you will use.
- A Resend account with a verified sending domain, or any SMTP server.
- Optionally, a Microsoft Teams Workflows webhook.
NotAfter does not run a tunnel, a reverse proxy or an ACME client, and the compose file has exactly one service. Ingress is the host's business.
git clone https://github.com/example/notafter.git
cd notafter
cp .env.example .env
python3 -c 'import secrets; print(secrets.token_urlsafe(48))' # SECRET_KEYFill in .env. The values that matter most:
| Variable | What it is |
|---|---|
BASE_URL |
The public URL. It goes into every email and invite. |
SECRET_KEY |
The value you just generated. |
CF_ACCESS_TEAM |
The <team> in https://<team>.cloudflareaccess.com. |
CF_ACCESS_AUD |
The Access application's Audience tag (step 3). |
EDITOR_EMAILS |
Who may change things. Everyone else is read-only. |
RESEND_API_KEY |
From the Resend dashboard. |
EMAIL_FROM |
The sending address, on a domain verified in Resend. It is also the organiser of every calendar invite. |
The app refuses to start in AUTH_MODE=cloudflare without CF_ACCESS_TEAM
and CF_ACCESS_AUD, or with the placeholder SECRET_KEY still in place.
In your Cloudflare dashboard, on the tunnel the host already runs, add an ingress rule:
certs.example.org → http://127.0.0.1:8087
Or, if the host's cloudflared is configured from a file, add to its
ingress: list — before the catch-all rule:
ingress:
- hostname: noafter.example.org
service: http://127.0.0.1:8087
- service: http_status:404then sudo systemctl reload cloudflared.
The container publishes only to 127.0.0.1:8087, so nothing but the host —
and therefore nothing but the tunnel — can reach it.
In Zero Trust → Access → Applications, add a self-hosted application
for certs.example.org. Add a policy for the people who should see the board.
Then open the application's Overview tab and copy the Application
Audience (AUD) Tag into CF_ACCESS_AUD in your .env.
Every request now arrives with a Cf-Access-Jwt-Assertion header. NotAfter
validates that token against Cloudflare's published keys on every request, and
takes the user's email address from it. It never trusts a header on its own.
docker compose up -d --build
docker compose logs -f notafter
curl -s http://127.0.0.1:8087/healthzOpen https://certs.example.org, sign in through Access, and go to
Settings to add the notification recipients.
In Teams, on the channel you want: Workflows → "Post to a channel when a webhook request is received". Create it, copy the URL, and paste it into Settings → Microsoft Teams. Then press Send test Teams card.
Read the result carefully. A Workflows webhook replies 202 Accepted as
soon as it has queued the flow run — before any of the flow's own steps
execute. So a 202 proves the URL is live and the request was accepted; it does
not prove a card reached the channel. If none appears, the flow ran and failed,
and its run history in Power Automate names the step that broke. The
settings page shows the exact JSON that is posted, so you can compare it with
what your flow expects: the card is in attachments[0].content.
The test posts the same card as a real reminder, with one extra line saying it is a test — so if the test renders, real notifications will too.
The URL is a secret. It is stored in the database, never shown again after it is saved, and masked in log lines. The retired "Office 365 connector" webhooks are not supported — the payload NotAfter sends is an Adaptive Card 1.4.
On the settings page: Send test email to me, Send test Teams card, Send test invite to me. Then Run the notification job now — it is idempotent, so it is safe to press whenever you like.
Registering a certificate. Register → Upload a file → pick it. For a
.pfx, the page asks for the password if it needs one, and shows what it
found before anything is sent. If you do not have the file, use Enter the
expiry by hand; the record is marked unverified until someone attaches the
certificate later.
Renewing. Open the certificate, use Renew or replace, upload the successor. The old record is archived and kept, its reminders stop, its calendar events are cancelled, and new invites go out for the replacement.
Quietening one certificate. Open it and tick Mute reminders. Or archive it, with a reason.
Nothing is being sent. Check /healthz (scheduler.running should be
true, last_job should be recent) and the Recent problems table on the
settings page, which shows the actual error. Failures retry on the next run;
Run the notification job now retries immediately.
Someone can see the board but cannot change anything. Their address is not
in EDITOR_EMAILS. Add it and restart the container.
Everyone gets 401. The Access application's AUD tag does not match
CF_ACCESS_AUD, or the request is not coming through Access at all. Compare
the AUD tag in the dashboard with your .env.
A reminder went out twice. It should not be possible; the database
prevents it. Check notification_log — if there really are two sent rows
for one (certificate, channel, rule), that is a bug worth reporting.
Backup. Everything is in the notafter-data volume:
docker compose stop notafter
docker run --rm -v notafter-data:/data -v "$PWD":/backup alpine \
tar czf /backup/notafter-$(date +%F).tar.gz -C /data .
docker compose start notafterRestore.
docker compose down
docker volume create notafter-data
docker run --rm -v notafter-data:/data -v "$PWD":/backup alpine \
tar xzf /backup/notafter-2027-04-08.tar.gz -C /data
docker compose up -dThe database is SQLite in WAL mode, so back it up with the container stopped,
or copy notafter.db, notafter.db-wal and notafter.db-shm together.
Upgrading. git pull && docker compose up -d --build. Migrations run at
start-up. Take a backup first.
make setup # virtual environment and npm packages
make dev # http://127.0.0.1:8000 with AUTH_MODE=dev
make check # lint, types, tests, dependency auditAUTH_MODE=dev takes the user's email from an X-Dev-User header, which
anyone could forge — it is for local work only and binds to 127.0.0.1.
app/ FastAPI application
parsing.py the refusal gate, then the parser
auth.py AuthProvider, Cloudflare Access, dev header
notifier.py who gets told, once
notify/ email, Teams, iCalendar
jobs.py the daily job and the scheduler
web/src/ TypeScript: in-browser .pfx extraction
tests/ pytest, including an optional Playwright test
alembic/ migrations
Run the browser test — which proves that only PEM leaves the page — with:
.venv/bin/pip install playwright && .venv/bin/playwright install chromium
.venv/bin/pytest -m browsertests/test_pfx_extraction.py runs the same TypeScript through Node against a
.pfx in every encryption scheme, including the legacy ones Windows and
keytool produce. Run those if you touch anything under web/src/.
Notes to a later self, each of which exists because ignoring it caused a problem once.
Never let a private key reach the server. A change is wrong if it parses
PKCS#12 server-side "just to read the certificate", writes an upload to disk
or a temp directory even briefly, sends a password or a .pfx to the server,
or adds a column, log line or error page that could carry key material. The
tests in tests/test_parsing.py, tests/test_upload_routes.py and
tests/test_browser_extraction.py exist to make those mistakes loud — do not
weaken them to make a change pass.
Read every generated migration before running it. alembic revision --autogenerate does not know about Python-side defaults, so it writes
NOT NULL columns with no server_default. Those cannot be added to a table
that already holds rows, and the failure appears as a crash-looping container
against real data, not in the test suite. This has happened three times. Test
each migration against a copy of the live database:
docker cp notafter:/data/notafter.db /tmp/copy.db
DATABASE_URL="sqlite:////tmp/copy.db" .venv/bin/alembic upgrade headKeep the identifiers stable. iCalendar UIDs and PRODID, the Python
package, the database file and the container all still say notafter. A
calendar client matches an update to the event someone already holds by
UID; renaming would orphan every invitation ever sent.
Fictional data only. Fixtures, examples and screenshots use example.org
and invented labels. Real hostnames and addresses live in .env and in the
database, and neither is committed.
Explain, don't blame. Every error a person can see should say what happened and what to do next. The board is read by people who do not know what a certificate is: no jargon, no abbreviations, no all-caps.
- Authentication is delegated to a trusted identity-aware proxy behind a
small
AuthProviderinterface. Cloudflare Access is built in; adding OIDC means writing one class. The Access JWT is validated on every request — issuer, audience, expiry and signature, against keys fetched from/cdn-cgi/access/certsand cached. The email in that token is the identity used everywhere: it names who signed in and who made every change. - Roles are
viewer(anyone who passes Access) andeditor(listed inEDITOR_EMAILS). Every editor action re-checks. - CSRF: signed double-submit tokens on every state-changing request.
- Rate limits on uploads and settings changes, per user.
- CSP is
default-src 'self'with nounsafe-inlineand nounsafe-eval, plusframe-ancestors 'none',nosniffandReferrer-Policy: no-referrer. There is a test asserting that no served page, script or stylesheet mentions an external origin. - The container runs as uid 10001 with a read-only root filesystem, all
capabilities dropped,
no-new-privileges, and a pinned base image by digest. It needs outbound access to your SMTP server and, if you use it, the Teams webhook, andapi.resend.comwhen email goes through Resend. Nothing else.
Choices made while building this, and why.
- A small in-process rate limiter instead of
slowapi. The specification said "slowapi or equivalent". One container serves this app, so a sliding-window limiter in memory is exactly as effective and is 40 lines with a test, rather than another dependency. - The Teams webhook lives in the database, not the environment. It is entered on the settings page so it can be rotated without a redeploy. It is never rendered back into the page and is masked in logs.
/healthzneeds no authentication. The container healthcheck calls it from inside the container, before any proxy. It exposes no certificate data — only whether the scheduler is running and how the last job went.- CSRF is a dependency, not middleware. Reading the token out of a multipart body in middleware consumes the request stream before the endpoint can parse it. As a FastAPI dependency it shares Starlette's form cache with the endpoint, so both see the same fields.
- Only the nearest crossed threshold is sent. A certificate added with 20
days left would otherwise fire 60, 30 and 14 at once. The thresholds it
skipped are written to
notification_logasskipped, so they cannot fire later either. - The built browser bundle is committed to
app/static/. The Dockerfile rebuilds it from source anyway; committing it meansmake devand the test suite work without Node installed. - pkijs walks the PKCS#12 structure; node-forge decrypts it. The
specification allowed node-forge "if PKCS#12 support is more robust", and it
is: pkijs implements only PBES2, so a
.pfxfrom Windows,keytoolor older OpenSSL — RC2-40 or Triple DES, neither of which WebCrypto has — could not be opened at all. Only the blob holding certificate bags is decrypted; the private key bag is encrypted separately and is never passed to the decryptor. Importing forge's individual modules rather than its index keeps 140 KB out of the bundle. - Email goes through Resend's HTTP API by default, and this costs something.
Resend has no way to express a
multipart/alternativetext/calendarpart, which is what makes Outlook and Google Calendar render an invite with accept and decline buttons. Over the API an invite arrives as an.icsattachment with the rightmethod=content type — openable, but not a native invite. Resend's own SMTP relay does not have this limitation, soEMAIL_PROVIDER=smtpwithSMTP_HOST=smtp.resend.comis the setting to use if the calendar behaviour matters more than the API does. Both go through the same Resend account. - The display name is "No After"; the identifiers are not. The wordmark,
the page titles and the wording of every message say "No After". The
iCalendar
UIDs andPRODIDstill saynotafter, and must: a calendar client matches an update or a cancellation to the event someone already holds byUID, so changing it would orphan every invite ever sent. The Python package, the database file and the container keep the old name for the same reason — they are addresses, not branding. - Two vendored typefaces, both subset. Inter for everything, subset to
Latin with its weight and optical-size axes intact (119 KB). Nabla for the
wordmark, subset to its eight characters (5 KB) — a chromatic COLRv1 face
recoloured to the brand palette with
@font-palette-values. Both are SIL OFL and their licences are served next to them. Sources and the rebuild step are inassets/fonts/. create_allat start-up as well as Alembic. Migrations are what runs in the container;create_allis what makes a fresh test database. Both derive from the same models.
MIT — see LICENSE.