NewsBlur Tutorial
Self-Hosting project
◆May 2026
Self-Hosting project
◆May 2026
Introduction
This is a guide for setting up NewsBlur on a Windows PC. I used Tailscale, my own subdomain, and a reverse proxy to access it from anywhere. NewsBlur documentation isn't great and using Windows as your server OS means you'll typically have less documentation than with a Linux config, so I've written this guide to be as comprehensive as possible. If you're here, I assuming you're familiar with basic networking principles and you're moderately comfortable with the command line, but I cover some of the more basic stuff just in case.
I have a specific setup that may be a little different than yours, but I've included plenty of information about how to substitute alternative services, and the overall structure is going to be pretty similar no matter what tools you use. See Substitutions for more information.
Without further ado, let's get started!
Tools
What you should have going in
A Windows machine with at least 8 GB RAM (16 GB more comfortable) and ~30 GB free disk space.
Tailscale installed and your tailnet set up. The server should be visible at a 100.x.x.x IP from your other devices. If you don't know how to do this part, check out Tailscale's documentation, which is extremely comprehensive.
A domain name. If you don't have a domain, you can just use the one that Tailscale gives you when you add a machine to your tailnet. In this guide, yourdomain.com is always the placeholder for whatever you choose.
Cloudflare DNS (free tier). If your DNS is elsewhere, that works too.
An email provider if you want email notifications from NewsBlur. I use Proton Mail (paid plan) so I can get emails from an address at my domain, but an email account from Gmail or most other providers work just fine.
Software you'll install during this process
WSL2 (Windows Subsystem for Linux) with Ubuntu
Docker Desktop with WSL2 integration
NGINX (native Windows binary)
NGINX Proxy Manager (a Docker container, separate from the native NGINX)
Architecture
This is the flow of traffic in this implementation of NewsBlur.
Browser (remote device on tailnet)
│ https://news.emrysmayell.com
▼
DNS resolution
│ Cloudflare DNS
▼
Tailscale authentication
│ Tailscale IP
▼
Server's tailnet interface
│ http://localhost:443
▼
Native Windows NGINX (terminates TLS with Let's Encrypt cert)
│ http://localhost:44343
▼
NewsBlur HAProxy (in Docker container)
│
▼
NewsBlur internal NGINX → Gunicorn → Django → The rest of NewsBlur
Step 1: Installing NewsBlur in Docker
1.1 Check for conflicting services
Before you start, check for other services listening on port 443. Even if you haven't used this machine for self-hosting before, something like IIS may be running in the background and conflict with NGINX later. Run in PowerShell:
netstat -ano | findstr ":443"
Anything in that output other than Docker's backend (identified by a Process ID that you can look up in Task Manager → Details) will conflict with your setup and needs to be stopped or removed before continuing. This could be IIS, NGINX or another reverse proxy, or a service you forgot about. If you find something, make sure the service doesn't autostart next time you reboot your machine (Task Manager → Startup apps).
1.2 Set up WSL2
If WSL2 isn't already installed, open PowerShell as administrator and run:
wsl --install
This installs WSL2 with Ubuntu by default. You can install a different distro if you'd like, but there's no reason to for this project. Reboot when prompted, then complete the initial Ubuntu setup (create a user account or accept root).
After reboot, verify that the installation was successful:
wsl -l -v
The output should show Ubuntu and VERSION 2. If it shows VERSION 1, upgrade it:
wsl --set-version Ubuntu 2
I recommend making a shortcut to the WSL terminal at this point. Search for an application named Ubuntu (not WSL) and you can pin that to the Start menu or to your Taskbar like any other app. Opening jumps you straight into the terminal.
1.3 Set up Docker Desktop
Download Docker Desktop from docker.com and install. After installation:
- Open Docker Desktop → Settings (gear icon)
- Go to General → make sure "Use the WSL 2 based engine" is checked
- Go to Resources → WSL Integration → toggle on your Ubuntu distro
- Click Apply & Restart
Verify the integration worked. In WSL terminal:
docker --version
docker ps
The first line shows the Docker version, the second line shows all existing Docker containers. If you get an error saying command docker is not found, the WSL integration didn't activate and you should check Docker Desktop's WSL Integration setting.
1.4 Clone NewsBlur
Clone the NewsBlur repository into your WSL2 home directory. From WSL:
cd ~
git clone https://github.com/samuelclay/NewsBlur.git
cd NewsBlur
Line 1 takes you to the WSL root directory, line 2 clones the NewsBlur repo to the root, and line 3 puts you in the newly created NewsBlur folder.
It's important that your NewsBlur files are in WSL and not in a Windows folder, because the cross-filesystem boundary between WSL2 and Windows' NTFS causes permission errors with PostgreSQL and MongoDB. If you try to host the files in NTFS, you'll get the Portgres error operation not permitted in the next few steps.
1.5 Update ports in Docker
Change the NewsBlur ports away from the default ports for HTTP and HTTPS (80 and 443, respectively) because even if this is the first service you're running on this machine, it's best to avoid a conflict down the line. Most home setups already have other services on these ports (for example, native NGINX uses 443, which we'll get to later). NewsBlur's HAProxy publishes both by default and will fail to start with a port conflict.
To check which ports you can use, open your Terminal as an Administrator and run:
netstat -ab
This will show you all of the ports that are in use. Use anything that isn't listed, between 1024 through 49151. Here's some non-essential reading if you're interested in why that specific range is important. In my setup, I added "43" to the end of the default ports and had no issues, so I'll be using that example throughout the guide.
Open docker-compose.yml in /root/NewsBlur/ and find the haproxy service. Change the ports section:
from:
ports:
- 80:80
- 443:443
- 1936:1936
to:
ports:
- 8043:80
- 44343:443
- 1936:1936
The host-side ports (left of the colon) can be anything unused; the container-side ports (right) must stay 80/443. Port 1936 is HAProxy's service monitoring page and can stay the same. Also update the NGINX service's port if it conflicts with NPM (which uses 81 by default):
ports:
- 8143:81
If you're running x86_64 Windows, you also have to remove the ARM-specific JVM flags from Elasticsearch. This is a NewsBlur-specific issue I ran into that took me a while to figure out, because the project assumes it's running on Apple Silicon and adds -XX:UseSVE=0 to Elasticsearch's JVM options. This flag exists only on ARM64, so the JVM refuses to start on x86_64 Windows and Elasticsearch crashes in a loop. If you don't do this, you'll notice the Elasticsearch service in Docker starts and stops over and over.
If you're not sure, check whether you're running an ARM system in Powershell:
(Get-ComputerInfo).CsSystemType -like "*ARM*"
If it returns true, skip this step. If it returns false, it's not an ARM machine and you need this step.
In docker-compose.yml, find the newsblur_db_elasticsearch service, and make a change:
from:
- "ES_JAVA_OPTS=-Xms384m -Xmx384m -XX:UseSVE=0"
- "CLI_JAVA_OPTS=-XX:UseSVE=0"
to:
- "ES_JAVA_OPTS=-Xms384m -Xmx384m"
1.6 Ensure WSL has enough memory
Elasticsearch will OOM-crash if WSL2's VM is undersized. Edit (or create) C:\Users\<your-username>\.wslconfig with:
[wsl2]
memory=8GB
processors=4
In PowerShell, run:
wsl --shutdown
Wait at least 10 seconds, then reopen WSL. Docker Desktop's WSL backend will use the new limits on next startup.
1.7 Raise WSL's virtual machine memory allocation
Elasticsearch also requires vm.max_map_count >= 262144 in the kernel, because WSL2's default memory allocation is too low. To raise it, run this from WSL:
sudo sysctl -w vm.max_map_count=262144
To make it persistent across reboots, you'll need to add a line to WSL's sysctl.conf file. Navigate to\\wsl$\Ubuntu\etc\sysctl.conf, and update the vm.max_map_count line as follows:
vm.max_map_count=262144
For those not familiar with WSL or Linux file paths, here are a couple tips. You can access the WSL file system in Windows File Explorer by entering \\wsl$\Ubuntu into the address bar. Paths like \etc\sysctl.conf are relative to this directory. Your NewsBlur instance will live at \\wsl$\Ubuntu\root\NewsBlur, and most file paths referencing a NewsBlur-specific file will be relative to that directory.
1.8 Run the install
From WSL, in your NewsBlur directory:
make
This is the maintainer's single-command install. It pulls and builds all images, generates self-signed certificates for HAProxy, brings up every container, and runs Django migrations. The first run takes 10-20 minutes depending on your network speed.
Once it returns to the prompt, verify everything is up:
docker ps
You should see all eight containers (haproxy, web, node, postgres, mongo, redis, elasticsearch, task_celery) in Up state. If anything is Restarting or Exited, see the Troubleshooting section.
Visit https://localhost:44343 (or whatever port you remapped to 443) in a browser. You'll get a warning because the site doesn't have an SSL certificate yet, but bypass this by typing thisisunsafe (Chrome/Edge) or clicking through (Firefox) to confirm if NewsBlur is being served correctly. If it's working, you should see NewsBlur's signup screen.
Step 2: Configuring NewsBlur
2.1 Create your account
Make a NewsBlur account on that signup screen. Use any username and email, they don't need to be real for a self-hosted instance. With NewsBlur's defaults, your first account is automatically activated and granted a 30-day premium trial.
2.2 Upgrade to permanent premium
The "30-day trial" notice you'll see on NewsBlur's home page is real (the premium features will disappear after 30 days), but since this is an self-hosted installation of an open-source program, it's very simple to give yourself permanent premium access. To do this, you'll drop into the Django shell. From WSL in your NewsBlur directory:
make shell
At the >>> prompt:
u = User.objects.get(username='your_username')
u.profile.activate_archive()
It's worth noting that activate_premium()is also a tier, but activate_archive() is the highest, with all Premium features plus the option of permanent story retention (which you can change in NewsBlur settings on your own).
You can leave the Django shell with the command below, but stay there for a moment- it'll be useful in Step 2.3.
exit()
If you create more accounts later, each one will need to be granted Archive account status individually. Or, you can auto-grant new accounts premium-archive status permanently. To do this, you'll create a file in newsblur_web called local_settings.py. This file contains your manual overrides of Docker's existing docker_local_settings.py file. Once you've created it, enter:
AUTO_PREMIUM_NEW_USERS = True
AUTO_ENABLE_NEW_USERS = True
Save the file, but keep it open for a moment because you'll return to it in Step 2.5.
When I first set up my instance, I didn't create local_settings.py, I just replaced the existing values in docker_local_settings.py. This will also work, but it's better to have a separate file so you can easily refer to the default settings (and return to them by simply deleting your overrides), and because if you update your NewsBlur instance, docker_local_settings.py will get overwritten but local_settings.py will not. In fact, local_settings.py is written in .gitignore even though the file doesn't exist out of the box, so you never have to worry about it being overwritten.
2.3 Update the Site object
This is where you'll start integrating the domain you're using to access NewsBlur into the system. Make sure you know the endpoint you're using to access it, whether it's your custom domain, one of its subdomains, or a Tailscale domain. For example, I use news.emrysmayell.com. Like I said earlier, anywhere you see "yourdomain.com", replace it with yours.
NewsBlur uses Django's Sites framework to build canonical URLs (for password reset emails, OAuth, etc.). The default is example.com, and if you don't change it, you'll run into a bug soon where NewsBlur will redirect your URL to example.com when you try to access the site. While you're still in make shell, enter:
from django.contrib.sites.models import Site
site = Site.objects.get(pk=1)
site.domain = "yourdomain.com"
site.name = "yourdomain.com"
site.save()
2.4 Allow your subdomain
If you're using a subdomain, this step is critical (it took me over an hour to figure out). NewsBlur has a "blurblog" feature where each user gets a public blog at <username>.newsblur.com. Because of this, the app's URL routing tries to match any incoming subdomain against the user table, meaning if your URL is news.emrysmayell.com for example, NewsBlur looks for a user named "news," fails to find one, and it triggers an infinite loop in your browser (which will manifest as a page that says something along the lines of "this page failed to load - too many requests").
Open apps/reader/views.py and find the line that starts with ALLOWED_SUBDOMAINS:
grep -n "ALLOWED_SUBDOMAINS" apps/reader/views.py
Add your subdomain to the list:
ALLOWED_SUBDOMAINS = ['www', 'beta', 'staging', 'discover', 'news', ...]
This is an edit to the system files, so if you update your NewsBlur instance to a newer version, this change will get overwritten. I recommend keeping a runbook and documenting this quirk, and make sure you return to this step after an update.
2.5 Add your domain to local settings
In the newsblur_web/local_settings.py file you created earlier, enter these lines along with the AUTO_PREMIUM_NEW_USERS lines you added earlier:
# newsblur_web/local_settings.py=
# Public URL
NEWSBLUR_URL = "https://yourdomain.com"
SESSION_COOKIE_DOMAIN = "yourdomain.com"=
# Trust upstream proxies' HTTPS header so Django doesn't try to redirect
# traffic that is HTTP internally but served as HTTPS externally
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')=
Now you need to make sure that NEWSBLUR_URL variable is consistent everywhere. NewsBlur's docker_local_settings.py re-reads NEWSBLUR_URL from an environment variable named NEWSBLUR_URL and applies conditional logic based on it. If the environment variable isn't set, it defaults to "https://localhost" and then sets SESSION_COOKIE_DOMAIN = "localhost" regardless of what you put in your local_settings.py. This silently breaks session cookies for any non-localhost setup, which can result in your traffic being diverted to the wrong place later.
The workaround is to set NEWSBLUR_URL as an environment variable in docker-compose.yml. Find the newsblur_web, task_celery, and newsblur_node services and add that variable to their environment: blocks:
newsblur_web:
# ... existing config ...
environment:
- NEWSBLUR_URL=https://yourdomain.com
task_celery:
# ... existing ...
environment:
- NEWSBLUR_URL=https://yourdomain.com
newsblur_node:
# ... existing ...
environment:
- NEWSBLUR_URL=https://yourdomain.com
After editing the compose file, recreate the affected containers so they pick up the new env vars:
docker compose up -d newsblur_web task_celery newsblur_node
docker compose restart -d container-name restarts a container, and it's a similar command that will come in handy later, but it's worth noting that docker compose up -d container-name is a slightly different command, which builds/rebuilds containers completely. The important differece in this case is that a restart won't read new environment: variables, only a rebuild with up will.
Verify the settings are loading:
make shell
from django.conf import settings
print(settings.NEWSBLUR_URL) # should return: https://yourdomain.com
print(settings.SESSION_COOKIE_DOMAIN) # should return: yourdomain.com
print(settings.SECURE_PROXY_SSL_HEADER) # should return: ('HTTP_X_FORWARDED_PROTO', 'https')
If any value is wrong, double check your local_settings.py file and the environment: variables you changed in Docker before continuing.
Step 3: Networking and Reverse Proxy
3.1 Configure DNS in Cloudflare
On Tailscale's Machines page, get your server's IP address. Then, in the Cloudflare DNS records for your domain, add an A record:
| Type | Name | Content (IPv4) | Proxy status | TTL |
|---|---|---|---|---|
| A | yourdomain (see Note 1) | 100.x.x.x (your server's tailnet IP) |
DNS only (gray cloud) | Auto |
This routes traffic from your domain of choice to your private Tailscale network, and next you'll configure where the traffic goes from there.
If you're using a subdomain, enter just the subdomain here (in my case, news). If you're using a main domain, you probably have an existing DNS record that you can edit to replace the existing IP address with Tailscale's. The Name field will either have @ or the domain name, such as emrysmayell.com. If it doesn't exist, create one.
Cloudflare's edge proxy can't connect to private/tailnet IPs because their servers are public, which is why the Proxy Status has to be DNS-only. This is worth noting because most of your other DNS records will probably be proxied by default. Most likely, you won't even have the option to change this, but if you do, keep the button toggled off or you'll break the connection. DNS-only means all queries interface with your tailnet IP directly.
Get your tailnet IP from the server:
tailscale ip -4
Wait at least 30 seconds for DNS to propagate, then verify from another machine on your tailnet:
nslookup yourdomain.com
This should return your tailnet IP.
3.2 Install NGINX Proxy Manager
NGINX Proxy Manager (NPM) will manage SSL certificate issuance. For configurations on Linux, NPM could also route the traffic itself and it would be simpler to manage both in one place, but Docker on Windows can't expose its ports to virtual private network interfaces like Tailscale. Windows-native NGINX binds to port 0.0.0.0:443 meaning it can receive all types of traffic, and because of this, the best we can do is to use NPM on Docker for SSL certificate management, and Windows NGINX for traffic management.
To set up NPM, Create an npm directory and a file called docker-compose.yml inside of it.
Enter this into the new docker-compose.yml:
services:
npm:
image: jc21/NGINX-proxy-manager:latest
container_name: docker-npm-1
restart: unless-stopped
ports:
- '8044:80' # HTTP (not used externally; avoids port conflicts)
- '81:81' # NPM admin UI
- '8045:443' # HTTPS (not used externally either)
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
The mapping 8044:80 and 8045:443 keeps NPM off the standard ports so native Windows NGINX can use 80/443 for actual public traffic without conflicts. NPM's port 81 is the admin page.
Build the container and start it:
docker compose up -d
Visit http://localhost:81 in a browser. Default credentials are admin@example.com / changeme but you can change them on your first login.
3.3 Install native Windows NGINX
Download the Windows NGINX zip from NGINX.org/en/download.html (the mainline version). The link should read nginx/Windows-[version.number]. Extract to the Windows location C:\Users\[user]\Documents\NGINX. The folder should contain NGINX.exe and a conf subdirectory with NGINX.conf in it along with many other files.
Start it (from PowerShell, in that directory):
cd C:\Users\[user]\Documents\NGINX
.\NGINX.exe
Verify it's running:
netstat -ano | findstr ":443"
You should see entries with NGINX's PID. Again, if something else is using that port, stop/disable that service.
3.4 Configure NGINX for NewsBlur
Open C:\Users\[user]\Documents\NGINX\conf\NGINX.conf. Inside the existing http { ... } block, add a server block for NewsBlur. The full structure should look like:
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 443 ssl;
server_name yourdomain.com;
# Cert paths set in Step 4 below; placeholders for now
ssl_certificate conf/newsblur-fullchain.pem;
ssl_certificate_key conf/newsblur-privkey.pem;
location / {
# Forward to NewsBlur's HAProxy
proxy_pass https://127.0.0.1:44343;
proxy_ssl_verify off;
proxy_ssl_server_name on;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
}
Notable lines:
listen 443 SSL and proxy_pass: the former tells NGINX that traffic will come from port 443 (with an SSL certificate), and to send it to port 44343. If you configured a different port back in Step 1.5, replace 44343 with that instead.
proxy_ssl_verify off: HAProxy serves a self-signed cert internally, and NGINX would reject it by default so this tells it to ignore that.
proxy_ssl_server_name on: this sends the server name to HAProxy so it knows which hostname is being requested.
proxy_http_version 1.1 as well as the Upgrade and Connection lines: these enable WebSocket support, which lets NewsBlur update its pages in real time instead of on manual refresh.
X-Forwarded-Proto $scheme: this tells everything downstream that the original connection was HTTPS, which Django needs to avoid an SSL-redirect loop.
A majority of common self-hosted services (such as Jellyfin, Audiobookshelf, Kiwix, FreshRSS, and many more) serve HTTP to the client, as opposed to NewsBlur, which serves HTTPS. For those, you'd replace https:// on the proxy_pass line with http:// and drop the proxy_ssl_* lines, and otherwise the NGINX configuration would be the same.
Step 4: SSL Certificates
4.1 Create a Cloudflare API token
- Visit the API tokens page: https://dash.cloudflare.com/profile/api-tokens
- Click Create Token
- Use the Edit zone DNS template (one-click preset)
- Under "Zone Resources," select Include → Specific zone → yourdomain.com. Leave all other fields as their defaults.
- Continue to the summary, click Create Token
- Copy the token immediately! Cloudflare shows it exactly once. You should copy it to a password manager to save it for later, because if you set up any other services on your machine and need an SSL certificate, you should use this same API token to create new SSLs for those as well.
The token has DNS-edit permission for your main domain and its subdomains, which means whether your NewsBlur instance is hooked up to a subdomain or the main one, this will work either way, and you can use it for other subdomains as well. Also, since it only covers DNS, it can't be used for anything else.
Verify the token works:
curl -X GET "https://api.cloudflare.com/client/v4/user/tokens/verify" \
-H "Authorization: Bearer YOUR_TOKEN"
If you get "status": "active" in the response, it's working.
4.2 Issue the certificate via NPM
- Visit NPM's admin page at
http://localhost:81 - SSL Certificates in the top menu → Add SSL Certificate → Let's Encrypt
- Domain Names:
yourdomain.com - Email Address: your real email (Let's Encrypt sends expiry warnings here if auto-renew fails)
- Toggle Use a DNS Challenge on
- DNS Provider: Cloudflare
- Credentials File Content: replace the placeholder text with
dns_cloudflare_api_token = YOUR_TOKEN. Everything else in the placeholder text can be deleted for this process. - Then, agree to Let's Encrypt's Terms of Service and hit Save
The Save button will spin for a short time while NPM confirms the API token with Cloudflare and creates the SSL certificate. This can take up to a minute, so just let it run for a while. If succeeds, the window will disappear and you'll see a new SSL certificate in your list. If you have multiple and you're not sure which one is the new one, it'll be the one an expiry date 90 days away.
If it fails, look at the certificate entry to see the error. It's most likely to say "permission denied" which means you configured the API token incorrectly or the Cloudflare API timed out for some reason. The solution to both issues is to remove the token you just made and follow from Step 4.1 again.
4.3 Find the cert files inside NPM
To get a list of NPM IDs, run the command below (in PowerShell or WSL). This lists the IDs of all certificates generated by NPM on your machine, formatted as npm-[number].
docker exec -it docker-npm-1 ls /etc/letsencrypt/live/
If this returns multiple NPM IDs, the one that corresponds to this project is probably the one with the highest number, but it's good to double check. To find which ID is correct:
docker exec -it docker-npm-1 sh -c "for d in /etc/letsencrypt/live/npm-*/; do echo \$d; openssl x509 -noout -subject -in \"\$d/cert.pem\" 2>/dev/null; done"
This pulls the -subject line from the certificate, which will show you which service you created it for: it will say CN=yourdomain.com.
Most commands that begin with docker can be run from both file systems, NTFS or WSL. Going forward, know that if you see a command beginning in docker exec, you can run that line of code from whatever terminal you have open at that moment, and you'll get the same result. This is also true for many other docker commands, but not all: for example, docker compose up must be run in a directory with a docker-compose file (unless you specify a path in the command).
4.4 Copy cert files to Windows NGINX
The cert files in /etc/letsencrypt/live/[npm-ID]/ are symlinks pointing to /etc/letsencrypt/archive/[npm-ID]/. docker cp chokes on relative symlinks, so copy the actual archive files. First resolve the symlinks, replacing [npm-ID] with the ID you found in the previous step:
docker exec docker-npm-1 readlink /etc/letsencrypt/live/[npm-ID]/fullchain.pem
docker exec docker-npm-1 readlink /etc/letsencrypt/live/[npm-ID]/privkey.pem
Then copy from PowerShell:
docker cp docker-npm-1:/etc/letsencrypt/archive/[npm-ID]/fullchain1.pem C:\Users\[user]\Documents\NGINX\conf\newsblur-fullchain.pem
docker cp docker-npm-1:/etc/letsencrypt/archive/[npm-ID]/privkey1.pem C:\Users\[user]\Documents\NGINX\conf\newsblur-privkey.pem
The numeric suffix in the archive filename (1, 2, ...) increments with each renewal.
Verify the files are correct:
Get-Content C:\Users\[user]\Documents\NGINX\conf\newsblur-fullchain.pem -TotalCount 1
# Expected: -----BEGIN CERTIFICATE-----
Get-Content C:\Users\[user]\Documents\NGINX\conf\newsblur-privkey.pem -TotalCount 1
# Expected: -----BEGIN PRIVATE KEY----- (or BEGIN RSA PRIVATE KEY / BEGIN EC PRIVATE KEY)
If either file returns the expected header of the other, double check the commands you used to copy the files from PowerShell. You can just run the correct commands again and they will overwrite the existing files with correct versions.
Also confirm the fullchain has two cert blocks (your leaf + LE intermediate):
(Get-Content C:\Users\[user]\Documents\NGINX\conf\newsblur-fullchain.pem | Select-String "BEGIN CERTIFICATE").Count
# Expected: 2
If you see only 1, browsers won't trust the cert because the chain is incomplete. Double check your copy commands, and if they were correct, you may have to go back to Step 4.2 and generate a new certificate.
4.5 Reload NGINX
cd C:\Users\[user]\Documents\NGINX
.\NGINX.exe -t # test config
.\NGINX.exe -s reload # apply
NGINX -t should print syntax is ok and test is successful. If it reports a cert load error, the file contents are wrong. Go back to Step 4.2 and verify the headers again, and if they look right, go back to 4.2 and generate a new cert.
4.6 Test
From another machine on your tailnet:
nslookup yourdomain.com # should return your tailnet IP
curl -v https://yourdomain.com # should connect, no cert warnings, return a 302 to /web/
Open https://yourdomain.com in a browser. You should land on NewsBlur with a trusted cert and no warnings.
Step 5: Automated Certificate Renewal
Let's Encrypt certs are valid for 90 days; NPM auto-renews them at the 60-day mark inside the container, but those renewed files don't automatically propagate to your Windows NGINX. Without automation, you'll get a "certificate expired" warning in 90 days and have to figure this out all over again. Instead, spend a minutes automating the renewal and you'll never have to worry about it again.
5.1 Create the renewal script
Create a scripts folder in C:\Users\[user]\Documents\NGINX\, create a file called renew-certs.ps1 in the folder, and copy the following to that file:
# Cert renewal script for NewsBlur (and other domains as you add them)
# Copies LE certs from NPM container to Windows NGINX, then reloads NGINX.
$confPath = "C:\Users\[user]\Documents\NGINX\conf"
$NGINXPath = "C:\Users\[user]\Documents\NGINX"
$logPath = "C:\Users\[user]\Documents\NGINX\logs\cert-renewal.log"
New-Item -ItemType Directory -Force -Path (Split-Path $logPath) | Out-Null
function Copy-Cert {
param($npmId, $domainPrefix)
# Resolve the symlinks to find actual archive filenames
$fullchainTarget = docker exec docker-npm-1 readlink /etc/letsencrypt/live/$npmId/fullchain.pem
$privkeyTarget = docker exec docker-npm-1 readlink /etc/letsencrypt/live/$npmId/privkey.pem
# Strip the "../../archive/" prefix to get the archive filename
$fullchainFile = $fullchainTarget -replace '\.\./\.\./archive/[^/]+/', ''
$privkeyFile = $privkeyTarget -replace '\.\./\.\./archive/[^/]+/', ''
docker cp "docker-npm-1:/etc/letsencrypt/archive/$npmId/$fullchainFile" "$confPath\$domainPrefix-fullchain.pem"
docker cp "docker-npm-1:/etc/letsencrypt/archive/$npmId/$privkeyFile" "$confPath\$domainPrefix-privkey.pem"
# Verify it actually copied a key, not another cert
$firstLine = Get-Content "$confPath\$domainPrefix-privkey.pem" -TotalCount 1
if ($firstLine -notmatch "BEGIN .*PRIVATE KEY") {
throw "$domainPrefix-privkey.pem doesn't look like a private key: first line is '$firstLine'"
}
}
try {
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Starting cert renewal" | Out-File -Append $logPath
Copy-Cert -npmId "[npm-ID]" -domainPrefix "news"
& "$NGINXPath\NGINX.exe" -s reload -p $NGINXPath
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Renewal completed successfully" | Out-File -Append $logPath
}
catch {
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - ERROR: $_" | Out-File -Append $logPath
exit 1
}
Replace [user] with your username. Add additional Copy-Cert lines if you add more services with a similar configuration later, replacing the NewsBlur NPM ID with theirs.
5.2 Allow PowerShell scripts to run
By default, Windows blocks unsigned .ps1 scripts. Open PowerShell with your standard user permissions:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
This allows local scripts to run freely while still blocking scripts downloaded from the internet so you aren't disabling the most important protections.
5.3 Test the script manually
& "C:\Users\[user]\Documents\NGINX\scripts\renew-certs.ps1"
Check C:\Users\[user]\Documents\NGINX\logs\cert-renewal.log. The most recent entry should contain "completed successfully". The cert files in conf\ should also have new modification timestamps (check last modified date and time with in file properties, or Alt+Enter).
If anything has thrown an error in this step but the site is still working, make sure you fix it now anyway, because if the certs renew incorrectly, you won't know for three months.
5.4 Schedule it
Open Task Scheduler. Click Create Task (and not "Create Basic Task").
General tab:
Name: NewsBlur Cert Renewal
Check "Run whether user is logged on or not"
Check "Run with highest privileges"
Triggers tab → New:
Begin: On a schedule
Monthly, all months, day 1.
Start time: 3:00 AM (anytime works, just avoid times you're actively using the server)
Actions tab → New:
Action: Start a program
Program/script: powershell.exe
Add arguments: -ExecutionPolicy Bypass -NoProfile -File "C:\Users\[user]\Documents\NGINX\scripts\renew-certs.ps1"
Conditions tab:
Uncheck "Start the task only if the computer is on AC power" if your server is a laptop that isn't plugged in 100% of the time.
Settings tab:
Check "Run task as soon as possible after a scheduled start is missed"
Check "If the task fails, restart every: 1 hour" (max 3 attempts)
Save (it'll prompt for your password to confirm).
Test by right-clicking the task in the library → Run. After 5 seconds, check "Last Run Result". If the code is 0x0, it's working. Anything else means there was an error, and you should go through the settings for the task you just created to make sure everything is correct.
Let's Encrypt certificates expire after 90 days and NPM renews them after 60, but I recommend running the renewal monthly because there's essentially no downside, and it means if the renewal ever fails and has to wait until the next month to try again, it'll still renew about 30 days before that cert would have actually expired.
Step 6: Email Notifications
NewsBlur can send emails when feeds publish new stories, which was a major draw for me. There's a lot of customization for which emails (and other types of notifications) are sent, but in order to get there, you have to set some things up behind the scenes.
6.1 Configure SMTP
Email won't actually send until you provide SMTP credentials. I use Proton Mail, so this guide will be Proton-specific. Note that Proton requires a paid plan for both SMTP token generation (required) and connecting a custom domain (optional). Most major email providers have SMTP capability and there are comprehensive guides on all of them, but the overall process is pretty similar. I'm assuming you've already set up your domain's email to be managed by Proton, but if you haven't, check out Proton's guide on setting it up. It's pretty simple, especially if you've made it this far, because that means you already know how to manage DNS records.
To generate an SMTP token:
- Settings → All settings → IMAP/SMTP → SMTP tokens
- Click Generate token and name it something like "NewsBlur"
- Select your custom-domain address from the dropdown
- Copy the generated token immediately (it's shown once)
Add to newsblur_web/local_settings.py:
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'smtp.protonmail.ch'
EMAIL_PORT = 587
EMAIL_HOST_USER = 'notifications@yourdomain.com' # your Proton custom-domain address
EMAIL_HOST_PASSWORD = 'your-smtp-token-from-step-3'
EMAIL_USE_TLS = True
DEFAULT_FROM_EMAIL = 'NewsBlur <notifications@yourdomain.com>'
SERVER_EMAIL = 'notifications@yourdomain.com'
Restart the containers that handle email:
docker compose restart newsblur_web task_celery
6.2 Test SMTP
Django has a built-in test command:
docker exec -it newsblur_web ./manage.py sendtestemail your.real@email.address
You should use the email you want notifications sent to, likely a different one than notifications@yourdomain.com that you're using to send them. A test email should arrive within a minute. If it returns an error, the message should tell you exactly what's wrong (usually wrong credentials or wrong port).
6.3 Configure per-feed notifications
In the NewsBlur UI:
Right-click any feed → Notifications. This will let you edit notifs on a per-feed basis.
Or use the gear menu → Manage → Notifications. This will let you edit notifs from every feed at once.
Toggle "Email" on for each feed you want emailed. Choose "All Stories" for low-volume feeds where every story matters (status pages, infrastructure alerts). Choose "Focus Stories" for high-volume feeds where you only want notifications on stories matching your trained preferences.
Troubleshooting
Helpful commands, and common issues and how to solve them
The most useful general advice is to trace the request layer by layer. Run openssl s_client from the server to see what cert is being served at each point in the stack, use docker logs to see what happened within each container, and use netstat/ss to verify what's actually listening on each port. Most issues become obvious when you figure out where in the chain they happened.
Find what's listening on a port
In PowerShell:
# Shows all PIDs listening on port 443
netstat -ano | findstr ":443"
# Then look up the PID
Get-Process -Id <PID> | Select-Object Id, ProcessName, Path
You can also find PIDs in Task Manager → Details.
In WSL:
# Shows all PIDs listening on port 443
sudo ss -tlnp | grep ':443'
# Or
sudo lsof -i :443
See what cert is being served
From WSL on the server:
# For issuer, domain, and valid timespan
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com </dev/null 2>/dev/null | openssl x509 -noout -issuer -subject -dates
# For the above information, the full certificate, and more
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com -showcerts </dev/null 2>/dev/null | grep "BEGIN CERTIFICATE"
From PowerShell on any machine, without using openssl:
$req = [System.Net.WebRequest]::Create("https://yourdomain.com")
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true}
$req.GetResponse() | Out-Null
$req.ServicePoint.Certificate | Format-List Issuer, Subject, NotAfter
You can also see most useful information from a browser by clicking the lock icon next to the URL and finding Certificate Details.
Checking cert expiry
You can audit any cert from PowerShell:
$cert = Get-PfxCertificate -FilePath C:\Users\[user]\Documents\NGINX\conf\newsblur-fullchain.pem
$cert.NotAfter
Or from the server with openssl:
openssl x509 -enddate -noout -in /path/to/fullchain.pem
If your renewal script is working (Step 5), you should never have to worry about this.
Monitoring logs
You can find a lot of useful information by watching the logs for the tools you believe are points of failure. Each of these commands monitors logs in real time, so run the command and watch for changes.
In PowerShell:
# NGINX logs
Get-Content C:\Users\[user]\Documents\NGINX\logs\error.log -Wait -Tail 0
In WSL:
# NewsBlur web container logs
docker logs newsblur_web --tail 100 -f
# HAProxy logs
docker logs newsblur_haproxy --tail 100 -f
502 Bad Gateway errors
I ran into this a few times. If this happens, check NGINX logs.
| Error in log | Cause | Fix |
|---|---|---|
SSL_do_handshake() failed ... wrong version number |
You're probably sending HTTPS traffic to an HTTP-only upstream | In nginx.conf, change proxy_pass URL from https:// to http://. For NewsBlur, this should only be temporary for testing |
peer closed connection in SSL handshake |
You're probably sending HTTP traffic to an HTTPS-only upstream | In nginx.conf, change proxy_pass URL from http:// to https://. For NewsBlur, this should be what the final config contains |
connect() failed (10061: ...) |
Something upstream isn't listening on the port | Verify each upstream service is running and sending to the expected port |
upstream timed out |
Something upstream is slow or stuck | Restart upstream containers. If that doesn't work, look at other logs for erros. If nothing else works, try increasing timeouts for upstream containers |
no live upstreams |
At least one upstream server is down | Start/restart upstream containers and look at logs if they're failing |
Redirects
If your page redirects to example.com, the issue is that Django Sites framework defaults to example.com, its placeholder. To fix it, update Site.objects.get(pk=1) in Django shell (see Step 2.3)
If your page is stuck in a redirect loop, which is a common issue when setting up NewsBlur, the page won't load and you'll get an error message. Those messages look like this:
| Browser | Message |
|---|---|
| Chrome, DuckDuckGo, other Chromium browsers | "ERR_TOO_MANY_REDIRECTS" |
| Firefox | “The page isn’t redirecting properly.” |
| Microsoft Edge | “This page isn’t working right now.” |
| Safari | “Safari Can’t Open the Page.” |
Here are common issues and their fixes:
| Cause | Fix |
|---|---|
| NewsBlur may be trying to parse the subdomain as a username | Add subdomain to ALLOWED_SUBDOMAINS in apps/reader/views.py (Step 2.4) |
SECURE_SSL_REDIRECT without SECURE_PROXY_SSL_HEADER |
Add SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') to local_settings.py |
SESSION_COOKIE_DOMAIN mismatch |
Set in both local_settings.py AND ensure NEWSBLUR_URL env var is set so conditional in docker_local_settings.py doesn't fire |
HSTS warning
If you get an error page that looks similar to the untrusted certificate page you might get on sites serving HTTP or without a valid SSL certificate, but you don't have the option to bypass the warning, it's almost definitely an HSTS issue (HSTS is essentially an HTTPS requirement). Having HSTS enabled is a good thing, but you must have a valid SSL or the site will be completely inaccessible. The error is likely happening for one of two reasons: either your conf/nginx.conf file is linking to the wrong SSL, or your browser has an old one cached. Check your certificate paths and update the paths, and clear your browser's HSTS cache for your domain by doing the following:
Chrome/Edge:
Navigate to chrome://net-internals/#hsts
Scroll to "Delete domain security policies"
Type your domain → Delete
Restart browser
Firefox:
Close Firefox
Firefox's Profile folder → edit SiteSecurityServiceState.txt
Remove the line for your domain
Reopen
The lines you just deleted will be repopulated with the updated information the next time you visit the site.
Elasticsearch container is in a restart loop
Check the container log:
docker logs newsblur_db_elasticsearch
| Error | Cause | Fix |
|---|---|---|
Could not create the Java Virtual Machine + Unrecognized VM option 'UseSVE=0' |
x86_64 system trying to use ARM flag | Remove -XX:UseSVE=0 from ES_JAVA_OPTS in docker-compose.yml (see Step 1.5) |
| OOM killed (process died, large memory before exit) | Insufficient memory in WSL2 VM | Increase memory in .wslconfig, run wsl --shutdown, restart (see Step 1.6) |
max virtual memory areas vm.max_map_count [...] is too low |
Linux kernel parameter too low | sudo sysctl -w vm.max_map_count=262144 (see Step 1.7) |
java.nio.file.AccessDeniedException |
Permissions issue, likely with node.lock |
See below |
Permissions issues causing restart loops
If one or more services are constantly restarting, there may be a permissions issue. This is common in setups where WSL2 runs as root, or where files inside the NewsBlur directory were created by a different user than the one Docker containers run as. To fix it, you need to align ownership across the host filesystem and the container processes.
If this is an issue for one container, it's likely to be an issue for others. Check them all so you can update permissions for everything at once. In the /NewsBlur directory in WSL:
# Stop all containers first
docker compose stop
# Find the UIDs and GIDs of each volume
docker compose run --rm --entrypoint id newsblur_db_postgres
docker compose run --rm --entrypoint id newsblur_db_mongo
docker compose run --rm --entrypoint id newsblur_db_elasticsearch
docker compose run --rm --entrypoint id newsblur_web
docker compose run --rm --entrypoint id newsblur_node
docker compose run --rm --entrypoint id task_celery
Each command will return a line like uid=1000 gid=1000 groups=1000. You can ignore groups.
If there are mismatches between any UIDs and GIDs, the command below will apply ownership of each volume to match its container's ID. Match both numbers in each line to the UID returned above- the numbers below are likely to be correct but they are placeholders:
sudo chown -R 999:999 docker/volumes/postgres
sudo chown -R 999:999 docker/volumes/db_mongo
sudo chown -R 1000:1000 docker/volumes/elasticsearch
sudo chown -R 1000:1000 logs .prom_cache # used by newsblur_web, task_celery, newsblur_node
# Restart everything
docker compose up -d
You may have to run this again after updating NewsBlur.
You may notice that newsblur_db_redis, the fourth directory in docker/volumes, isn't addressed in the above commands. This is because Redis' imagefile doesn't define a user, meaning it's root by default and you won't run into any read/write/execute issues.
These permissions issues may not stop NewsBlur's frontend from running, but it's still a problem. In my case, newsblur_celery and newsblur_db_elasticsearch were stuck in loops without affecting the website, but the former meant Celery couldn't access Django's logging system which would have caused diagnosis issues down the line.
MongoDB fails to start with WiredTiger
MongoDB didn't shut down properly. In WSL:
make mongo-repair
If that doesn't work, delete and rebuild the data directory (only do this on a fresh install with no data you care about):
docker compose down
rm -rf docker/volumes/db_mongo
make
Site times out from a remote computer but works from the server
Several possible causes:
| Cause | Fix |
|---|---|
| DNS hasn't propagated | Wait a few minutes after adding the Cloudflare record |
| Tailscale isn't connected on at least one device | Verify on both devices using the Tailscale tray app or with tailscale status in PowerShell |
| A native Windows service is already listening on port 443 | Check netstat -ano | findstr ":443" and end the unwanted process. This is often IIS or an old NGINX installation |
Backups and Updates
Backing up
Backing up your database occasionally, especially right before updating NewsBlur or NPM, is a good practice.
Your critical data is in WSL, in these directories:
~/NewsBlur/docker/volumes/postgres/ (subscriptions, user accounts, intelligence training)
~/NewsBlur/docker/volumes/db_mongo/ (stories, read states)
~/NewsBlur/newsblur_web/local_settings.py (your custom configuration)
~/npm/data/ and ~/npm/letsencrypt/ (NPM's config and issued certs)
In order to back up these directories, you can run a script. Here's a simple version with comments explaining what it does:
# Defines BACKUP_DIR as the folder /backups/[date]. Creates /backups folder or uses existing one
BACKUP_DIR=~/backups/$(date +%Y-%m-%d)
mkdir -p "$BACKUP_DIR"
# Stops Postgres and Mongo temporarily (optional but good practice)
cd ~/NewsBlur && docker compose stop newsblur_db_postgres newsblur_db_mongo
# Copies all important directories to BACKUP_DIR
tar czf "$BACKUP_DIR/newsblur-volumes.tar.gz" -C ~/NewsBlur docker/volumes
cp ~/NewsBlur/newsblur_web/local_settings.py "$BACKUP_DIR/"
tar czf "$BACKUP_DIR/npm-data.tar.gz" -C ~/npm data letsencrypt
# Restarts Postgres and Mongo
cd ~/NewsBlur && docker compose start newsblur_db_postgres newsblur_db_mongo
# Optional: copies new backup folder to an external location (replace path with your own)
rsync -avz "$BACKUP_DIR" /mnt/external-drive/NewsBlur/backups
I recommend scheduling this via cron or systemd weekly so you don't have to do it manually.
Updating NewsBlur
NewsBlur updates are pretty rare, but they do happen and can include important bug fixes. Back up your important directories beforehand. To update:
cd ~/NewsBlur
git pull
make
make re-runs migrations as part of its install process. The update typically takes 1-3 minutes.
Important: git pull will overwrite your edit to apps/reader/views.py (the ALLOWED_SUBDOMAINS change from Step 2.4). This will break your connection if you set up access via a subdomain. Either:
- Maintain a personal git branch with your modifications, and rebase on top of upstream after each pull
- Document the change in a runbook so you can re-apply manually
- Use
git stashbefore pulling, thengit stash popafter
Updating may also change permissions for some directories, (often Celery's access to the Django logs) starting restart loops. If this happens, follow the instructions in Permission issues causing restart loops.
Updating NPM
cd ~/npm
docker compose pull
docker compose up -d
NPM releases updates pretty often. Read release notes for breaking changes before updating, but for this project, updates to NPM are very unlikely to cause issues. Also, you don't have to run the post-updates step in the previous command after updating NPM, just NewsBlur itself.
Substitutions
Different DNS provider
I used Cloudflare DNS because their API is among the best-supported in NPM and many other tools. Most providers have a similar process to Cloudflare, but often have slight differences in their name or process. Here's a list of common substitutions:
| Provider | Comparison to Cloudflare |
|---|---|
| AWS Route 53 | Provide AWS access key + secret in credentials |
| DigitalOcean | Provide DO API token |
| Hetzner | Provide Hetzner API token |
| Linode | Provide Linode API token |
| Namecheap | Provide username + API key |
| OVH | Application key + secret + consumer key |
| Gandi | Provide Personal Access Token |
| deSEC | Provide token |
| GoDaddy | Provide API key + secret |
| Vultr | Provide API key |
| Google Cloud DNS | Service account JSON |
The process in NPM is identical regardless of provider, and all of the providers listed above are listed in the "DNS Provider" dropdown. Only the credentials file format change, and it should be clear what you need to enter.
For DNS providers without a plugin (some registrars don't expose an API), you can't use a DNS-01 challenge through NPM. Alternatives:
Switch DNS to a supported provider (free Cloudflare DNS works regardless of where the domain is registered and switching is easy)
Use acme.sh manually with whatever method your registrar supports (some have custom plugins for acme.sh even when NPM doesn't
If you're exposing your NewsBlur instance to the open internet (skipping the Tailscale private network step), you can use HTTP-01 instead
Different VPN
This guide uses Tailscale because it has the friendliest setup and works on every platform. Substitutions:
WireGuard (vanilla): more work to set up but identical from NewsBlur's perspective. Your server gets a private IP in the wg range (e.g., 10.0.0.x), you point DNS at it, and traffic flows the same way. The Docker Desktop limitation about not binding to VPN interfaces still applies, so the "native NGINX as the entry point" design is still needed.
Headscale: open-source Tailscale-compatible coordination server. If you don't want to depend on Tailscale's hosted service, run your own Headscale instance. Client config and routing behavior are otherwise identical to Tailscale.
ZeroTier: similar mesh-VPN concept, also assigns private IPs. Same overall architecture, same Docker-on-Windows limitation.
OpenVPN / IPSec / commercial VPNs: doable but not the best tool for the job. Most commercial VPNs don't give you a stable IP-to-server mapping that would work with this configuration, so mesh VPNs (Tailscale, Headscale, ZeroTier) are the right tool.
No VPN
If you don't want a VPN and just want your NewsBlur to be reachable on your custom domain, there are a few changes to make and steps to skip.
Please note that having an authentication layer in front of all home server projects is strongly advised, because even though most services like NewsBlur have their own logins that protect the information inside that service, public exposure to your personal machine increases your risk significantly (especially with Windows PCs used as servers). Your home server will just not have the same level of security as most publically-facing services, so be very careful about this decision.
If you know what you're doing, here's what to do differently in this guide:
- Set the Cloudflare A record to your public IP (or use a Dynamic DNS service like ddclient or Cloudflare-DDNS if your IP is dynamic)
- You can use the proxied DNS now (orange cloud), since public IPs are reachable by Cloudflare
- Forward ports 80 and 443 on your router to your server's LAN IP
- Use HTTP-01 challenge in NPM (simpler than DNS-01)
Different reverse proxy
Windows-native NGINX is what I'm most familiar with, but there are a few great alternatives:
Caddy: nicer config syntax than NGINX and built-in ACME (meaning you can manage reverse proxies and SSL in one place and skip NPM entirely). Caddy can do DNS-01 directly with Cloudflare if you build it with the right plugin. Tradeoff: Caddy on Windows is less common, so there's less documentation than its Linux counterpart.
Traefik: most popular in Docker-native setups, but also has a Windows binary version that you'd want to use in place of Windows NGINX to avoid the same VPN interface issue as NPM.
HAProxy: this is a pretty heavy-duty option. It's capable of everything you need but the config language has a learning curve, and the Windows builds aren't amazing.
Linux server: if you're able to move away from Windows for your server, you should. It simplifies pretty much all server projects significantly. NPM in Docker would be the public entry point because it doesn't have the VPN-interface limitation on Linux as it does on Windows, so you can skip the separate NGINX installation entirely. You'd also avoid using WSL because you'd just be running Linux natively instead of on top of Windows, meaning you wouldn't have to jump back and forth because different terminals and file systems.
Different email provider
| Provider | Setup difficulty | Cost, message limits | Notes |
|---|---|---|---|
| Gmail (App Password) | Easiest | Free, 500 messages/day | Set up 2FA, generate app password at myaccount.google.com/apppasswords, use smtp.gmail.com:587. |
| Outlook.com (App Password) | Easy | Free, 300/day | Setup similar to Gmail. Use smtp.office365.com:587. |
| Mailgun | Medium | Free tier, 100/day | Requires DNS records (SPF/DKIM) for sender domain like Proton. |
| SendGrid | Medium | Free tier, 100/day | Setup similar to Mailgun. |
| Brevo (Sendinblue) | Medium | Free tier, 300/day | Setup similar to Mailgun. Marketing focused, more bloat than the previous two if you just want the email delivery. |
| Postmark | Medium | Paid (free trial only) | Setup similar to Mailgun. |
| Amazon SES | Hard | Pay-as-you-go (~$0.10 / 1000) | Cheapest at high volumes but complex setup. Sandboxed by default, not recommended for casual personal use. |
| Self-hosted Postfix/Postfix-relay | Hard | Free | I have to mention a self-hosted option, but delivery to Gmail and Outlook addresses usually fail from home IPs even with proper SPF/DKIM/DMARC, so it's very hard to recommend. |
The Django config is the same for all of them, just customize EMAIL_HOST, EMAIL_PORT, and the credentials to your provider.
NewsBlur alternatives
As ironic as it is to list alternatives to the central tool in the guide, there are some great ones. The networking and certificate parts of this guide apply the same way to these tools, all of which are FOSS like NewsBlur:
| Reader | Notes |
|---|---|
| FreshRSS | Lighter than NewsBlur with a simpler setup, but with fewer features. Runs in a single Docker container. |
| selfoss | Has a good plugin ecosystem, and written in PHP. Appealing to people like myself who are most familiar with web development. |
| Tiny Tiny RSS (tt-rss) | Older but still maintained, with active forums for support. |
| Miniflux | Minimalist and very fast, and runs as a single binary or Docker container. Written in Go. |
| CommaFeed | Supports Google Reader's API, and written in Java. |
| Stringer | I don't think it's very actively maintained but it's a good Ruby on Rails-based alternative. |
If NewsBlur's setup is too complicated for your taste, Miniflux or FreshRSS are your best bets. If you want easy client side customization, selfoss has custom CSS and JS capability built in.
Conclusion
I hope this guide has been helpful. Please reach out if you have questions or something to add/fix - I'd like to make this as comprehensive as possible. I'd also just love to hear if you were successful in installing it. Happy homelabbing!