Introduction#

In Self-hosting remark42 alongside an existing Caddy on VPS I set up a remark42 instance with a minimal configuration. The container runs, the widget embeds on the blog, everyone can comment as anonymous. The starter setup is functional enough to prove something works, but it has two gaps that show up on day one of real use: no meaningful authentication and no notifications of any kind, which means a new comment can only be noticed by manually visiting posts.

In this article I get three remark42 elements working. Authentication via GitHub OAuth as the main login channel, email notifications for the admin and thread subscribers, and magic-link email as a third channel for people without a GitHub account. I walk through the configuration conceptually, treating specific provider examples (GitHub as OAuth, EmailLabs as transactional SMTP) as illustrations of the mechanics, not as “the one true choice”. If you have different preferences, the roadmap stays the same, only the values in .env change.

Authentication via GitHub OAuth#

OAuth is a delegation of identity verification. remark42 doesn’t hold reader passwords, it just asks the provider whether a given person really is who they claim to be, and the provider responds with a signed token. remark42 sees the result: username, avatar, identifier scoped to the provider. Nothing more is needed to issue its own session and tie comments to it.

I use GitHub as the example. The convention AUTH_<PROVIDER>_CID and AUTH_<PROVIDER>_CSEC transfers unchanged to other providers supported by remark42 (Google, GitLab, Microsoft, Facebook, and a few others), so if you picked a different one, the steps below are the same, you only change the provider name in the variables and go into its developer panel instead of GitHub’s.

Registering an OAuth App with the provider#

Before touching .env, you have to register the application on GitHub’s side so that GitHub knows who is allowed to ask a user to log in and where to send the confirmation back. Without this step GitHub will not agree to redirect from the login flow. The path in the panel goes through Settings → Developer settings → OAuth Apps → New OAuth App. Watch out for naming: next to OAuth Apps there is a GitHub Apps section, but that’s a different thing used for repository-level integration, not for end-user login.

The form has three required fields and one checkbox. Application name is visible to the user at first login on the “X wants to access your GitHub account” screen, so make it sound like your blog, not a generic “Comments”. Homepage URL is the blog address. Enable Device Flow leave off, remark42 doesn’t use it.

The field that eats the most debugging time is Authorization callback URL. It must have exactly the format https://<REMARK_URL>/auth/github/callback, where <REMARK_URL> is the public domain of your remark42 instance. The path /auth/github/callback is fixed, GitHub compares it bit for bit with the URL that remark42 sends in the OAuth request. Any deviation (http instead of https, missing /callback, a typo in the subdomain, a trailing slash) ends with a redirect_uri_mismatch error and a login that never completes. This is literally the most common setup mistake.

After saving the form, GitHub shows the Client ID (a public identifier like Iv1.abcdef123) and an empty Client secrets section. You generate the secret with the Generate a new client secret button. It appears just once as a long string. Closing the tab without copying it means you have to generate a new one. It’s not a database password, it’s the permission to represent yourself to GitHub as your application, so treat it like any other secret: keep it in a password manager, out of git, out of logs.

Wiring it into .env and verifying#

Configuration on the remark42 side is two variables:

AUTH_GITHUB_CID=Iv1.abcdef123
AUTH_GITHUB_CSEC=<secret_from_GitHub_panel>

remark42 detects the presence of the CID/CSEC pair at startup and adds a GitHub button to the login widget on its own. You don’t have to click anything on the admin side, there is no separate “providers” section, the whole detection is declarative based on environment variables. Every additional pair (GitLab, Google, and so on) adds another login button to the widget, all of them work side by side.

After a restart (docker compose up -d remark42) check the logs:

docker compose logs remark42 | grep -i "github\|oauth"

Look for a line saying that the GitHub provider was brought up. Open the comments widget in a second browser (or a private window), click “Sign in”, you should see a GitHub button next to the anonymous one. After clicking, GitHub will show the “Authorize X” consent screen. Accepting it will take you back to the blog as a logged-in user, with the avatar and nickname from GitHub.

A multi-tenant warning#

It’s worth noting one detail before anyone decides to host remark42 for multiple domains on a single instance. An OAuth App has one name and one callback URL, and the callback URL points to the remark42 instance, not to an individual site. If you connected blog-a.pl and blog-b.pl to the same instance, readers of both sites will see the same application name on GitHub’s consent screen, regardless of which site they clicked “sign in” from. To a non-technical reader on blog B, this may look like a phishing warning.

This is one of several reasons that argue for a “one remark42 instance per site, on the same domain as the site” model instead of a central hub. This whole discussion deserves its own article though, treat it here just as a signal: if you’re thinking about multi-tenancy, look into the topic more deeply before you build infrastructure for it.

Email notifications: two separate things#

remark42 splits notifications into two independent channels. NOTIFY_ADMINS=email sends an email to the admin about every new comment, so you know something is happening under some post. NOTIFY_USERS=email lets a reader subscribe to a specific thread and get an email when someone replies to their comment. Both channels use the same SMTP_* block, so if you have email sending configured for one, you get the other essentially for free.

Configuring the NOTIFY block#

The target block in .env looks like this:

NOTIFY_USERS=email
NOTIFY_ADMINS=email
NOTIFY_EMAIL_FROM=comments@example.com
NOTIFY_EMAIL_VERIFICATION_SUBJ=Confirm your comment subscription

ADMIN_SHARED_EMAIL=your@personal.email

NOTIFY_EMAIL_FROM is the address visible in the From: field of sent emails. Its domain must be authorized with your SMTP provider, the part before @ doesn’t have to be a real mailbox. The value no-reply@ will work technically, but in the section on product decisions I’ll show why it’s a worse choice than comments@.

ADMIN_SHARED_EMAIL is your personal address where notifications about new comments should land. This address is independent of NOTIFY_EMAIL_FROM, one is the sender, the other is the recipient. Without ADMIN_SHARED_EMAIL, remark42 doesn’t know where to send admin notifications and sends nothing, even though NOTIFY_ADMINS=email is set.

NOTIFY_USERS=email enables the possibility of a subscription for the user, but doesn’t enroll anyone automatically. Without an explicit opt-in, no email will fly to a reader.

Subscription requires confirmation#

The widget shows a “Subscribe by Email” option under each thread. The reader enters an address, gets an email with a token, pastes it into the widget, and starts receiving emails about replies in that thread.

It isn’t optional. GDPR and CAN-SPAM demand email confirmation. Without email confirmation, anyone could enroll someone else’s address for notifications from any blog using remark42.

SMTP configuration#

Transactional SMTP is a service for sending emails from an application instead of from a mail client. Providers like EmailLabs, Postmark, Mailgun, or Amazon SES let you configure your own sender domain and have deliverability built for this kind of traffic. For a production blog you pick a dedicated service, not your own Gmail, which forces From: onto its domain and has daily limits.

Sender domain authorization goes according to your provider’s documentation, each one walks you through its own flow (a few DNS records, verification in the panel). Once the domain is verified and you have SMTP login data (host, port, username, password), you go back to remark42’s .env:

SMTP_HOST=smtp.example-provider.com
SMTP_PORT=587
SMTP_USERNAME=<login_from_provider_panel>
SMTP_PASSWORD=<password_from_provider_panel>
SMTP_TLS=false
SMTP_STARTTLS=true
SMTP_TIMEOUT=10s

Port 587 with STARTTLS is the standard. If your provider requires 465 with implicit TLS, swap the flags:

SMTP_PORT=465
SMTP_TLS=true
SMTP_STARTTLS=false

One of the flags SMTP_TLS or SMTP_STARTTLS must be true, the other false. We skip port 25, VPS hosts and most providers block it as a source of spam.

After docker compose up -d remark42, check in the logs that the email channel came up. remark42 logs the status of every notification channel at startup:

docker compose logs -f remark42 | grep -i "smtp\|notify\|email"

Messages like smtp auth failed, connection refused, or tls handshake failed point to a problem with credentials, port, or TLS mode. The fix is comparing values in .env with what the provider gave you.

Magic-link is a way to log in without a password and without OAuth. The user enters an email, gets a one-time token, types it into the widget, the backend verifies it and issues a session. A third channel next to GitHub OAuth and anonymous.

Mechanics and when it makes sense#

Step by step from the backend side, it looks like this. The reader types the address into the widget and picks “Sign in with Email”. The backend generates a one-time token, saves it in the database with a short TTL (fifteen minutes by default) and a binding to a specific address. An email with the token goes out to the reader, in newer remark42 versions with just the raw token, without a clickable link.

Dropping the link isn’t accidental. Mail clients like Outlook prefetch links for antivirus scanning, and end up consuming the token before the reader gets a chance to click. Sending just the token eliminates this class of failure, at the cost of manual copy-paste on the reader’s side.

The reader copies the token, goes back to the widget, and pastes it into the field next to “Sign in”. The widget sends the token to the backend, which checks whether it exists, hasn’t expired, and hasn’t been used already. If everything checks out, the backend issues a session and marks the token as used.

Configuration is trivial as long as SMTP already works:

AUTH_EMAIL_ENABLE=true
AUTH_EMAIL_FROM=login@example.com
AUTH_EMAIL_SUBJ=Sign in to comments

Three lines, restart, and an additional email option appears in the widget next to GitHub and anonymous. The SMTP_* block configured earlier for notifications is reused in full.

The password manager conflict trap#

The setup is tested, you send yourself a test email, paste the token into the widget, and notice something odd. The form with the token disappears, the popup closes without a message, clicking “Sign in” shows an empty field, another paste attempt closes the form again. The browser console is empty, no JavaScript error. The Network tab is empty, no request goes to the backend. The widget doesn’t even try to log in, it just quietly resets its state.

The diagnosis surfaces in incognito mode. Without browser extensions, magic-link works correctly, the popup closes after authorization and the widget shows the logged-in identity. In your normal profile the silent fail returns. The culprit: a password manager. Bitwarden, 1Password, LastPass, and similar extensions are programmed to intercept paste events on fields that look like one-time code inputs (short input, autocomplete=off). The extension catches the event before the widget can handle it, closes the overlay in the belief that it’s its own dialog, and the paste never reaches the widget’s JavaScript handler.

The silent fail leaves no error message, no log, no signal for the reader that something went wrong. The blog admin will diagnose it on their own, but an ordinary reader will swallow the silent fail and walk away without commenting.

Product decisions you have to make yourself#

Technical configuration is one thing, decisions about how you treat readers are another. Three such decisions fall out directly from the auth and notification configuration. Each of them is a trade-off, not an obvious choice.

AUTH_ANON: anti-spam filter or low barrier#

Anonymous comments (AUTH_ANON=true) lower the entry barrier to zero. A reader with no account picks any name and comments right away. The price: the nickname isn’t reserved for an account, so impersonating known nicknames is trivial. Bans don’t work, because a banned commenter comes back under a different nickname. Spam has a low barrier, a bot picks a random nickname and drops in an ad, and remark42 has no meaningful blocking mechanism.

Shutting anonymous down (AUTH_ANON=false, GitHub only) gives the opposite profile. Every commenter has a real identity, an avatar, a link to a profile. The anti-spam filter is free: a bot would have to open a GitHub account to spam, a cost disproportionate to the value. Bans work, because identity is pinned to an account. The price: you cut off people without a GitHub account and those who don’t want to give OAuth access just to write a sentence.

Without data on the actual spam volume, the decision rests on gut feeling. It’s more reasonable to leave AUTH_ANON=true for a month, observe the ratio of comments, spam volume, and impersonation frequency, then decide. Flipping it later is one line in .env and a restart.

The From address format#

Convention says to set NOTIFY_EMAIL_FROM=no-reply@example.com, a signal to the reader “don’t reply, this is automated”. The convention is bad in the context of a personal blog, though.

Email providers (Gmail, Outlook, Yahoo) show statistically higher suspicion toward no-reply@ and noreply@ addresses, because this address is used en masse by spammers and marketing. On top of that you close the only natural return channel: someone gets a notification, wants to ask about something, clicks “Reply” in their mail client, and the mail comes back as a bounce or falls into the void.

The alternative comments@example.com or blog@example.com sidesteps both issues. The mailbox doesn’t have to be real, a “user unknown” bounce is less aggressive communicatively than no-reply. If you want return contact, you configure a forwarder to your personal address.

The silent fail of magic-link with password managers presents a decision without a clear winner. Below are three realistic paths with arguments for and against.

Leave it enabled, accepting that some readers fall into the silent fail. Argument: full coverage of login channels. Counter-argument: some readers bounce off without any message, without reporting the problem.

Disable magic-link (remove AUTH_EMAIL_ENABLE=true from .env), keep only GitHub and anonymous. Argument: every option works reliably. Counter-argument: you give up a reasonable channel for people without a GitHub account.

Leave it enabled and add a note under the widget like “when logging in via email, disable your password manager for this site”. Argument: honest toward the reader, shows awareness of the problem. Counter-argument: you’re asking the user to do extra work, few will actually do it.

Without data it’s hard to decide with certainty. The specific decision is worth making after a month of observation, how many people try the email channel and how many of them make it through.

Summary#

Configuring OAuth via GitHub and email notifications in remark42 comes down to copying a few values from provider panels into .env and restarting the container. The remark42 code itself is declarative: variables enable channels, a restart brings up the configuration, the widget hooks into the interface automatically.

A few things worth remembering that aren’t obvious from the documentation:

  • The callback URL in the OAuth App must have the exact format https://<REMARK_URL>/auth/<provider>/callback bit for bit, redirect_uri_mismatch is the most common setup error.
  • User subscriptions are a two-step opt-in, not automatic, and that’s a feature, not a gap.
  • Magic-link email has a silent conflict with password managers like Bitwarden and 1Password, the decision to enable it should be a conscious one.

Product trade-offs (AUTH_ANON, the format of From:, magic-link yes/no) don’t have a single correct answer. It’s worth making them with full awareness of the consequences, and verifying decisions with data after a few weeks, when the channel proportions and spam volume stop being theoretical.

References and further reading#