ReportLog v0.0.3 standing up a deployment

Operator guide

Standing up ReportLog

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.

Tell the page what you have

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.

Fill these in once

Every command on this page updates to match, so you can copy them straight out without editing. Leave them blank to see the defaults.

What is ahead

  1. Getting to a command prompt — you have a server and nothing else
  2. Before you start — four things, with the instructions for each
    1. A server, and how big
    2. Point a hostname at it — the exact DNS record
    3. Somewhere to send mail from
    4. Object storage, if you want uploads
  3. What the install puts on the box
  4. The short version, if you have done this before
  5. The install — thirteen steps
  6. Checking it actually works
  7. Backups — before you take a real report
  8. Your first logbook — nothing can be filed until you make one
  9. Upgrading, and starting over

Getting to a command prompt

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.

Hetzner Cloud

Making the server

In the Hetzner Cloud console: Add Server.

  • Location — whichever is nearest the people who will use it. For an archive of reports about a place, prefer a country whose legal regime you are content with; the data lives there.
  • Image — Ubuntu 24.04.
  • Type — shared vCPU is fine. CX22 (2 vCPU, 4 GB, 40 GB) is comfortably above what this needs.
  • SSH keys — add one if you have one; see below if not. Without a key Hetzner emails you a root password instead, which works and is worse.
  • Leave the rest alone. No cloud-init, no volumes, no firewall yet — there is one later in this section.

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.

Another provider

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.

If you do not have an SSH key

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 ed25519
ssh-keygen -t ed25519

Press 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.pub
type $env:USERPROFILE\.ssh\id_ed25519.pub

The 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.

Connecting

Windows

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.

Mac

Open Terminal — press ⌘-Space, type terminal, press Enter.

Linux

Open your terminal. On most desktops that is Ctrl-Alt-T.

Then, replacing the address with your server's:

ssh root@203.0.113.10
what you should see

A 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:~#.

It will not connect

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.

The first five minutes on a new box

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 -y
if it asks you something

A 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.org

Turn 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 status
what you should see

Status: 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.

Worth doing, and not required for this guide

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/casey

Open 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.

Before you start

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.

1 · A server

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-onlyWith attachments
RAM1 GB works; 2 GB is comfortable2 GB
CPU1 core2 cores, so a transcode does not starve the website
Disk5 GB10 GB, plus the media cache
Where those numbers come from

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.

Attachments are where the CPU goes, and it is worth one number

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.

2 · Point a hostname at the server

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 one
I do not have a domain yet

Buy 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.

Find the right DNS editor first

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.

what the answer means
It printsEdit 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:

TypeNameValueTTL
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.org
what you should see

Your server's IPv4, on its own line, and nothing else. If dig is not installed: host reports.example.org or nslookup reports.example.org.

It prints nothing, or the wrong address

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.

3 · Somewhere to send mail from

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.

Do not send mail from the server itself

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.

ProviderFree tierWorth knowing
Mailguna few thousand a month Wants a domain and DNS records. Reliable once set up
Postmark100 a month Best deliverability of the three, and strict about what it will send. Plenty for a small archive
Amazon SEScheapest 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

Whichever you pick

  1. Sign up and add your domain (or verify a single sender address, if you are only testing).
  2. Add the SPF and DKIM DNS records it gives you, in the same DNS editor you used for the hostname above. They are usually one TXT record each. Without them your mail lands in spam.
  3. Find the SMTP credentials page — sometimes called “SMTP”, sometimes “Sending”, sometimes “Integration”. Copy the host, the port, the username and the password.

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, which does not need SMTP at all

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.

4 · Object storage, for uploads

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.

What you are creating

Three buckets, and five values to write down. A bucket is just a named place to put files; making one takes a few seconds.

BucketHoldsPublic?
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.

Hetzner Cloud

Hetzner Object Storage, step by step

In the Cloud console, left-hand menu, Object Storage.

  1. Create bucket. Name it reportlog-staging, pick the same location as your server, leave visibility Private. Create.
  2. Do it twice more for reportlog-evidence and reportlog-renditions.
  3. Go to Credentials and Generate credentials. You get an access key and a secret key. The secret is shown once. Copy both somewhere now.

Your endpoint is the location, in this shape — fsn1 for Falkenstein, nbg1 for Nuremberg, hel1 for Helsinki:

https://fsn1.your-objectstorage.com

Bucket 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.

Any other provider

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.

What to write down

The configure step asks for exactly these:

S3_ENDPOINThttps://fsn1.your-objectstorage.com
S3_REGIONthe location code, e.g. fsn1
S3_ACCESS_KEYthe access key
S3_SECRET_KEYthe secret, shown once
three bucket namesstaging, 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.

Which providers work

Why there is no AWS SDK here

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_ADDRESSINGThe requestUse 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.

Why the wrong setting fails so confusingly

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-smoke

It 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.

What the install puts on the box

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.

WhatWhichHow 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.
Node22.0.0 or newer The Node step. The app refuses to start below 22.
PostgreSQL16 or 17 The PostgreSQL step.
nginxAny 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-utils
note

Node 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.

The short version

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.

Scripted

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-attachments

By hand

sudo -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.org

Then 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.
  • Without working mail nobody can sign in at all, including you, and the wizard cannot be finished.
  • The proxy has to forward five paths. Miss /manifest.webmanifest and it fails silently, with a 200.
  • Nothing backs up your database off this machine. That part is yours.

The install

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.

  1. A user, and somewhere to put it

    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 reportlog
    what you should see

    Adding 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 reportlog

    uid=… 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/reportlog
    what you should see

    Your 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 && pwd

    reportlog 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.

    If you want a different user or directory

    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.

  2. Node 22 or newer

    node --version
    you should see

    v22. 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 nodejs
    Why not nvm, fnm or asdf

    Use 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 node

    If 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.

  3. PostgreSQL, a role and a database

    sudo apt install -y postgresql

    The 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 reportlog
    your connection string

    postgres://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.

  4. Media tools, for photo, video and PDF uploads

    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-utils
    what you should see

    All five on the path:

    for b in ffmpeg ffprobe heif-convert pdftoppm pdfinfo; do command -v $b || echo "MISSING $b"; done

    Five 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.

    Not on Debian or Ubuntu, or a name is missing

    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.

    Virus scanning, which is off by default

    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-daemon
  5. Get the code

    cd /srv/reportlog
    git clone --branch stable https://github.com/munsdev/reportlog.git .
    npm ci --omit=dev
    check what you got

    git 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.

  6. Run the doctor — now, and after every step

    npm run doctor

    It 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.

    what you should see

    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.

  7. Configure

    npm run configure

    This 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.

    as you answer

    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.

    Checking an existing .env without changing it

    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.

  8. Back up .env — before you go any further

    This 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)
    what you should see

    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.

  9. Build the schema, and set the curtain password

    npm run migrate
    what you should see

    A 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-password
    what you should see

    Two 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.

  10. Prove mail works

    npm run mail-test -- you@example.org
    you should see

    The 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.

    If it does not arrive

    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.

  11. Leave it running

    First check it starts at all:

    npm start
    then, in another terminal

    curl -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
    reportlogthe app. Required.
    media-workerprocesses uploads, every minute. Required if uploads are on — without it an upload sits at “checking” forever.
    anchorthe daily chain timestamp. Required if you want the thing this software is for.
    anchor-watchchecks 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.
    backupa verified daily dump. Required — see backups.
    notify-hourlysends 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-weeklythe same digest, with a week's window.
    sweepdeletes expired sign-in codes. Nothing breaks without it; the tables just grow.
    updateapplies 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.
    Why a script rather than a list of commands

    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.

    check it took

    systemctl list-timers --all | grep reportlog — all eight timers should appear.

  12. A certificate, and the proxy

    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.org
    what you should see

    It 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.

    certbot failed

    “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 nginx
    what you should see

    syntax 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 doctor
    what you should see

    Under 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.

    Two ways an nginx edit goes wrong quietly

    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.

  13. Open it, and finish setup

    From here you are in a browser on your own machine, not in the terminal. Visit https://reports.example.org.

    what you should see

    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:

    1the 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
    2a one-time code sent to it — where a broken mail setup stops you dead, which is why the mail test exists
    3what this archive is called
    4whether it publishes a public archive
    5whether people can attach files to a report
    6the 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.

Checking it actually works

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:

Backups

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 nothing

It 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.

.envYours, and deliberately not in the dump. Putting the keys beside the ciphertext they open turns two secrets into one. Off the machine, once.
the databaseThe timer covers it. Getting a copy off the box is still yours — point whatever you already use at BACKUP_DIR.
the bucketsIf uploads are on. Whatever replication your storage provider offers, turn it on.
the transparency logA 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.
export-archive is not a backup

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>.dump

Your first logbook

A 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.

1 · Create the draft

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 pointQuestions
Election incidents10
Election day: how did it go?9
Incident reports10
Safety observations9
Complaints about a practitioner12

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.

2 · Ask your questions

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.

3 · Read it as a reporter, before you launch

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.

4 · Launch it, and this is the one-way door

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.

Why a launched form is frozen

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.

5 · Somebody to review what arrives

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.

RoleCan mint
Moderatornobody
Access ManagerModerators — not other Access Managers
Administratorper the above, and only the owner appoints or removes one
Ownerexactly 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.

6 · File one for real, through the whole path

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.

Afterwards

Upgrading from the admin page

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.

Why the button cannot install anything by itself

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.

Upgrading over SSH

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.sh

The 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.

Starting over on a test box

./contrib/reset-to-fresh.sh

This 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.

When something is wrong

npm run doctor
systemctl status reportlog
journalctl -u reportlog -n 50 --no-pager
Service will not start,
No such file or directory
Node is not at /usr/bin/node. See 2.
Service exits immediatelySITE_ORIGIN unset — it refuses to start without one — or the database is unreachable.
Nobody can sign in, including youMail. The code is discarded rather than logged. See the mail test.
Lock screen rejects every passwordNo curtain password is set, so nothing matches it. See the schema step.
A route 404s but the site worksA 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 anchoredThe anchor timer was never enabled. See the units step.
Backups stopped and nothing said sonpm run backup -- --check, then journalctl -u reportlog-backup -n 30.
SASL error running something by handAnything 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.