agentive-coding / prep
~90 min read

Preparation guide · how the web actually works

Agentive coding for journalists and data professionals

Read this before the training. The goal is not to make you a web developer. The goal is that when an agent writes code for you, you can tell what it built, where your data went, and why it broke.

Start reading Before the course →
Read in order Do the Try this boxes A terminal and a browser Don't memorise anything

Start here

How to use this guide

  • Read it in order. Each part depends on the one before it.
  • Do the Try this boxes. You need a terminal and a browser, nothing else.
  • Do not memorise anything. You need the vocabulary and the mental model.
  • The goal is not to make you a web developer. The goal is that when an agent writes code for you, you can tell what it built, where your data went, and why it broke.

What you should be able to answer afterwards

  • What is localhost, and why can nobody else see it?
  • What is a port, and why does the app "not listen on the right one"?
  • What does DNS do, and what did I change when I pointed my domain at the server?
  • What is the difference between Nginx, Gunicorn, and my Flask or FastAPI code?
  • Where does the data that people submit to my app actually live?
  • What does it mean to deploy, and what exactly happened when I clicked deploy?
  • Why did my data disappear after a redeploy?

The one idea that matters most

Analysis code and web applications are different kinds of program.

A script ends. A server does not.

  • An R or Python analysis: read input, compute, write output, exit. It is finished.
  • A web application: start, wait, answer a request, wait again, answer again, forever, until something stops it.

Almost everything that feels strange about web development follows from this single difference.

Consequence of "a server does not end"

Because your program is always running and always reachable:

  • It needs a machine that never sleeps. Your laptop sleeps.
  • It needs an address people can find. Your laptop has no public address.
  • It needs to survive crashes and reboots without you watching.
  • It needs to handle several people at the same time.
  • It will be found by strangers and bots, whether you announced it or not.
  • Its data has to live somewhere that survives the program restarting.

Part 1

The three machines

Nearly every confusion in deployment comes from mixing these up.

MachineWhat it is forWho can see it
Your laptopWrite code, run it locally, testOnly you
GitHubThe shared, versioned copy of the codeYour team
The server (Hetzner VM)Runs the app, always onThe whole internet

Code moves laptop, then GitHub, then server. Data usually only exists on the server.

  Your laptop            GitHub                Hetzner VM
  -----------            ------                ----------
  write code   --push--> repository  --build--> running app
  run locally            (the truth)            + database
  no real data           no data                the real data

The arrows go one way. Never edit code directly on the server. Never copy production data down to your laptop without thinking about it first.

Part 2

What happens when you open a URL

One request, seven steps

  1. You type https://tips.example.org/form in the browser.
  2. The browser asks DNS: what is the IP address of tips.example.org?
  3. DNS answers with an IP address, for example 95.217.14.22.
  4. The browser opens a connection to that IP, on port 443.
  5. TLS handshake: the server proves its identity, traffic is encrypted.
  6. The browser sends an HTTP request: GET /form.
  7. The server sends back a status code, headers, and a body (HTML). The browser renders it, then requests the CSS, JS, and images it references.

Everything in this guide is one of those seven steps.

Anatomy of a URL

https://data.example.org:443/search?q=utrecht&page=2#results
\___/   \_____________/ \_/ \_____/ \______________/ \_____/
scheme       host      port  path       query        fragment
  • scheme: which protocol (https, http)
  • host: which machine (resolved by DNS)
  • port: which door on that machine (443 is implied for https)
  • path: which resource on that machine
  • query: parameters, key=value pairs, visible and loggable
  • fragment: browser only, never sent to the server

HTTP methods

MethodMeaningTypical use
GETGive me somethingLoading a page, a search with parameters
POSTHere is something newSubmitting a form, uploading a file
PUT / PATCHChange this existing thingAPIs
DELETERemove this thingAPIs

Rule of thumb: GET should never change anything. If clicking a link deletes a record, something is wrong.

Status codes

RangeMeaningExamples
2xxIt worked200 OK, 201 Created
3xxLook elsewhere301 moved, 302 redirect
4xxYou made a mistake400 bad request, 401 not logged in, 403 not allowed, 404 not found, 429 too many requests
5xxThe server made a mistake500 crash, 502 bad gateway, 504 timeout

The one you will see most

502 means the proxy is up but your app behind it is not answering. The app crashed, or it is listening on the wrong port.

Request and response, in full

GET /form HTTP/1.1
Host: tips.example.org
Cookie: session=abc123
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8

<!doctype html>
<html>...</html>

A response is: a status line, some headers (metadata), and a body (the content). Content-Type tells the browser what the body is: text/html, application/json, image/png.

Try this · watch the requests

  1. Open any website.
  2. Open developer tools (F12 or Ctrl+Shift+I, Cmd+Option+I on macOS).
  3. Go to the Network tab and reload the page.
  4. Look at: how many requests, their status codes, their types, their sizes.
  5. Click one request and look at its headers.

You have just seen everything a browser does. There is no hidden magic layer.

Part 3

localhost, ports, and addresses

IP addresses

An IP address is the street address of a machine on a network.

  • Your Hetzner VM has a public IP, for example 95.217.14.22. Anyone on the internet can try to reach it.
  • Your laptop has a private IP on your home or office network, for example 192.168.1.42. Only your network can reach it.
  • Public IPs are scarce and cost money. Private IPs are free and invisible from outside.

localhost

localhost is the name for 127.0.0.1, which means this machine, talking to itself.

  • It never leaves your computer. It does not touch the network card.
  • When your terminal says Running on http://127.0.0.1:8000, that is a private door in your own house.
  • Your colleague cannot open it. Your phone cannot open it. It is not "on the internet" in any sense.

This is the whole reason deployment exists. Running an app is easy. Running it somewhere other people can reach is the work.

Ports

One machine, many services. A port is the apartment number at the street address.

PortUsual service
22SSH (remote terminal access)
80HTTP (unencrypted web)
443HTTPS (encrypted web)
5432PostgreSQL
3000, 5000, 8000, 8080Development servers, by convention

http://localhost:8000 means: connect to my own machine, apartment 8000, speak HTTP.

Two port errors you will meet

"Address already in use"

Something is already listening on that port. Another app, or a previous run of your own app that never stopped. Stop it, or pick another port.

"This site can't be reached" after deploying

Your app is listening on a port, but the proxy is forwarding to a different one. In Coolify you tell it which port your app listens on. If your FastAPI app runs on 8000 and Coolify is told 3000, you get a 502.

127.0.0.1 versus 0.0.0.0

This one costs people an afternoon.

  • Listening on 127.0.0.1 means: accept connections only from inside this machine.
  • Listening on 0.0.0.0 means: accept connections on all network interfaces.

Inside a Docker container, the container is its own little machine. If your app binds to 127.0.0.1, nothing outside the container can reach it, including the proxy.

In a container, always bind to 0.0.0.0.

Locally, 127.0.0.1 is fine and safer.

Try this · be a server for two minutes

In a terminal, inside any folder with files:

python -m http.server 8000
  1. Open http://localhost:8000 in your browser. You are serving files.
  2. Now try to open that address on your phone. It fails.
  3. Stop it with Ctrl+C. The site is gone.

You have just run a web server, seen why localhost is private, and seen why "always on" needs another machine.

Part 4

DNS

DNS is the phone book

Humans use names. Machines use numbers. DNS translates one into the other.

tips.example.org  ->  95.217.14.22
  • Your browser asks a resolver, the resolver asks the authoritative nameservers, the answer comes back and is cached.
  • TTL (time to live) is how long that answer may be cached. This is why a DNS change is not instant.
  • Nothing about DNS is encrypted content or secret. It is a lookup, nothing more.

The records you will touch

RecordMeaning
AThis name points to this IPv4 address
AAAAThis name points to this IPv6 address
CNAMEThis name is an alias for another name
MXMail for this domain goes here
TXTFree text, used for verification and policies

For the course you will do one thing: create an A record pointing yourapp.yourdomain.org at the public IP of your VM.

Three roles people confuse

  • Registrar: where you bought the domain name.
  • Nameserver / DNS host: where the records live and get answered. Often the registrar, but not necessarily.
  • Hosting: the machine the records point at. In our case, Hetzner.

You can move any of these without moving the others.

Try this · look it up yourself

dig +short nos.nl
nslookup nos.nl      # if dig is not installed

You get back an IP address. That is the only thing DNS did. Now try dig +short nos.nl A and dig nos.nl in full and look at the TTL.

Part 5

HTTPS and certificates

What HTTPS gives you

  1. Encryption: nobody between the browser and the server can read or change the traffic.
  2. Identity: a certificate, signed by an authority the browser trusts, proves this server is really allowed to answer for this domain name.

Certificates are free (Let's Encrypt) and issued automatically. Coolify requests and renews them for you, on one condition: the domain must already point at your server, because the certificate authority checks that by connecting to it.

Why HTTPS is not optional for journalism

  • Sources submitting to a tip form over plain HTTP can be read by their network operator.
  • Browsers actively warn users away from HTTP forms, and rightly so.
  • Modern browser features (geolocation, clipboard, service workers) refuse to work without it.

Order of operations: DNS first, then the app, then the certificate appears.

Part 6

What a web page is made of

Three languages, three jobs

LanguageJobGrammar analogy
HTMLStructure and contentNouns
CSSPresentationAdjectives
JavaScriptBehaviourVerbs

The browser downloads all three and assembles them into a page. That is all a "frontend" is.

HTML: structure

<!doctype html>
<html>
  <head>
    <title>Tip line</title>
    <link rel="stylesheet" href="/static/style.css">
  </head>
  <body>
    <h1>Send us a document</h1>
    <form method="post" action="/submit">
      <input type="text" name="subject">
      <button type="submit">Send</button>
    </form>
  </body>
</html>

Note the form: method="post", action="/submit". That is the HTTP request it will send.

CSS: presentation

body { font-family: system-ui; max-width: 40rem; margin: 2rem auto; }
h1 { color: #14324a; }
button { background: #14324a; color: white; padding: .5rem 1rem; }

CSS selects elements and gives them looks. It cannot change what the page means, only how it appears.

JavaScript: behaviour

const res = await fetch('/api/tips?limit=10');
const tips = await res.json();
document.querySelector('#list').textContent = `${tips.length} tips`;

JavaScript runs inside the browser, on the reader's machine. It can make its own HTTP requests, and it can change the page after it has loaded (the DOM, the live tree of the page in memory).

It cannot be trusted. Anyone can edit it in their own browser.

Static versus dynamic

Static site: files sit on disk, the server hands them over unchanged. Fast, cheap, almost unbreakable. Cannot store anything on its own.

Dynamic app: your code runs on every request. It can read and write a database, check a login, generate a page per user. This is what you need for a tip line, a tracker, a search over an archive.

Most of our course projects are dynamic. Some prototypes are static.

Where does the HTML get built?

Server-side rendering: the server builds the finished HTML (Flask with Jinja, FastAPI with Jinja, Django). The browser gets a complete page. Simpler, works without JavaScript, better for search engines and for readers on weak connections.

Client-side rendering: the server sends an almost empty page plus JavaScript, the browser fetches JSON and builds the page (React, Vue, Svelte). More moving parts, more that an agent can get wrong invisibly.

Default for this course: server-side, with a little JavaScript where it helps.

Part 7

Frontend, backend, and APIs

The trust boundary

  • Frontend = runs in the browser = the reader's machine = not yours.
  • Backend = runs on your server = yours.

Everything the browser sends you can be faked: form fields, hidden fields, cookies, headers. A required field in HTML is a hint, not a guarantee.

Validate on the backend. Always. No exceptions.

APIs and JSON

An API is a door for programs instead of humans. Same HTTP, but the body is data instead of a page.

{
  "tips": [
    {"id": 12, "subject": "Municipal contract", "received": "2026-07-10"},
    {"id": 13, "subject": "Housing corporation", "received": "2026-07-11"}
  ],
  "total": 2
}

JSON is: objects {} with keys and values, arrays [], strings, numbers, booleans, null. That is the whole format.

Try this · talk to an API

curl -s "https://api.github.com/repos/openstate/open-raadsinformatie" | head -40

You get JSON. No browser, no HTML, no CSS. This is what your app will consume and produce.

Part 8

The serving stack

People know they have "a Python app". Then someone says the words Gunicorn, Uvicorn, Nginx, Traefik, and it sounds like four competing things doing the same job.

They are not competing. They are a chain, and each link has one job.

The restaurant

ComponentIn the restaurant
BrowserThe guest
Nginx or Traefik (reverse proxy)The host at the door: checks the reservation, takes the coat (TLS), sends the guest to the right room
Gunicorn or Uvicorn (application server)The kitchen pass: several cooks working in parallel, one order each
Your Flask or FastAPI codeThe recipes
PostgreSQLThe pantry

Ask which link is failing, and most production problems become obvious.

The chain, drawn

Browser
   |  https://tips.example.org
   v
DNS  ->  95.217.14.22
   |
   v
VM, port 443
   |
Traefik / Nginx   (TLS, routing by hostname, static files)
   |  http://app-container:8000
   v
Gunicorn / Uvicorn   (4 worker processes)
   |
   v
Your FastAPI app     (your code, your routes)
   |
   v
PostgreSQL           (the data)

Why not just run python app.py?

The built-in development server exists to make your life easy while you write code. It is:

  • single-threaded, so one slow request blocks everyone
  • unencrypted
  • often running with debug mode on, which exposes an interactive console to the internet if left on
  • unable to restart itself when it crashes

It is perfect for localhost. Putting it on the public internet is the web equivalent of publishing your working notes.

WSGI and ASGI

A standard plug between a web server and a Python application.

  • WSGI: the older, synchronous standard. Server: Gunicorn. Frameworks: Flask, Django.
  • ASGI: the newer, asynchronous standard, also handles websockets. Server: Uvicorn (often started by Gunicorn). Framework: FastAPI.

Workers are separate processes, each able to handle one request at a time (roughly). Four workers means four requests in parallel. More workers means more RAM.

You will typically run: gunicorn -k uvicorn.workers.UvicornWorker -w 4 app:app

The reverse proxy

One machine, several apps, one public port 443. Who sorts that out? The reverse proxy.

  • Terminates TLS (one place holds the certificates)
  • Routes by hostname: tips.example.org to container A, dashboard.example.org to container B
  • Serves static files fast, without waking up Python
  • Can rate limit, add headers, block obvious abuse

Coolify runs Traefik for this and configures it from the domain you type into the interface. Nginx and Caddy do the same job elsewhere. Same link in the chain.

And if the app is JavaScript?

Same shape.

Traefik -> Node process (Express, Next.js) -> database

Node is both the runtime and the server, so there is no separate Gunicorn. The chain is one link shorter, everything else is identical.

What "always on" actually requires

A program that crashes stays dead unless something restarts it.

  • Docker restart policy (restart: unless-stopped) restarts the container when it exits or when the machine reboots.
  • systemd does the same for programs running directly on the VM.
  • Coolify manages this for you: containers come back after a crash and after a reboot.

"Always on" is not a property of your code. It is a property of the thing supervising your code.

Part 9

Where does the data live

Three places, three lifespans

WhereSurvives a request?Survives a restart?Survives a redeploy?
Memory (a Python variable)yesnono
Container filesystemyesusuallyno
Volume (mounted disk)yesyesyes
Database serviceyesyesyes

The agent will happily write submissions = [] at the top of the file and append to it. That works perfectly in testing and loses everything on the next restart.

The ephemeral filesystem trap

A container is built from an image. When you redeploy, a new container is created from a new image. Anything written inside the old container is gone.

So if your app writes uploads to /app/uploads, or keeps a SQLite file at /app/data.db, and no volume is mounted there, then:

  • everything works in the demo
  • everything works for a week
  • you deploy a small fix, and every submission is gone

Fix

Mount a persistent volume, or use a real database service. Decide this on day one, not after.

SQLite or PostgreSQL?

SQLitePostgreSQL
What it isOne file on diskA separate running service
SetupNoneA container plus credentials
Concurrent writersPoorExcellent
Good forLocal prototypes, read-heavy tools, small archivesAnything always-on that collects data
BackupCopy the filepg_dump

For a local prototype: SQLite, and enjoy it. For an always-on public app collecting data: PostgreSQL.

The five database words you need

  • Table: a sheet. Row: a record. Column: a field with a type.
  • Primary key: the unique id of a row.
  • Index: a lookup structure that makes searching a column fast. Without one, the database reads every row.
  • Query: SQL. SELECT subject FROM tips WHERE received > '2026-01-01';
  • Migration: a versioned, repeatable change to the structure of the database. Adding a column in production is a migration, not an edit.

Backups

If the data exists in exactly one place, it does not exist.

  • A dump on the same VM is not a backup. The failure mode you are protecting against usually takes the VM with it.
  • A backup you have never restored is not a backup, it is a hope.
  • For journalism: decide who can restore, from where, and what happens to the copy afterwards.

Try this · two minutes of SQL

sqlite3 test.db
CREATE TABLE tips (id INTEGER PRIMARY KEY, subject TEXT, received TEXT);
INSERT INTO tips (subject, received) VALUES ('Contract', '2026-07-10');
SELECT * FROM tips;
.quit

You now have a file called test.db holding a table. That file is exactly the kind of thing that disappears on redeploy if it lives inside a container.

Part 10

Environments and dependencies

"It works on my machine"

Your program depends on more than your code:

  • a language runtime and version (Python 3.11, Node 20)
  • packages (FastAPI, pandas, requests)
  • system libraries (image codecs, database drivers)
  • environment variables and files it expects to find

A different machine has different versions of all of these. This is not a rare edge case, it is the normal state of affairs.

Managing dependencies

Python: a virtual environment (venv, or uv), a requirements.txt or pyproject.toml, and a lockfile pinning exact versions.

Node: package.json for what you want, package-lock.json for exactly what you got. node_modules is generated, never committed.

A lockfile is the difference between "install FastAPI" and "install FastAPI 0.115.2 with these 41 dependencies at these versions". Only the second one is reproducible.

Containers

  • An image is a frozen recipe: an operating system layer, a runtime, your dependencies, your code. Immutable.
  • A container is a running instance of an image. Disposable.
  • The image is built once and runs identically on your laptop and on the VM. That is the entire point.

Docker is the tool that builds and runs them. Coolify uses Docker underneath.

A Dockerfile, read out loud

FROM python:3.12-slim              # start from a small Linux with Python
WORKDIR /app                       # work in this folder inside the image
COPY requirements.txt .            # copy the dependency list in
RUN pip install -r requirements.txt   # install them, now, at build time
COPY . .                           # copy the rest of the source in
EXPOSE 8000                        # document: this app listens on 8000
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", \
     "-b", "0.0.0.0:8000", "app:app"]   # what to run when the container starts

Note 0.0.0.0. Note that the port here must match the port you tell Coolify.

Build time versus run time

  • Build: dependencies installed, assets compiled, image produced. Happens once per deploy. Failures here mean nothing changes in production, which is good.
  • Run: the container starts, your app boots, it connects to the database, it listens. Failures here mean a 502.

When something breaks, first ask: did the build fail, or did the run fail? Coolify shows both, in separate logs.

Part 11

Configuration and secrets

Config belongs in the environment

The same code must run on your laptop and on the server, pointing at different databases, with different keys, in different modes.

import os
DATABASE_URL = os.environ["DATABASE_URL"]
DEBUG = os.environ.get("DEBUG", "false") == "true"

Locally: a .env file. In production: environment variables set in Coolify. Code in Git, configuration in the environment. Never the other way round.

Secrets

  • API keys, database passwords, session keys, tokens.
  • .gitignore your .env. Commit a .env.example with the names and no values.
  • If you commit a secret: rotate it. Deleting the commit does not help. It was published, and public repositories are scraped within minutes.
  • Never log secrets. Logs get shared, pasted, and given to an agent.

Part 12

Git and GitHub

Git in one sentence

Git is a time machine for a folder.

  • Repository: the folder, plus its entire history.
  • Commit: a labelled snapshot of the whole project at one moment.
  • Branch: a parallel line of work you can throw away.
  • Merge: bringing a branch back into the main line.

GitHub is a hosted copy of that repository, plus the collaboration features around it.

The commands you will actually use

git status                  # what have I changed?
git diff                    # exactly what did those changes do?
git add .                   # stage the changes
git commit -m "Add tip form validation"
git push                    # send to GitHub
git pull                    # get others' work
git log --oneline           # what happened here?

Everything else you can look up when you need it.

Why Git is the core skill of agentive coding

The agent will change many files at once, confidently, and quickly.

  • Commit before you let it work. A clean commit is an undo button that always works.
  • git diff is your review. It is the only place you see precisely what changed. Read it. This is the single habit that separates people who ship working apps from people who ship mysteries.
  • Commit every working state. Small commits with honest messages are a lab notebook.

What goes in Git, what does not

In: source code, templates, requirements.txt, Dockerfile, .env.example, README, migrations.

Out: .env, secrets, venv/, node_modules/, __pycache__/, build output, database files, collected data, source material, large binaries.

Write your .gitignore before your first commit, not after.

Part 13

Deployment

What deployment actually means

Deploy = put your code on a machine that is always on, install what it needs, run it, and make it reachable under a name over HTTPS.

That is the whole definition. Every deployment platform in existence is a different way of automating those five things.

The VM

A Hetzner VM is a rented Linux computer with a public IP address. You reach it with SSH:

ssh root@95.217.14.22
  • Authenticate with an SSH key, not a password. The key stays on your laptop.
  • Firewall: allow 22 (SSH), 80 (HTTP), 443 (HTTPS). Close the rest.
  • Everything else in the course happens through Coolify, in a browser. But it is worth knowing that the browser interface is only a friendly face on this machine.

What Coolify is

A self-hosted platform-as-a-service. A control panel that you own, running on your own VM. It gives you, without you writing any of it:

  • a connection to a GitHub repository
  • a build (Dockerfile, or auto-detected via Nixpacks)
  • a running container with a restart policy
  • Traefik in front of it, routing your domain and getting the TLS certificate
  • environment variable management
  • databases as one-click services
  • logs, restarts, rollbacks

The deploy loop

git push
   |
   v
Coolify notices the new commit
   |
   v
Build:  image created from your Dockerfile        (fails -> nothing changes)
   |
   v
Run:    new container started, health checked
   |
   v
Route:  Traefik points the domain at the new container
   |
   v
Old container stopped

Deployment is not a single event. It is a build step and a run step, and they fail differently.

What you configure per app

  • Repository and branch: where the code comes from
  • Build method: Dockerfile, or auto-detection
  • Port: the port your app listens on inside the container
  • Domain: the hostname Traefik should route, matching your DNS A record
  • Environment variables: database URL, keys, mode
  • Persistent storage: volumes for anything that must survive a redeploy
  • Database service: PostgreSQL, with its connection string handed to your app as an environment variable

Deployment failures, decoded

SymptomUsual cause
502 bad gatewayThe app is not running, or the port in Coolify does not match the port in the app
Works locally, not in the containerThe app binds to 127.0.0.1 instead of 0.0.0.0
Build failsA dependency is installed on your laptop but not in requirements.txt
Crash on startA required environment variable is missing
Data gone after deployData was written inside the container, no volume
No certificateDNS does not point at the VM yet, or port 80 is blocked
Slow, then deadOut of RAM. Too many workers, or one huge query

Local versus cloud, side by side

Your laptopThe VM
UptimeSleeps, moves, rebootsAlways on
AddressPrivate, changesPublic, fixed
Who visitsYouAnyone, plus scanners, within hours
FilesYour whole life is thereEmpty, only what you put there
SecretsIn a .env fileInjected as environment variables
FailureYou see the traceback in your terminalYou read the logs, later

Same code. Completely different neighbourhood.

Part 14

Logs and debugging

Three places an error can appear

  1. Browser console: JavaScript errors. The page loaded but something in it broke.
  2. Browser network tab: the HTTP status. A 404 means the route does not exist. A 500 means your server code raised an exception. A 502 means the app is not there at all.
  3. Server logs: the Python traceback, the real cause of any 500.

Look in the right place first. Most wasted debugging time is spent looking in the wrong one.

Reading a traceback

Traceback (most recent call last):
  File "/app/main.py", line 42, in submit
    save_tip(form["subject"])
  File "/app/db.py", line 18, in save_tip
    cur.execute("INSERT INTO tips (subject) VALUES (?)", (subject,))
sqlite3.OperationalError: no such table: tips

Read from the bottom. The last line is what actually went wrong. The lines above are how you got there. The top is where the request entered.

Paste the whole thing to your agent, not a summary of it.

Logs in containers

  • A containerised app logs to standard output. Docker collects it. Coolify shows it.
  • Log deliberately: what came in, what decision was taken, what went out. Not everything, and never secrets.
  • A health check endpoint (GET /health returning ok) lets the platform know whether the app is genuinely alive, rather than just started.

Part 15

Security and privacy

Your app will be attacked, immediately

Not because it is interesting. Because it is reachable. Automated scanners find new hosts within hours.

Baseline, non-negotiable:

  • SSH keys only, no password login
  • Firewall closed except 22, 80, 443
  • Debug mode off in production
  • No admin page, export route, or database interface without authentication
  • Keep the system updated

Input is hostile

  • SQL injection: never build SQL by pasting strings together. Use parameters (? or %s placeholders). Every library supports it.
  • XSS: never insert user text into HTML unescaped. Template engines escape by default. An agent turning that off "to make the formatting work" is a security incident.
  • File uploads: check type and size, store outside the web root, never execute.
  • Rate limits: a form without one is a free megaphone.

Authentication and authorisation

  • Authentication: who are you? (login, session cookie, token)
  • Authorisation: what are you allowed to do? (this user may read, that user may export)

They are separate questions and they fail separately. "I am logged in, therefore I can delete everything" is a bug in the second one.

Bots on public forms

For a tip line or a survey, expect spam within days.

  • Honeypot field: an input hidden from humans by CSS. If it is filled in, it was a bot. Cheap, invisible, effective.
  • Rate limiting per IP.
  • Timing check: a form submitted 300 milliseconds after loading was not typed.
  • A captcha is a last resort. It hurts exactly the sources you most want to reach.

Collecting data from people, as a journalist

Before you write a single line, answer these:

  • What is the minimum I need to collect? Collect that and nothing else.
  • What do I tell people I am doing with it, in plain language, on the page?
  • How long do I keep it, and what deletes it?
  • What is my lawful basis under the AVG/GDPR?
  • Where is the server, who can access it, and who can access the backups?
  • Are my logs data too? IP addresses and timestamps in a web server log can identify a source. Decide on purpose what you log and how long you keep it.

Source protection is an architecture decision, not a promise.

Part 16

The machine has limits

Resources are finite

A small VM might have 2 CPU cores, 4 GB RAM, 40 GB disk. Everything shares it: your app, its workers, the database, the proxy, Coolify itself.

htop            # CPU and memory, live
df -h           # disk space
docker stats    # per container

A form receiving 500 submissions a day is nothing. Transcribing audio, running a model, or holding a large dataframe in memory per request is not nothing. Know which one you are building.

Part 17

Working with a coding agent

What the agent is

A fast, confident, extremely well-read junior developer who has never seen your project before, remembers nothing between sessions, and will never tell you that it is out of its depth.

It will produce plausible code for anything you ask. Plausible is not the same as correct, and it is very much not the same as safe.

What actually improves the results

  • State the goal and the constraints, not just the task. "FastAPI, PostgreSQL, deployed on Coolify, must survive redeploys."
  • Work in small steps. One feature, run it, commit it, next.
  • Make it plan before it writes. Ask for the approach first, in prose. Correct the approach, then let it code.
  • Run the code yourself. Every time.
  • Read the diff. Every time.
  • Commit the working state before the next instruction.

Verification is your job, and it is the job

Questions to ask about any code an agent hands you:

  • Which routes did this add, and what do they do?
  • Where does the data go, and does it survive a redeploy?
  • What happens when this fails? What does the user see?
  • What did it install, and why?
  • What did it change that I did not ask for?

You are allowed not to know. You are not allowed not to check.

Red flags in agent output

  • A new dependency you have never heard of, to solve a small problem
  • A check, validation, or escape that was removed "to make it work"
  • Secrets or absolute paths written into the source
  • A database schema change slipped into an unrelated commit
  • debug=True, allow_origins=["*"], or a disabled certificate check
  • Code that works only because of a file that exists solely on your laptop

Give the agent a memory

Keep a short README.md or CLAUDE.md in the repository:

  • the stack and why
  • how to run it locally, in two commands
  • where it deploys and how
  • conventions: server-rendered templates, no new dependencies without asking, all data in Postgres

Point the agent at it at the start of every session. It is documentation for your colleagues and a prompt for your tools, at the same cost.

Part 18

Glossary

TermOne line
localhost / 127.0.0.1This machine talking to itself. Not on the internet.
PortWhich door on a machine. 80, 443, 8000.
DNSName to IP address lookup.
A recordThe DNS entry pointing a name at an IPv4 address.
TLS / HTTPSEncryption plus proof of identity for a hostname.
HTTPThe request and response protocol of the web.
Endpoint / routeA path your app answers on, for example /submit.
APIA door for programs. Usually JSON over HTTP.
DOMThe live tree of a page inside the browser.
Reverse proxyNginx or Traefik. TLS and routing at the front door.
WSGI / ASGIThe plug between the web server and a Python app.
Gunicorn / UvicornThe application server that runs your Python code in worker processes.
Image / containerA frozen recipe, and a running instance of it.
VolumeStorage that survives a redeploy.
Environment variableConfiguration handed to the app at run time.
MigrationA versioned change to the database structure.
Repository / commit / diffThe project with its history, a snapshot, the change between snapshots.
DeployGet the code onto an always-on machine, run it, make it reachable by name.

Before the course

Get ready

Accounts to have

Software to install

Check with:

git --version && python3 --version && node --version

Warm-up, about 30 minutes

  1. Twenty minutes in the terminal: cd, ls, mkdir, cat, less, curl. Navigate to a folder, create a file, read it back.
  2. Create a test repository on GitHub, clone it, add a file, commit, push. See it appear in the browser.
  3. Run python -m http.server 8000 and open it. Stop it.
  4. Run dig +short on a domain you own or use.

If all four worked, you are ready.

Bring a real project

The course works far better with your own material. Something like:

  • a tip line with a form, storage, and an export
  • a tracker that scrapes a source on a schedule and publishes a page
  • a search interface over a document set you already have
  • a small dashboard over a dataset you maintain
  • an internal tool your newsroom keeps asking for

Bring the idea, the data if you have it, and the question you want it to answer.

The whole model, one last time

    You                 GitHub              Hetzner VM
     |                    |          +---------------------------+
  write  --commit-->   repo  --build-->  Traefik (443, TLS)      |
  review diff                            |                       |
                                         v                       |
                                     Gunicorn / Uvicorn          |
                                         |                       |
                                         v                       |
                                     your app code               |
                                         |                       |
                                         v                       |
                                     PostgreSQL  <-- volume      |
                                         ^                       |
                                     backups                     |
                                         +---------------------------+
     ^                                              |
     +----------------- DNS ------------------------+
              tips.example.org -> 95.217.14.22

If you can point at every arrow and say what it does, you are ready to start building.