Installing and running the site¶
This guide sets up the recipe website on a server: nginx serving the static site and recipe files, plus the optional Node service that adds sign-in, recipe import and an admin page. The static site works without the service. Once it's running, see Using the import service for signing in, roles and passwords.
How it works¶
How each kind of input is converted, and what isn't supported, is covered in Using the import service.
Requirements¶
- A container or VM running a Linux distro of your choice. The guide is written for Rocky Linux 10. 1 vCPU, 2 GB of RAM and a 20 GB disk are plenty. Depending on the distro you choose, some parts of the setup may differ from this guide. For example, your distro may not use SELinux, or nginx might use the
sites-availableandsites-enabledfolder structure instead ofconf.d/*. - A reverse proxy/router that ends HTTPS for the site's host name and forwards to port 80 on the VM. The example uses Traefik. Setting up Traefik isn't covered here; the security checklist lists what nginx needs to know about it.
- Outbound internet access from the VM: ports 80 and 443 for package installs,
github.com,api.anthropic.com,registry.npmjs.organd any recipe site you import from. The service connects directly; it doesn't support an outbound HTTP proxy. - An Anthropic API key for PDF, photo and text imports, from console.anthropic.com. Without one,
.jsonfiles and most recipe websites still import.
Preparing the VM¶
1. Install Rocky Linux 10¶
Boot the VM from the Rocky Linux 10 minimal ISO. In the installer:
- Software Selection: Minimal Install.
- Network & Host Name: switch the network adapter on and set a host name. Give the VM a fixed address, either static or a DHCP reservation, so Traefik can always find it.
- Root Account: leave it locked.
- User Creation: create your own account and make it an administrator (wheel group), so it can use
sudo.
Leave SELinux enforcing, which is the default. The steps below set the labels and permissions it needs.
When the install finishes, sign in over SSH as your user (ssh you@your-vm) and run everything from there.
2. Update and install packages¶
sudo dnf -y update
sudo dnf -y install nginx nodejs npm git acl nano firewalld policycoreutils
sudo systemctl enable --now firewalld
sudo reboot
The reboot picks up any new kernel. After signing back in, check Node.js:
Rocky Linux 10 ships Node.js 22 in its AppStream repository, so no extra repository is needed.
3. Check outbound access¶
Each line should print an HTTP status code. A timeout or 000 means outbound access is blocked:
curl -s -o /dev/null -w '%{http_code}\n' --max-time 10 https://api.anthropic.com/
curl -s -o /dev/null -w '%{http_code}\n' --max-time 10 https://www.bbcgoodfood.com/
curl -s -o /dev/null -w '%{http_code}\n' --max-time 10 https://registry.npmjs.org/
curl -s -o /dev/null -w '%{http_code}\n' --max-time 10 https://github.com/
Any number counts as success, including 404; it only proves the VM can reach the site.
Quick install with install.sh¶
install.sh in the repo runs steps 4 to 12 below for you, and running it again later updates the install. It supports the RHEL family (Rocky, Alma, RHEL, CentOS Stream, Fedora) and Debian or Ubuntu. On anything else, follow the steps by hand. To run the site in Docker instead, see docker.md.
sudo dnf -y install git # or: sudo apt install git
git clone https://github.com/foshiee/nammet-recipe-website.git ~/nammet-recipe-website
cd ~/nammet-recipe-website
./install.sh
Run it as your normal user; it asks for your sudo password. It asks for the reverse proxy's address (see the security checklist for which to use), whether Cloudflare is in front, your Anthropic API key (optional), and a name for the first admin, then checks the site answers. Updates don't ask about the proxy or Cloudflare again; they keep what's in the installed nginx config unless you give --proxy, --cloudflare or --no-cloudflare. To skip the questions, give the answers as options:
| Option | |
|---|---|
--proxy ADDR[,ADDR] |
The reverse proxy's IP address or CIDR range. Fills in the real-IP lines in the nginx config and, if firewalld or ufw is running, opens port 80 to that address only. --proxy '' clears the addresses. |
--cloudflare |
Also trusts Cloudflare's ranges (written to /etc/nginx/cloudflare-ips.inc). |
--no-cloudflare |
Stops trusting Cloudflare's ranges. |
--admin NAME |
Creates the first admin. You're still asked for the password. |
--no-admin |
Skips the first admin. |
--yes |
Asks nothing and uses the options given. Without --admin, it prints the command for creating the admin. |
What it does, and doesn't do:
- It installs the packages it needs, but doesn't upgrade the system. On the RHEL family it installs the repo's
nginx.conf, as in step 12, keeping the old one asnginx.conf.bak. On Debian and Ubuntu it keeps the distro'snginx.confand removes thesites-enabled/defaultsite, which would clash with this one. If the distro's Node.js is older than 20, it adds NodeSource's Node.js 22 repository (on RHEL 9, it switches to thenodejs:22module stream instead). - It writes
/etc/nammet/api.envonly if it doesn't exist yet. After that, edit it by hand. - It writes
/etc/nginx/conf.d/nammet.conffrom the repo's copy each time, keeping the proxy addresses and Cloudflare setting already in it. If the file had other changes, it shows them and keeps the old file asnammet.conf.bak. Ifnginx -tfails, it puts the old file back. - It only adds firewall rules if firewalld or ufw is already running. Without
--proxy, it opens port 80 to everyone and says so. - It never touches the recipes, photos, users or the admin page's settings.
Installing the site and import service by hand¶
These are the steps install.sh runs. The VM keeps its own clone of the repo and installs from it. The repo mirrors the server's layout, so each file is copied from its repo path to the same path on the server. Run everything as your normal user, which needs sudo rights. Run git commands without sudo so the clone stays yours; only the commands that install files use sudo.
4. Clone the repo¶
The repo is public, so cloning and pulling over HTTPS need no key or GitHub account. Clone it into your home directory. Never clone it into /var/www, where nginx would serve .git/ and everything else in the repo.
git clone https://github.com/foshiee/nammet-recipe-website.git ~/nammet-recipe-website
cd ~/nammet-recipe-website
The steps below run from this folder. The repo's .gitattributes makes every checkout use LF line endings, which the systemd unit and api.env need.
5. Create the service account and the site's folders¶
sudo useradd --system --home-dir /var/lib/nammet --shell /sbin/nologin nammet
sudo install -d -m 0700 -o nammet -g nammet /var/lib/nammet
sudo install -d -m 0755 /var/www/nammet /var/www/nammet/recipes
sudo install -d -m 0755 -o nammet -g nammet /var/www/nammet/theme /var/www/nammet/photos
/var/lib/nammet holds the users file and the admin page's settings, including the API key if you save one there. Recipe files go in /var/www/nammet/recipes/; see recipe-format.md. /var/www/nammet/theme is public: the service writes the site's look into it (theme.css, site.json and the icon) each time an admin saves, and once when it starts. /var/www/nammet/photos is public too and holds the recipe photos added on the site.
6. Install the website and the service code¶
sudo install -m 0644 var/www/nammet/index.html var/www/nammet/app.js var/www/nammet/style.css var/www/nammet/robots.txt /var/www/nammet/
sudo restorecon -Rv /var/www/nammet # SELinux labels
sudo cp -r opt/nammet-api /opt/nammet-api
cd /opt/nammet-api && sudo npm ci --omit=dev && cd -
The service code is copied with cp -r because install can't copy folders. npm ci installs the two dependencies (@anthropic-ai/sdk and busboy) at the exact versions in package-lock.json.
7. Let the service write recipes¶
This adds write access for nammet without changing who owns the folder, so you can still copy files in by hand:
Optional: let an SFTP user upload recipes by hand¶
The folder is owned by root, so any other account (for example loginuser, used with scp or FileZilla) can read it but not upload to it. Give that account write access the same way. The second command sets default ACLs, so files created by either loginuser or nammet stay writable by both. Without it, the import service may be unable to overwrite a recipe that was uploaded by hand.
sudo setfacl -m u:loginuser:rwx /var/www/nammet/recipes
sudo setfacl -d -m u:loginuser:rwx -m u:nammet:rwx /var/www/nammet/recipes
getfacl /var/www/nammet/recipes # check both users are listed
To upload photos by hand as well, give the same account write access to the photos folder:
sudo setfacl -m u:loginuser:rwx /var/www/nammet/photos
sudo setfacl -d -m u:loginuser:rwx -m u:nammet:rwx /var/www/nammet/photos
If uploads still fail, see Troubleshooting.
8. Configuration¶
sudo install -d -m 0750 -o root -g nammet /etc/nammet
sudo install -m 0640 -o root -g nammet etc/nammet/api.env.example /etc/nammet/api.env
sudo nano /etc/nammet/api.env # paste your key after ANTHROPIC_API_KEY=
No quotes or spaces around the key. You can also leave it empty and add the key on the admin page once the site is running, which needs no restart. A key saved on the admin page takes priority over this file. Without any key, everything except PDF, photo and text imports still works.
9. Create your login¶
This creates the first admin. Everyone else can be added from the admin page. Passwords need at least 12 characters, with a lowercase letter, an uppercase letter, a number and a symbol. Run it from /opt/nammet-api, because the nammet account can't read your home directory. The password is temporary, so you'll choose your own the first time you sign in. Add --permanent to skip that.
cd /opt/nammet-api
sudo -u nammet USERS_FILE=/var/lib/nammet/users.json node tools/add-user.js yourname --role admin
cd -
10. Start the service¶
sudo install -m 0644 etc/systemd/system/nammet-api.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now nammet-api
systemctl status nammet-api --no-pager # should say "active (running)"
curl -s http://127.0.0.1:3000/api/me # {"user":null}
The service listens only on 127.0.0.1, so no firewall port needs opening for it.
11. Let nginx connect to the service¶
SELinux stops web servers from opening network connections by default, including to the service on 127.0.0.1:3000. Without this setting the site still loads, but every /api/ request fails, so the import page says The import service isn't available on this server. nginx's error log shows connect() to 127.0.0.1:3000 failed (13: Permission denied).
sudo setsebool -P httpd_can_network_connect 1
getsebool httpd_can_network_connect # httpd_can_network_connect --> on
-P keeps the setting after a reboot. If your distro doesn't use SELinux, skip this step.
12. nginx¶
sudo install -m 0644 etc/nginx/nginx.conf /etc/nginx/nginx.conf
sudo install -m 0644 etc/nginx/conf.d/nginx-nammet.conf /etc/nginx/conf.d/nammet.conf
sudo nano /etc/nginx/conf.d/nammet.conf # set Traefik's address, uncomment the three real-IP lines
sudo nginx -t && sudo systemctl enable --now nginx && sudo systemctl reload nginx
sudo firewall-cmd --permanent --add-service=http && sudo firewall-cmd --reload # so Traefik can reach port 80
The main nginx.conf is the stock one with its sample server blocks commented out, so nammet.conf is the only site.
The address to use, and how to find it, is in the security checklist below.
13. Check it end to end¶
- Open the site in a browser over HTTPS. There should be a small Sign in link at the bottom of the home page.
- Click it and sign in. You'll be asked to choose your own password (unless you used
--permanent), then you should land on the import screen, and the home page should now show Add recipe at the top right. - Import a recipe web page, such as one from BBC Good Food or Allrecipes. The review screen should say Read from the recipe data the web page publishes.
- Save it. You should land on the new recipe, and it should be on the home page.
- Use Edit recipe on it, change something small such as the time, and save. The recipe page should show the change.
- If you set an API key, import a photo or PDF of a recipe as well. That checks the Claude path.
- Watch it all in the log:
journalctl -u nammet-api -f.
If any of this doesn't work, see Troubleshooting.
Security checklist (Traefik in front)¶
Traefik ends HTTPS, so passwords and session cookies are encrypted between the browser and your network. Check the following:
- nginx port 80 is reachable only from Traefik. Otherwise someone on the network could sign in over plain HTTP. Which address Traefik's requests come from depends on where it runs:
- Traefik on another machine: that machine's IP.
- Traefik in Docker on this VM: the Docker network's gateway, often
172.17.0.1or172.18.0.1. Check withdocker network inspect <network>. Use the subnet, such as172.18.0.0/16. - Traefik installed directly on this VM:
127.0.0.1. Nothing outside the VM needs port 80, so close it completely.
To confirm, watch sudo tail -f /var/log/nginx/access.log while loading the site. The first column is the address Traefik connects from. Use the same address in nginx's set_real_ip_from line. Then, with firewalld:
sudo firewall-cmd --permanent --remove-service=http
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="TRAEFIK_IP/32" port port="80" protocol="tcp" accept'
sudo firewall-cmd --reload
Skip the rich rule if Traefik is on the same VM. Docker publishes container ports around firewalld, but nginx's port 80 isn't a container port, so these rules do apply to it.
- Traefik redirects HTTP to HTTPS for this site's router.
- The Traefik to nginx hop stays on a trusted network, such as the same host, a Docker network or your LAN.
- The real-IP lines in the nginx config are filled in. Without them, the login rate limit and the recipe-file rate limit count all visitors as one, so failed logins from anyone lock everyone out for 15 minutes, and a busy evening can get everyone a 429.
- Behind Cloudflare, nginx trusts Cloudflare's ranges too. Otherwise the first column of
access.logshows Cloudflare addresses (such as172.70.x.x) instead of visitors, and the rate limits count per Cloudflare server. Write the ranges to a file, then uncomment theincludeline under the real-IP lines innammet.conf:
{ curl -s https://www.cloudflare.com/ips-v4; echo; curl -s https://www.cloudflare.com/ips-v6; echo; } \
| grep -E '^[0-9a-f]' | sed 's/.*/set_real_ip_from &;/' | sudo tee /etc/nginx/cloudflare-ips.inc
sudo nginx -t && sudo systemctl reload nginx
Cloudflare appends the address that connected to it, so visitors can't fake their way past this. The ranges change rarely; re-run the command every few months.
- COOKIE_SECURE=true in /etc/nammet/api.env, which is the default.
What the service does to protect itself:
- Passwords are hashed with scrypt.
- Sign-in is limited to 5 failures per user name and 10 per IP address in 15 minutes.
- Sessions use random tokens in
HttpOnly; Secure; SameSite=Strictcookies, and every POST must carry a custom header, which blocks cross-site requests. - Web-address imports refuse private, loopback and cloud-metadata addresses, including through redirects.
- Uploads are limited to 20 MB, and file types are identified from their contents, not their names.
- Photos are scaled down and re-saved as JPEG in the browser before they're uploaded, which drops the camera's metadata, such as where the photo was taken. The server then accepts only JPEG files under 5 MB, removes any metadata again, and writes them only to the photos folder under a name made from the recipe and the file's contents. It only deletes photos it named itself. A web page's photo is downloaded with the same protections as the page.
- A recipe's
imagecan only name a file in the site's own photos folder, so pages never load anything from other sites. - Recipe files are written atomically and never overwrite an existing recipe unless you confirm.
- Admin routes need the admin role, and import routes need the importer or admin role. Role changes and removals apply to open sessions straight away, and a password reset signs that user out everywhere.
- Until someone with a temporary password has chosen their own, the server refuses every import and admin request from them. Changing a password needs the current one, counts wrong attempts towards the sign-in limit, and signs out the user's other sessions.
- There's no self-service password reset, so there's nothing to abuse by guessing a user name. Only an admin, or someone with shell access to the VM, can reset a password.
- An API key saved on the admin page is stored in
/var/lib/nammet/settings.json(mode 0600, outside the web root). It is never sent back to the browser or written to the log: the admin page only shows its last four characters. The key field is masked and can't be copied out. - Only checked values reach the public
theme/folder: colours must be hex codes, and the main text, link and button colours must contrast at least 3:1 with what's behind them (the admin page warns below 4.5:1); fonts come from a fixed list, corner rounding must be a whole number of pixels from 0 to 24, and icons must be PNG, ICO or WebP (SVG uploads are refused). - systemd limits the service to writing in the recipes, photos and theme folders and
/var/lib/nammet.
Day to day¶
- Users, the site's look and the API key: use the admin page.
- From the command line (for example if you're locked out):
sudo -u nammet USERS_FILE=/var/lib/nammet/users.json node /opt/nammet-api/tools/add-user.js <name> [--role admin|importer] [--permanent]adds a user or resets a password. New users are importers unless you pass--role admin(or there's no admin yet). The password is temporary unless you pass--permanent, for example when resetting your own. Add--removeto remove a user, or use--listinstead of a name. Changes made this way don't reach sessions that are already open until they expire or the service restarts (sudo systemctl restart nammet-api). - Logs:
journalctl -u nammet-api -f. Each import logs who imported what and which method was used. Claude imports also log token usage. - Restarting signs everyone out, because sessions are kept in memory.
- Updating: in
~/nammet-recipe-website, pull the changes and run the installer again. This works for an install done by hand too.
It replaces the website and the service code (building the new code beside the old, so a failed npm ci leaves the running service alone), updates the nginx config while keeping your proxy settings, and restarts the service. Your users, settings, theme, api.env, the recipes and their photos are left as they are.
Cost of Claude imports¶
Imports use claude-sonnet-5-5 at medium effort by default. At time of writing, that model costs $2 per million input tokens and $10 per million output tokens. Most of the cost is the output, meaning the recipe itself plus the model's reasoning. Expect roughly 2 to 5 US cents per recipe, and more for a long multi-page PDF. These are estimates, so check real figures in the Anthropic console after your first few imports.
To change the model or effort, use the admin page (takes effect on the next import), or set MODEL / EFFORT in /etc/nammet/api.env and restart the service (sudo systemctl restart nammet-api). The admin page wins when both are set. claude-haiku-5-5 costs about a twentieth as much ($0.10 and $0.50 per million tokens, a fraction of a cent per recipe) and handles clear pages and typed text well; check a few drafts closely before relying on it for photos and scans. If blurry photos or messy scanned PDFs come out wrong, claude-opus-5-5 is more accurate at about twice the price of Sonnet.
Troubleshooting¶
Three logs explain most problems: the service's (journalctl -u nammet-api -e), nginx's (sudo tail /var/log/nginx/error.log) and SELinux's denials (sudo ausearch -m avc -ts recent).
- The service won't start.
systemctl status nammet-apishows the reason: status=226/NAMESPACE: a folder the service is allowed to write to doesn't exist. systemd needs all four (/var/www/nammet/recipes,/var/www/nammet/photos,/var/www/nammet/themeand/var/lib/nammet) before it will start the service. Create them as in step 5.Failed to load environment files:/etc/nammet/api.envis missing. See step 8.ERR_MODULE_NOT_FOUNDin the log: the dependencies aren't installed. Runnpm ciin/opt/nammet-apias in step 6.- The import service isn't available on this server. nginx can't reach the service.
curl -s http://127.0.0.1:3000/api/meon the VM should print{"user":null}; if it doesn't, the service isn't running. If it does, the problem is between nginx and the service.Permission deniedin nginx's error log means the SELinux setting from step 11 is off. If the error log shows nothing, nginx is probably missing the/api/block, so check that/etc/nginx/conf.d/nammet.confis the copy from step 12. - Couldn't load the recipe list. nginx isn't returning the JSON listing of the recipes folder. Opening
/recipes/on the site should show a list like[{"name":"example-recipe.json","type":"file",...}]. If it doesn't, check that thelocation /recipes/block is innammet.conf, then runsudo nginx -t && sudo systemctl reload nginx. If you see nginx's welcome page instead of the site, another server block is answering; thenginx.conffrom step 12 has the sample ones commented out. - Signing in seems to work, but you're asked to sign in again straight away. The browser isn't keeping the session cookie. With
COOKIE_SECURE=true, the default, it's only kept over HTTPS, so signing in over plain HTTP, for example by opening the VM's address directly, can't work. Use the site's HTTPS address. Don't turnCOOKIE_SECUREoff to get round it. - Too many failed sign-in attempts for everyone, or recipes failing with HTTP 429. nginx sees every visitor as coming from Traefik's address, so they all share one rate limit. Fill in the real-IP lines, and trust Cloudflare's ranges if it's in front (see the security checklist). The sign-in lockout clears after 15 minutes, or straight away when the service restarts.
- A recipe or photo you added by hand doesn't show. A recipe file nginx can't serve is listed on the home page under Some recipe files couldn't be read, with the reason:
- HTTP 403: nginx isn't allowed to read the file. A file moved in with
mvkeeps the SELinux label from where it was, while copied and uploaded files take the folder's;sudo restorecon -Rv /var/www/nammetfixes the label. The file also needs to be readable by everyone (chmod 644). - A JSON error: the file isn't valid JSON.
python3 -m json.tool file.jsonshows where.
Only files ending in .json count as recipes.
- PDF, photo and text imports fail, but web pages import. If the message says no Anthropic API key is set, add one on the admin page, or in api.env and restart the service. The server's Anthropic API key was rejected means the key is wrong or has been revoked; the admin page's Test key button checks a saved key.
- Uploads over SFTP fail.
- Write access: the account needs the ACLs from step 7's SFTP section. getfacl /var/www/nammet/recipes should list it.
- Chroot: if the account is chrooted or limited to internal-sftp in sshd_config, the chroot directory must be root-owned and not writable, so uploads must go into a writable subfolder.
- Permissions: the account needs execute (x) on every parent folder. /var/www/nammet is 0755, so this normally works.
Trying it locally¶
On your own machine, no nginx is needed. The service can serve the site too, and it emulates nginx's JSON folder listing:
cd opt/nammet-api
npm install
USERS_FILE=./dev-users.json node tools/add-user.js tester --role admin --permanent
mkdir -p ../../var/www/nammet/theme
USERS_FILE=./dev-users.json SETTINGS_FILE=./dev-settings.json THEME_DIR=../../var/www/nammet/theme \
RECIPES_DIR=../../var/www/nammet/recipes PHOTOS_DIR=../../var/www/nammet/photos \
DEV_STATIC_ROOT=../../var/www/nammet COOKIE_SECURE=false node server.js
Then open http://127.0.0.1:3000. On Windows PowerShell, set the variables with $env:USERS_FILE = "./dev-users.json" and so on. Recipes saved this way land in the repo's var/www/nammet/recipes/ folder, so delete any test recipes before committing. dev-users.json, dev-settings.json, the generated var/www/nammet/theme/ folder and photos other than the example recipe's are gitignored.
Tests for the parts that don't need Claude: npm test.