Operator guide
A tamper-evident archive of incident reports, on your own server. Written for somebody who has not run a production server before. Debian or Ubuntu, Node 22 or newer, PostgreSQL, nginx.
Work through the steps in order. Each one says what to type, what you should see, and what to do when it does not happen. If you have done this kind of thing a hundred times, the whole sequence is in the short version.
It will show the path that applies to you and hide the rest. Answer again to change it, or click the same answer twice to see everything. Kept in this browser only.
Only the first and last are yours to decide. Every command on this page updates to match what you type, so you can copy them straight out without editing. Anything you leave blank keeps the default already shown in the commands — you do not need to retype it, and the defaults are what the shipped service files expect.
What is ahead
Start here if you have just rented a server and are looking at a control panel wondering what to do. If you already have a root shell open, skip to Before you start.
In the Hetzner Cloud console: Add Server.
Create it, wait about fifteen seconds, and copy the IPv4 address from the server's page. That is what you connect to, and it is what the DNS record in the next section points at.
Whatever the provider calls it, you need three things from their
console before you can start: the server's public IPv4
address, a username to log in as (usually
root), and either an SSH key you gave them or a
password they gave you. Install Ubuntu 24.04 or
Debian 12 if you were offered a choice.
A key is a pair of files on your own machine. The public half goes to the server, the private half never leaves you, and together they replace typing a password. Make one:
ssh-keygen -t ed25519ssh-keygen -t ed25519Press Enter at every prompt to accept the defaults. A passphrase is optional and worth having. Then print the public half, which is the part you paste into Hetzner:
cat ~/.ssh/id_ed25519.pubtype $env:USERPROFILE\.ssh\id_ed25519.pubThe one with .pub on the end is the one you
share. The file beside it without .pub is the
private key. It never gets pasted anywhere, emailed, or put in a
repository.
Open PowerShell — press the Start key, type
powershell, press Enter. Windows 10 and 11 have
ssh built in, so you do not need PuTTY or anything
else.
Open Terminal — press ⌘-Space, type
terminal, press Enter.
Open your terminal. On most desktops that is Ctrl-Alt-T.
Then, replacing the address with your server's:
ssh root@203.0.113.10A warning that the authenticity of the host cannot be established,
and a fingerprint. Type yes and press Enter. You
get this once per server; it is your machine saying it has not seen
this one before, not that anything is wrong.
Then either it lets you straight in, or it asks for the root password Hetzner emailed you. Hetzner makes you change that password immediately on first login: it asks for the emailed one, then for a new one twice. Nothing is echoed as you type a password — not even dots. That is normal; keep typing.
You are in when the prompt ends in #, something like
root@ubuntu-2gb-nbg1-1:~#.
Permission denied (publickey). The server has a key you do not hold. On Hetzner, check the server was created with the key you think; if not, use the console's own web terminal or rebuild it.
Connection refused or it hangs. Usually the wrong address, or the server is still booting — give it a minute. Check the IPv4 in the console is the one you typed.
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED. You rebuilt
a server and your machine remembers the old one. Clear it:
ssh-keygen -R 203.0.113.10, then connect again.
Nothing works. Hetzner's console has a >_ Console button that opens a terminal in the browser, straight onto the machine, without SSH. Slow to type in, but it gets you in when SSH will not.
Everything from here runs on the server, in that SSH window, not on your own machine.
Update what is installed. On a fresh image this usually has something to do, and it is the one command that most often asks a question:
apt update && apt upgrade -yA purple screen about restarting services: press Tab to reach <Ok> and Enter. A question about a config file you have never edited: keep the local version, which is the default.
Set the server's name, so the prompt and the logs say what this machine is:
hostnamectl set-hostname reports.example.orgTurn on the firewall. Do the SSH line first — enabling the firewall without it locks you out of your own server:
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
ufw statusStatus: active, with OpenSSH, 80 and 443 listed. Ports
80 and 443 are for the certificate and the website; without them
the certificate step cannot prove the server is yours.
Do not close this window until you have opened a second one. If anything about SSH or the firewall goes wrong, an open session is the difference between a quick fix and the browser console. Open a new terminal, connect again, and only then carry on.
Logging in as root over SSH is normal on a throwaway
box and sloppy on one that matters. On a deployment you intend to
keep: make yourself a user, give it sudo, put your key on it, then
turn off root login and password authentication entirely.
adduser casey
usermod -aG sudo casey
rsync --archive --chown=casey:casey ~/.ssh /home/caseyOpen a second terminal and prove
ssh casey@203.0.113.10 works before going further. Then,
in /etc/ssh/sshd_config, set
PermitRootLogin no and
PasswordAuthentication no, and
systemctl restart ssh. The rest of this guide assumes
root; with a user, put sudo in front of the commands
that change the system.
Four things, in this order. Two of them take longer to arrange than the install does. The instructions are right here rather than further down the page — work through these, then go straight to the install.
Any Linux box you have root on, from any host — nothing here is specific to one provider. Use Debian 12 or Ubuntu 22.04 / 24.04: those are the tested ones, and the only ones the install script installs packages for.
These were measured on 6 October 2026, on an idle instance with an empty archive. They are a floor, not a forecast.
| Text-only | With attachments | |
|---|---|---|
| RAM | 1 GB works; 2 GB is comfortable | 2 GB |
| CPU | 1 core | 2 cores, so a transcode does not starve the website |
| Disk | 5 GB | 10 GB, plus the media cache |
Where that goes: the app sits at ~75 MB resident (~92 MB under a
few hundred requests, as V8's heap grows before collection), PostgreSQL
at ~90 MB, and a freshly migrated database is 10 MB across
45 tables. The checkout is ~4 MB with ~8 MB of node_modules
— seven production dependencies and no build step. Backups keep 14 dumps
by default. The media cache defaults to 2 GB and is only used when
attachments are on.
Transcoding 30 seconds of 1080p video took 21.8 seconds on one core with the settings this app actually uses — about 1.4× realtime, ffmpeg peaking at 148 MB resident. The same job across four cores took 7.5 seconds.
So one core keeps up with occasional video and makes the website slow while it does, because it is the same core. That is the whole argument for a second one. Transcoding runs in the media-worker timer rather than in the request, so a backlog delays publication instead of breaking the site. The files themselves live in object storage, not on the box.
Nobody knows what load this will see. Nothing here claims a capacity measured under real traffic, because none has happened yet. Read the table as “this is not heavy software”, not as a capacity plan.
Not needed, and deliberately: no Docker or container runtime, no Redis, queue or second service, and no build step — the frontend is served as it sits, so there is no toolchain to install.
You need the server's public IP addresses. Your host shows them on the server's page; on the server itself:
curl -4 https://ifconfig.co # IPv4, e.g. 203.0.113.10
curl -6 https://ifconfig.co # IPv6, if the server has oneBuy one first; nothing below works without it, and a certificate
cannot be issued for a bare IP address. Any registrar will do
— Namecheap, Porkbun, Gandi and Cloudflare are all fine, and
a .org or .com is a few pounds a year.
You do not need a new domain if you already own one: a
subdomain of it, reports.yourdomain.org, costs nothing
and is created in the same DNS editor as everything below.
Register it and come back in an hour. A brand-new domain can take that long before its nameservers answer, and every check below will fail in the meantime in a way that looks like you did something wrong.
DNS is edited wherever the domain's nameservers point, which is not always where you bought it. If you are not sure, ask the domain — run this on your own machine or on the server, it does not matter which:
dig +short NS example.org“dig: command not found”? It is not installed by
default on a fresh server or on Windows. Either install it
— sudo apt install -y dnsutils on Debian or Ubuntu
— or use what is already there:
host -t NS example.org, or
nslookup -type=NS example.org, which exists everywhere
including Windows.
| It prints | Edit DNS here |
|---|---|
ns1.your-server.de, second-ns.de |
Hetzner DNS. dns.hetzner.com, pick the zone, Records |
anything .cloudflare.com |
Cloudflare. dash.cloudflare.com, the domain, DNS → Records. Read the proxy warning below before you save |
.registrar-servers.com, .domaincontrol.com,
.googledomains.com and similar |
Your registrar. Namecheap, GoDaddy, Google and the rest each call it “DNS”, “Advanced DNS” or “Manage DNS” |
| nothing at all | The domain does not exist or has no nameservers. Buy it, or check the spelling |
In that editor, create:
| Type | Name | Value | TTL |
|---|---|---|---|
A |
reports for reports.example.org,
or @ for the bare domain |
the IPv4 from above | 300, or whatever the lowest offered is |
AAAA |
the same name | the IPv6 — only if the server has one. An AAAA record pointing nowhere makes the site fail for people on IPv6 and work for everybody else, which is a miserable thing to diagnose | 300 |
Name field conventions differ. Some editors want just
reports, some want the whole
reports.example.org, and some use @ for the
bare domain. If yours shows existing records, copy the style they use.
If your DNS is behind Cloudflare, switch the proxy off for this record — the orange cloud icon, set to grey (“DNS only”). While it is on, Cloudflare answers for your hostname instead of your server, and certbot cannot prove the server is yours. You can turn it back on afterwards.
Set a low TTL before you change anything if the record already exists: that is how long the old answer stays cached, and 300 seconds beats waiting out a day-long one. Then check, from your own machine and not from the server:
dig +short reports.example.orgYour server's IPv4, on its own line, and nothing else. If
dig is not installed: host reports.example.org
or nslookup reports.example.org.
Nothing at all. The record has not propagated, or it was
created in a zone that is not the live one — the second is more
common than people expect, and happens when a domain's nameservers
point somewhere other than the panel you just edited. Check which
nameservers are authoritative with
dig +short NS example.org and make sure that is the
service you edited.
An old address. It is cached. Wait out the previous TTL, or
test against a resolver that will not have it:
dig +short reports.example.org @1.1.1.1.
A Cloudflare address (104.x, 172.67.x) when you expected your server's. The proxy is on — see above.
Check DNS before you go any further. If the command below prints nothing, DNS is not ready and the certificate step will fail.
Not optional. Signing in is a one-time code sent by email, so without working mail nobody can sign in at all — including you, and including the setup wizard at the end of the install. Reports cannot be confirmed either.
You need four values: an SMTP host, a port, a username and a password. The configure step asks for them, and the mail test after it sends a real message to prove they work.
A new server's own IP has no reputation, and mail from it is treated as spam by nearly everyone. You would be debugging deliverability instead of running an archive. Use a provider; the free tiers are far more than this needs.
| Provider | Free tier | Worth knowing |
|---|---|---|
| Mailgun | a few thousand a month | Wants a domain and DNS records. Reliable once set up |
| Postmark | 100 a month | Best deliverability of the three, and strict about what it will send. Plenty for a small archive |
| Amazon SES | cheapest at volume | Starts in a sandbox that only mails addresses you have verified. You must request production access or sign-in codes will not reach anybody |
| Your own mailbox | — | Fastmail, Migadu and most hosts give you SMTP details. Fine for a test deployment. Gmail and Outlook need an app password and throttle hard — avoid for anything real |
Port 587, not 25. Most providers offer 587 (with STARTTLS) and 465 (direct TLS); either works. Port 25 is blocked outbound by Hetzner and most other hosts, so a configuration using it fails with a timeout that looks like the provider being down.
Scaleway Transactional Email is the one provider with its own path
here, over Scaleway's API rather than SMTP, and it needs two values
instead of four: SCALEWAY_SECRET_KEY and
SCALEWAY_PROJECT_ID, plus SCALEWAY_REGION if
you are not in fr-par. The configure step asks which of
the two you want and prompts for the right values;
EMAIL_TRANSPORT can force the choice afterwards.
Everything else on this page, SPF and DKIM included, still
applies.
A word on mail that nothing in the software can fix. Mail sent from a new server's own address is usually treated as spam. Use a provider, and set up SPF and DKIM for your domain with them. A confirmation link in a spam folder is a report that never gets filed.
Skip this if this deployment will never take a photo, a video or a PDF: the site runs text-only without it and uploads can be turned on later. Otherwise do it now — you need the endpoint, the keys and three bucket names at the configure step.
Three buckets, and five values to write down. A bucket is just a named place to put files; making one takes a few seconds.
| Bucket | Holds | Public? |
|---|---|---|
reportlog-staging |
files mid-upload, before a report is confirmed | No |
reportlog-evidence |
the encrypted originals | Never |
reportlog-renditions |
published versions, in the clear | No — the app serves them |
Three buckets, not one, and it is not fussiness. The bucket boundary carries the publication boundary. Evidence holds encrypted originals and must never be public; renditions holds published material in the clear, so an object appearing there is the publication decision. One bucket with per-object permissions is one misconfiguration away from publishing an unreviewed video.
Leave all three private. Nothing here needs a public bucket; the application hands out short-lived signed links instead.
In the Cloud console, left-hand menu, Object Storage.
reportlog-staging,
pick the same location as your server, leave visibility
Private. Create.reportlog-evidence and
reportlog-renditions.Your endpoint is the location, in this shape —
fsn1 for Falkenstein, nbg1 for Nuremberg,
hel1 for Helsinki:
https://fsn1.your-objectstorage.comBucket names are global to the region, so if
reportlog-staging is taken, put something of your own in
front — the names only have to match what you type at the configure step.
Keep S3_ADDRESSING on its default, path.
Same three buckets, all private, plus an access key and a secret key. The endpoint is whatever the provider calls the S3 API URL for your region. The table below says which providers work and whether you need to change one setting.
The configure step asks for exactly these:
S3_ENDPOINT | https://fsn1.your-objectstorage.com |
S3_REGION | the location code, e.g. fsn1 |
S3_ACCESS_KEY | the access key |
S3_SECRET_KEY | the secret, shown once |
| three bucket names | staging, evidence, renditions |
You do not have to get this right first time.
Nothing is destroyed by a wrong value: npm run storage-smoke
at the configure step writes, reads back and deletes one object in each bucket
and tells you exactly which one is wrong.
Skip this unless you are turning uploads on. There is no AWS SDK here
— the signing is sixty lines of node:crypto, so a box
running this does not also carry eighty transitive packages. What that
costs you is one decision an SDK would have made silently: how the
bucket is named in the request.
S3_ADDRESSING | The request | Use it for |
|---|---|---|
path (default) |
endpoint/bucket/key |
Hetzner, Backblaze B2, MinIO, Ceph/RGW, Scaleway, DigitalOcean
Spaces, Cloudflare R2 (also set S3_REGION=auto) |
virtual |
bucket.endpoint/key |
AWS S3 proper, where path-style is deprecated and a bucket in a newer region may refuse it outright |
Leave it alone unless an upload fails. Path-style is the default because it works against the widest range of endpoints and needs no wildcard DNS.
Getting this wrong is a signature mismatch, not a 404. The
bucket is part of what gets signed, so the wrong form fails with
SignatureDoesNotMatch and no mention of addressing. Do not
guess — run the smoke test below, and if it fails with a signature
error against a provider in the second row, set
S3_ADDRESSING=virtual and run it again.
npm run storage-smokeIt writes, reads back and deletes one object in each of the three
buckets, and checks the staging bucket is not readable without a
signature — the misconfiguration that would matter most. Run it before
switching ATTACHMENTS_ENABLED on.
With virtual, bucket names must be lowercase and contain no
dots: a dot means the certificate for *.s3.example.com does
not cover my.bucket.s3.example.com, so the browser's upload
fails with a certificate error that mentions nothing about S3. ReportLog
refuses such a bucket rather than signing a request that cannot work.
Reference rather than a to-do list: the steps install all of this. It is here so you can see what is about to change on the machine.
| What | Which | How it gets there |
|---|---|---|
| Operating system | Debian 12, or Ubuntu 22.04 / 24.04 | Yours. These are the tested ones, and the only ones
install.sh installs packages for. On Fedora, RHEL or
Arch the application itself is fine; the packages are yours to
install, and npm run doctor prints the right command
for your system. |
| Node | 22.0.0 or newer | The Node step. The app refuses to start below 22. |
| PostgreSQL | 16 or 17 | The PostgreSQL step. |
| nginx | Any current version | The certificate step. Not optional: the app listens on
127.0.0.1 only, so without a reverse proxy nothing
outside the server can reach it. |
| Media tools | ffmpeg, ffprobe,
heif-convert, pdftoppm,
pdfinfo |
The media tools step. For photo, video and PDF uploads.
Skippable only if this deployment will never take a file.
clamdscan is separate and stays off unless you set
CLAMAV_ENABLED=true. |
| Object storage | An S3-compatible account, three buckets | Yours to sign up for — the files do not live on the server. Skippable on the same terms as the media tools and on the same switch. Which providers work. |
If you would rather have everything in place before you start, this is the whole package list for Debian and Ubuntu. The steps below install the same things one at a time, and running this first does not break them:
sudo apt update
sudo apt install -y postgresql nginx git curl ca-certificates \
ffmpeg libheif-examples poppler-utilsNode is not in that list on purpose. Debian and Ubuntu ship a
version of Node older than 22, so installing nodejs from
the distribution gives you something the app refuses to start on.
The Node step adds the NodeSource repository and installs 22 from there.
For anyone who has done this before. npm run doctor after
each step says what is missing and prints the install command for your
distribution.
On Debian or Ubuntu, install.sh does every mechanical
step and then stops and prints the three commands that need a person.
Read the dry run first; there is no curl | bash form of
this on purpose.
git clone --branch stable https://github.com/munsdev/reportlog.git
cd reportlog
sudo ./install.sh --dry-run # prints every command, changes nothing
sudo ./install.sh --with-attachmentssudo -u postgres createuser reportlog --pwprompt
sudo -u postgres createdb reportlog -O reportlog
git clone --branch stable https://github.com/munsdev/reportlog.git /srv/reportlog
cd /srv/reportlog
npm ci --omit=dev
npm run doctor
npm run configure
npm run migrate
npm run set-curtain-password
npm run mail-test -- you@example.org
sudo ./contrib/install-units.sh
sudo certbot --nginx -d reports.example.orgThen merge contrib/nginx/reportlog.conf into the block
certbot wrote, and open the site to run the wizard.
Four things in there bite people, every time.
.env holds two encryption keys that exist nowhere
else. Lose either and the data behind it is unreadable
forever./manifest.webmanifest and it fails silently,
with a 200.13 steps. Tick them off as you go — the ticks are kept in this browser, so you can close the tab, reboot the server, and come back to where you were.
The application must not run as root. This makes a locked-down account to own it: no password, no shell login, nothing but the files it needs.
About sudo in the commands below.
If your prompt ends in # you are already root and
sudo does nothing — harmless, so the commands
keep it and work either way. If your prompt ends in
$ you are a normal user and sudo is
doing the work.
sudo adduser --system --group --home /srv/reportlog reportlogAdding system user `reportlog' … and a couple of
lines about a group and a home directory. It does not ask for a
password, and it should not — nobody logs in as this
account. Check it exists:
id reportloguid=… reportlog gid=… reportlog. If instead you
get no such user, the command above did not run.
Now become that user. Everything from here until the certificate step happens
as reportlog, not as root:
sudo -u reportlog -H bash
cd /srv/reportlogYour prompt changes — often to a bare $ with
no name on it, which looks like something broke and has not.
You are in a second shell, inside the first one. Confirm who you
are and where:
whoami && pwdreportlog and /srv/reportlog. Typing
exit leaves this shell and puts you back where you
were — which is how you get to the sudo
commands later on.
You can, but the shipped service files name this user and this
path. Change them together and nothing else —
contrib/install-units.sh, further down, does that
substitution for you if you pass --dir and
--user.
node --versionv22. or higher. Most distributions ship something
older, so you probably need the next block.
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejsUse a system package, not a version manager.
nvm, fnm and asdf install
Node inside a home directory and make it available only to
shells that read a profile script. systemd does not read your
shell profile. The service file names
/usr/bin/node, and if Node is somewhere else the
service fails with No such file or directory —
which reads like a broken service file and is a missing
interpreter.
Check where yours is before the step that installs the units:
command -v nodeIf that is not /usr/bin/node, either install the
system package or tell the unit installer with
--node. It refuses a Node inside a home directory
rather than writing a unit that cannot start.
sudo apt install -y postgresqlThe role owns the database; the app connects as it. The first command asks you to choose a password, twice — keep it, you type it into the configure step.
sudo -u postgres createuser reportlog --pwprompt
sudo -u postgres createdb reportlog -O reportlogpostgres://reportlog:the-password@localhost:5432/reportlog
npm run doctor prints these two commands with your
own names filled in. It will not run them for you: creating a role
on somebody else's cluster is not a thing an install script should
do unasked, and the authentication method your cluster uses is a
decision only you can make.
Skip this step if this deployment will never accept a file. Everything else works without it, and you can come back and do it later.
sudo apt install -y ffmpeg libheif-examples poppler-utilsAll five on the path:
for b in ffmpeg ffprobe heif-convert pdftoppm pdfinfo; do command -v $b || echo "MISSING $b"; doneFive binaries from three packages, because the package names are not the binary names and they differ between distributions. If a line above says MISSING, the doctor prints the right command for the machine you are on.
heif-convert is in libheif-examples on
Debian and Ubuntu, libheif-tools on Fedora and
Alpine, and libheif on Arch.
pdftoppm and pdfinfo are both in
poppler-utils.
Two that catch people: Fedora and RHEL do not ship
ffmpeg in their own repositories, so enable RPM
Fusion first; and Alpine keeps libheif-tools in a
repository that is commented out on some images.
clamdscan is a sixth binary and a separate
decision. Nothing uses it unless you set
CLAMAV_ENABLED=true later, and it fails closed: if
it is switched on and the binary is missing, uploads are left
unprocessed rather than passed through unscanned.
sudo apt install -y clamav-daemoncd /srv/reportlog
git clone --branch stable https://github.com/munsdev/reportlog.git .
npm ci --omit=devgit status -sb should start
## stable...origin/stable.
stable is the branch you install from. It
moves forward only when a change has been through all three test
suites, so it is the newest code anybody is being asked to run.
main is where work lands and may be mid-change; do
not install from it. npm ci rather than
npm install installs exactly what the lockfile says,
and --omit=dev skips the test tooling a server does
not need.
A tag would silently break upgrades, which is why this
is a branch. git clone --branch <tag>
leaves a detached HEAD — on no branch at all.
deploy.sh asks
git rev-parse --abbrev-ref HEAD what to pull, which
on a detached checkout returns the literal string
HEAD, so the upgrade pulls the remote's default
branch. A box installed from a tag would upgrade itself onto
main the first time anybody upgraded it, without
saying so. Measured against a real clone, not reasoned about.
v0.0.2, an old tag, predates logbooks
entirely. No operator-built questionnaires, and none of
this guide. Do not install it expecting any of this.
npm run doctorIt checks Node, the configuration, the database, the migrations, the media tools, mail and object storage, and for anything missing it prints the install command for your distribution. Right now it will complain about the configuration and the schema, because neither exists yet. That is correct.
It installs nothing and changes no files. Everything it suggests is printed for you to read first.
A list of checks, each ok or --, then
What is wrong and What to run. At this point it is
supposed to complain: there is no configuration and no
database schema yet. Those two are the next steps.
Run it again after every step from here. It is the fastest way to find out whether what you just did took.
npm run configureThis writes .env. It asks only for things a
person can know — the site's address, the database, the mail
and storage credentials — and generates the two encryption keys
itself.
It walks you through, in order: the site origin, the database URL, the port, what the deployment calls itself, the mail transport and its credentials, and object storage. Mail and storage can both be skipped and filled in later.
The site origin needs the scheme:
https://reports.example.org. The port is
localhost-only — the app binds 127.0.0.1, so it is
not reachable from outside without the proxy.
npm run configure -- --check reports what is
missing, including the case that matters most: a half-filled
section, which looks configured in the file and behaves exactly
as unset. configure itself refuses to overwrite
an existing .env.
.env — before you go any furtherThis is the step people skip and cannot undo.
configure just generated two keys:
TIER_B_ENCRYPTION_KEY, which encrypts reporters'
contact details and wraps the per-file key of every attachment,
and CONTACT_HMAC_KEY, which lets a reporter find
their own reports.
They are irreplaceable. Lose either and the data behind it is unreadable forever, with the ciphertext sitting intact in every backup you own. They are never prompted for, never displayed, and exist only in that one file.
A database backup without .env is a pile of
ciphertext nobody can open — including you.
sudo chmod 600 /srv/reportlog/.env
sudo chown reportlog:reportlog /srv/reportlog/.env
sudo cp /srv/reportlog/.env ~/reportlog-env-backup-$(date +%F)Nothing. All three are silent when they work, which is normal for Unix and unnerving the first time. Check instead:
ls -l /srv/reportlog/.env ~/reportlog-env-backup-*The first should read -rw------- and
reportlog reportlog — owner can read and
write it, nobody else can read it at all. The second is your
copy, and it is still on the same server, which is not a backup
yet.
Then get that copy off the server and somewhere you trust — a password manager, an encrypted volume, a printed copy in a safe. Treat it like the only copy of a private key, because that is what it is.
npm run migrateA line per migration — Applying
001_init.sql… and so on, forty-odd of them — ending
in Done. Run it a second time and it should print
Done. with nothing above it, because they have all
been applied.
Creates every table, index and trigger, and records what has run. Safe to run again — applied migrations are skipped and it says so. If it fails, it is almost always the database URL.
A fresh install comes up gated with no password set, which means nothing matches it and nobody gets in — including you. That is why this is a step and not an optional extra.
npm run set-curtain-passwordTwo prompts, then a line saying it is set. Nothing appears as you type — no characters, no dots. That is the terminal hiding a password, not a frozen program.
It asks twice and refuses to finish without one. Minimum eight characters. It never generates one for you: a password nobody chose is a password nobody read carefully, and this one is the only thing in front of the deployment until the rest of it exists. Keep it — you need it at the very last step, and you hand it to whoever will own the deployment.
The curtain is not authentication and is not a substitute for it. It keeps an unfinished deployment off the open internet; the archive is protected by everything else.
npm run mail-test -- you@example.orgThe mail, in the inbox you named. Wait for it. Do not move on because the command exited without error.
Prove mail before you hand the site to anybody. Without it the one-time sign-in code is discarded rather than logged — so nobody can sign in, nobody can confirm a report, and the setup wizard cannot be finished, which means the deployment can never be completed at all.
Check the spam folder first, then your provider's dashboard for
a rejection, then npm run doctor, which reports
whether the mail configuration is complete enough to try at
all.
First check it starts at all:
npm startcurl -s -o /dev/null -w "%{http_code}\n"
http://127.0.0.1:5000/api/health → 200.
Ctrl-C to stop it.
npm start stops when you close the terminal. A service
and eight timers keep it going, each timer with a one-shot service
of its own, and one command installs and enables all of them:
sudo ./contrib/install-units.sh --dry-run
sudo ./contrib/install-units.sh| reportlog | the app. Required. |
|---|---|
| media-worker | processes uploads, every minute. Required if uploads are on — without it an upload sits at “checking” forever. |
| anchor | the daily chain timestamp. Required if you want the thing this software is for. |
| anchor-watch | checks the anchor above actually ran. Required — a timer being enabled is not the same as the job working, and an anchor that fails every night looks identical to one that does not. |
| backup | a verified daily dump. Required — see backups. |
| notify-hourly | sends the hourly digest to whoever asked for one. Required once anybody subscribes — they chose it in their own settings, and without this nothing is ever sent. |
| notify-weekly | the same digest, with a week's window. |
| sweep | deletes expired sign-in codes. Nothing breaks without it; the tables just grow. |
| update | applies an update an admin asked for in the Updates pane. Optional — without it the pane still shows what is available and you update over SSH instead. |
That script exists because of a real failure. The anchor timer was once missing from a hand-typed list of commands, so a deployment that followed the instructions exactly never anchored — and nothing noticed, because a deployment that is not anchoring looks completely healthy. Every command you type from a list is a chance to stop one short, and the list has grown from five to eight since.
The script installs whatever ships in
contrib/systemd/, so a release that adds a unit is
picked up by running it again rather than by anybody noticing.
systemctl list-timers --all | grep reportlog —
all eight timers should appear.
Certificate first. certbot writes the HTTPS server
block and the redirect for you.
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d reports.example.orgIt asks for an email address (for expiry warnings), makes you
agree to the terms, and asks whether to share your address with
the EFF — N is fine. Then:
Successfully received certificate. and the paths
it wrote under /etc/letsencrypt/live/. The site is
now reachable over https:// and plain
http:// redirects to it.
“DNS problem: NXDOMAIN” or
“no valid A records found”. The hostname does not
resolve yet. Go back and check
dig +short reports.example.org prints this
server's address. Nothing else will work until it does.
“Timeout during connect”. Port 80 is not
reachable. Two usual causes: the firewall
(ufw status should list 80 and 443), or your
provider's own firewall in their console, which is separate.
“Unauthorized” and the address it reports is not your server's. Something else is answering for that hostname — most often Cloudflare's proxy. Turn the orange cloud grey and try again.
Too many failed attempts. Let's Encrypt rate-limits
after five failures an hour for the same hostname. Add
--dry-run while you work out the problem; it uses a
separate, far looser limit.
Then merge contrib/nginx/reportlog.conf into the
block certbot just wrote. Edit its file rather than replacing
it — delete and rewrite and the certificate wiring goes with
it.
Five paths must reach the app. nginx serves
public/ off disk and everything the app answers has
to be proxied: /api/, /docs,
/styleguide, /app and
/manifest.webmanifest.
The last one is the dangerous one. It exists both as a file on disk and as a route, so a missing location block does not 404 — nginx serves the file, successfully, with a 200. Nothing looks broken; the deployment just serves a stale copy of something the app was supposed to build, and every home-screen icon is labelled “ReportLog” instead of your deployment's name. It went three days undetected.
The security policy is one file, included in the server block and
again inside location = /sw.js. Do not paste the
policy string — including the file means a release that changes
the policy reaches your box with git pull.
include /srv/reportlog/contrib/nginx/csp.conf;It appears twice because one add_header anywhere
inside a location replaces every header inherited
from the server block — that is nginx's rule, not a quirk of
this config.
sudo nginx -t && sudo systemctl reload nginxsyntax is ok and test is successful,
then silence from the reload. The && is
load-bearing: it means the reload only happens if the test
passed, so a typo in the config cannot take the site down.
If the test fails it prints the file and line number. Fix that line and run it again — the running nginx is untouched until a test passes.
Now prove the five paths actually reach the application, which is the thing that silently does not work:
npm run doctorUnder Which of these the app answers, all five say
the app. If /manifest.webmanifest says
nginx instead, the location block for it is missing
— that is the 200 described above, and the doctor is the
only thing that notices.
The && matters. A failed test must not
reload.
Never leave a backup in sites-enabled/.
nginx includes that directory with a bare * and no
extension filter, so mysite.bak beside
mysite is parsed as a second copy of the same server
block. It fails as duplicate listen options, at a
line number inside the backup, which reads like a broken
config rather than a stray file.
And it fails late, not now. The running server keeps the config it already parsed, so everything looks fine until something reloads — a certificate renewal, weeks later, with nothing to connect it to your edit. Always finish an edit by testing it.
cp -a of a sites-enabled entry
usually copies a symlink, not a file, so your “backup” points
at the very file you are about to change. Use cp -L,
or back up the target.
From here you are in a browser on your own machine, not in the
terminal. Visit https://reports.example.org.
A padlock in the address bar, and a lock screen asking for a password. That is the curtain, and the password is the one you set when you built the schema.
If you get a browser warning about the certificate, you
reached the site over http:// or the hostname does
not match the one certbot was given. If you get nginx's default
“Welcome” page, the server block certbot wrote is
not the one being served — check
sudo nginx -T | grep server_name.
The first-run wizard then runs, once, in six screens:
| 1 | the owner's email address — this mints the owner, the one account that can appoint or remove an administrator, and a seat that cannot be granted to anybody |
|---|---|
| 2 | a one-time code sent to it — where a broken mail setup stops you dead, which is why the mail test exists |
| 3 | what this archive is called |
| 4 | whether it publishes a public archive |
| 5 | whether people can attach files to a report |
| 6 | the curtain password — keep it, or set a new one |
Screens three to five arrive set to what the software would do anyway and say which answer that is, so you can read them and pass through. Screen six has no default and no skip, deliberately: otherwise a password the installer chose stays live in somebody else's deployment and neither of them notices. Every screen saves before the next is shown.
Handing it over. If you are installing this for somebody else, what you hand them is the URL and the curtain password. They run the wizard themselves and become the owner. That is the whole point of the split: the installer never has to hold the owner's account.
systemctl is-active reportlog
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:5000/api/health
systemctl list-timers --all | grep reportlog
npm run doctor/api/health sits outside the curtain deliberately, so it
answers 200 even while the deployment is locked. It is the
cleanest liveness check.
npm run doctor answers most of this now. Besides
the configuration, it asks the running deployment three questions
nothing in the repository can answer: whether the live nginx config
serves a security policy at all, which of the five proxied paths the
app actually answered and which nginx answered instead, and whether
the timers are enabled and there is a recent backup.
Then, in a browser:
Read this before you take a real report. This archive cannot delete a row by design. That protects the record from being edited and not at all from the disk dying.
There is a script and a daily timer, and the doctor complains when the newest dump goes stale:
npm run backup # take one now
npm run backup -- --check # report, take nothingIt writes to a temporary name and renames only after reading the dump
back with pg_restore --list. A dump interrupted half way is
a plausible-looking file of the wrong length, and the one moment nobody
checks a backup is while taking it. It prunes only files it wrote, and
refuses a BACKUP_DIR inside the checkout.
.env | Yours, and deliberately not in the dump. Putting the keys beside the ciphertext they open turns two secrets into one. Off the machine, once. |
|---|---|
| the database | The timer covers it. Getting a copy off the box is still yours — point whatever you already use at BACKUP_DIR. |
| the buckets | If uploads are on. Whatever replication your storage provider offers, turn it on. |
| the transparency log | A git repository, meant to be mirrored to several hosts under different operators — a record only one company holds is a record one company can be compelled to remove. |
npm run export-archive is not a backup,
and the script says so itself. It re-encodes, and replaying it builds a
new chain — every hash recomputed, so the result is a valid
archive that is not the one it came from, and every anchor you have
published names a head that no longer exists. Use
pg_dump to get your data back as it was.
And check that a restore works. An untested backup is a belief, not a backup.
sudo -u postgres createdb reportlog_restore_test -O reportlog
sudo -u postgres pg_restore -d reportlog_restore_test /var/backups/reportlog/<newest>.dumpA deployment with no logbook accepts no reports at all, and that is deliberate rather than an omission. Every report is filed through a logbook, and a logbook is a questionnaire you build. There is no built-in form to fall back on and no second route in. Until one logbook is launched, the submit page has nothing to offer and the board is empty.
This is the part that is not sysadmin work. Take your time over it, because one decision in it cannot be undone.
Sign in, then Admin → Logbooks → Create a logbook. It asks two things.
Start from one of the shipped question sets rather than an empty form. The dialog offers them, they arrive as a draft you can change freely, and they are a better starting shape than a blank page.
| Starting point | Questions |
|---|---|
| Election incidents | 10 |
| Election day: how did it go? | 9 |
| Incident reports | 10 |
| Safety observations | 9 |
| Complaints about a practitioner | 12 |
You can also start from a logbook file — the .json a
Download as a logbook file gives you, which is how a question set
moves between deployments. An upload always arrives as a draft, never as
a launched logbook.
Add a question, one at a time. The types are: short text, long text, date, time of day, date and time, place, choose one, choose any, yes or no, number, email address, web address, evidence they have but are not uploading, and photos, video or documents.
For each one you also decide:
Three things people get wrong here.
You do not have to ask for contact details, consent, or whether it may be published. Every report carries those, whatever logbook it came through and however it was filed. Asking again means storing the second copy in the wrong place.
A written account is not built in. If you want one — and most logbooks do — ask for it as a long text question. It is the logbook’s question, not the software’s.
Uploads are decided by asking. A logbook takes files if, and only if, it has a photos-video-documents question. There is no separate switch to find; the question is the switch. Sizes and counts are under What this logbook will accept, and leaving a box empty uses the site’s own number.
Open the submit page and fill the whole thing in as if you were a reporter. Not a skim — type the answers. It is the only way to notice a question that reads clearly to the person who wrote it and ambiguously to everybody else. Do it on a test box, or remove the test report afterwards.
Launch this logbook is irreversible. Afterwards it may gain a new optional question and nothing else: no rewording, no removing, no reordering, and no promoting an optional question to required.
A report seals the questions it was answered against — the version the reporter was actually asked, not the one at confirmation. Changing a launched form would mean reports in the same logbook that cannot be read against one definition. The narrow opening for a new optional question exists only because a key is written into the hashed object exclusively when it applies.
So: read every question aloud, fill the form in once, and only then launch.
When it runs. A logbook can be given an Opens and a Closes time. Launched with an opening time in the future it is scheduled — the state is computed from the clock whenever somebody asks, not flipped by a timer, so it survives the box being down over the boundary.
Closing an intake is not retiring a record. Reports already filed stay published and stay verifiable.
Nothing is public until a moderator approves it. There is no auto-publish path and there must not be one. A deployment with a launched logbook and no moderator collects reports nobody can act on.
Access → Roles: an email address, a role, Apply. The person gets a one-time code at that address the first time they sign in — which is the other reason mail had to work before any of this.
| Role | Can mint |
|---|---|
| Moderator | nobody |
| Access Manager | Moderators — not other Access Managers |
| Administrator | per the above, and only the owner appoints or removes one |
| Owner | exactly one, and the seat cannot be granted at all |
The owner’s seat moves only by the two-party handover on the Access screen or by the setup wizard. It appears in no row of the granting table, so there is no code path that produces a second owner.
A moderator may publish a report’s text and withhold its photographs. Never the reverse — consent is the ceiling and moderation is the floor.
With the logbook open and a moderator appointed, file a report yourself and follow the confirmation link in the email.
That last part is the point. A path that only runs when somebody clicks a link in an email is the path nothing reaches by accident: report confirmation was broken in production for three days under three green test suites, because none of them walked a confirmation link. Walk it once by hand on every deployment.
Then you have a working archive, and the address is worth giving out.
Admin → Operations → Updates shows what this
deployment is running, what is on stable, and the release
notes for every version in between. One button installs it: the site
takes a database backup, installs, migrates and restarts — about a
minute, and a minute of downtime.
It needs reportlog-update.timer, installed with the other
units. Without it
the pane still tells you an update exists, but the button has nothing
listening and npm run doctor says so.
The application is not what updates it, and that is
deliberate. A web page that could run git pull
and restart a service would be a path from “somebody sent an
HTTP request” to “code runs on the server”, on a box
holding people’s identities. The button only writes a file
naming a commit. A root timer reads it, fetches from a URL kept in
the systemd unit rather than from the checkout, refuses anything
that is not the exact tip it just fetched, takes a backup, and only
then applies it.
So an attacker holding the whole application could cause this deployment to install genuine upstream code earlier than you meant. They could not make it install code of their own.
A failed update does not roll itself back. A half-applied migration plus an automatic revert is how an archive gets damaged. The pane shows what failed and the exact rollback command, and a person decides. The backup taken before it started is the way back.
Every deploy after the first is one command. It refuses to run over uncommitted changes, fast-forwards only, installs from the lockfile, migrates, restarts last, and prints the rollback command if the service does not come back.
cd /srv/reportlog && ./contrib/deploy.shThe first deploy is by hand.
deploy.sh cannot perform the deploy that installs
deploy.sh — on a box checked out before
contrib/ existed, bash says No such file or
directory. That is the file missing, not the box being
broken.
The checkout being current is not the same as the deployment
being current. A checkout can reach a commit without going
through deploy.sh — which is what runs the install, the
migrations and the restart. Confirm a deploy by asking the running
service, not the git log.
Take a dump before any upgrade that runs a migration. Migrations are
additive by rule, but a backup you did not need costs nothing. And read
the release notes: a release that adds a proxied path needs a one-time
nginx edit, because deploy.sh deliberately never edits
nginx's configuration.
./contrib/reset-to-fresh.shThis destroys the archive on that box and puts it back to the
state a stranger's install starts in, so the wizard runs again. It never
touches .env. Test deployments only. It makes you
type the deployment's own SITE_ORIGIN to confirm, after
printing the real counts of what it is about to delete — the only
confirmation that cannot be given by reflex, and the one that catches the
mistake that matters: running it on the wrong box.
npm run doctor
systemctl status reportlog
journalctl -u reportlog -n 50 --no-pagerService will not start,No such file or directory | Node is not at /usr/bin/node. See 2. |
|---|---|
| Service exits immediately | SITE_ORIGIN unset — it refuses to start without one — or the database is unreachable. |
| Nobody can sign in, including you | Mail. The code is discarded rather than logged. See the mail test. |
| Lock screen rejects every password | No curtain password is set, so nothing matches it. See the schema step. |
| A route 404s but the site works | A missing nginx location block. See the certificate step. |
| Home-screen icon says “ReportLog” | /manifest.webmanifest is being served off disk. Same step — this one fails with a 200. |
| Uploads stick at “checking” | The media worker timer is not running. See the units step. |
| The chain has never been anchored | The anchor timer was never enabled. See the units step. |
| Backups stopped and nothing said so | npm run backup -- --check, then journalctl -u reportlog-backup -n 30. |
SASL error running something by hand | Anything run with node -e must load dotenv/config before importing anything that reaches the database. It reads like a permissions problem and is not. |
Security problems go to security@reportlog.org, never to a
public issue.