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.
| Machine | What it is for | Who can see it |
|---|---|---|
| Your laptop | Write code, run it locally, test | Only you |
| GitHub | The shared, versioned copy of the code | Your team |
| The server (Hetzner VM) | Runs the app, always on | The 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
- You type
https://tips.example.org/formin the browser. - The browser asks DNS: what is the IP address of
tips.example.org? - DNS answers with an IP address, for example
95.217.14.22. - The browser opens a connection to that IP, on port 443.
- TLS handshake: the server proves its identity, traffic is encrypted.
- The browser sends an HTTP request:
GET /form. - 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
| Method | Meaning | Typical use |
|---|---|---|
GET | Give me something | Loading a page, a search with parameters |
POST | Here is something new | Submitting a form, uploading a file |
PUT / PATCH | Change this existing thing | APIs |
DELETE | Remove this thing | APIs |
Rule of thumb: GET should never change anything. If clicking a link deletes a record, something is wrong.
Status codes
| Range | Meaning | Examples |
|---|---|---|
| 2xx | It worked | 200 OK, 201 Created |
| 3xx | Look elsewhere | 301 moved, 302 redirect |
| 4xx | You made a mistake | 400 bad request, 401 not logged in, 403 not allowed, 404 not found, 429 too many requests |
| 5xx | The server made a mistake | 500 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
- Open any website.
- Open developer tools (F12 or Ctrl+Shift+I, Cmd+Option+I on macOS).
- Go to the Network tab and reload the page.
- Look at: how many requests, their status codes, their types, their sizes.
- 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.
| Port | Usual service |
|---|---|
| 22 | SSH (remote terminal access) |
| 80 | HTTP (unencrypted web) |
| 443 | HTTPS (encrypted web) |
| 5432 | PostgreSQL |
| 3000, 5000, 8000, 8080 | Development 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.1means: accept connections only from inside this machine. - Listening on
0.0.0.0means: 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
- Open
http://localhost:8000in your browser. You are serving files. - Now try to open that address on your phone. It fails.
- 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
| Record | Meaning |
|---|---|
| A | This name points to this IPv4 address |
| AAAA | This name points to this IPv6 address |
| CNAME | This name is an alias for another name |
| MX | Mail for this domain goes here |
| TXT | Free 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
- Encryption: nobody between the browser and the server can read or change the traffic.
- 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
| Language | Job | Grammar analogy |
|---|---|---|
| HTML | Structure and content | Nouns |
| CSS | Presentation | Adjectives |
| JavaScript | Behaviour | Verbs |
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
| Component | In the restaurant |
|---|---|
| Browser | The 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 code | The recipes |
| PostgreSQL | The 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.orgto container A,dashboard.example.orgto 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
| Where | Survives a request? | Survives a restart? | Survives a redeploy? |
|---|---|---|---|
| Memory (a Python variable) | yes | no | no |
| Container filesystem | yes | usually | no |
| Volume (mounted disk) | yes | yes | yes |
| Database service | yes | yes | yes |
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?
| SQLite | PostgreSQL | |
|---|---|---|
| What it is | One file on disk | A separate running service |
| Setup | None | A container plus credentials |
| Concurrent writers | Poor | Excellent |
| Good for | Local prototypes, read-heavy tools, small archives | Anything always-on that collects data |
| Backup | Copy the file | pg_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.
.gitignoreyour.env. Commit a.env.examplewith 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 diffis 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
| Symptom | Usual cause |
|---|---|
| 502 bad gateway | The app is not running, or the port in Coolify does not match the port in the app |
| Works locally, not in the container | The app binds to 127.0.0.1 instead of 0.0.0.0 |
| Build fails | A dependency is installed on your laptop but not in requirements.txt |
| Crash on start | A required environment variable is missing |
| Data gone after deploy | Data was written inside the container, no volume |
| No certificate | DNS does not point at the VM yet, or port 80 is blocked |
| Slow, then dead | Out of RAM. Too many workers, or one huge query |
Local versus cloud, side by side
| Your laptop | The VM | |
|---|---|---|
| Uptime | Sleeps, moves, reboots | Always on |
| Address | Private, changes | Public, fixed |
| Who visits | You | Anyone, plus scanners, within hours |
| Files | Your whole life is there | Empty, only what you put there |
| Secrets | In a .env file | Injected as environment variables |
| Failure | You see the traceback in your terminal | You read the logs, later |
Same code. Completely different neighbourhood.
Part 14
Logs and debugging
Three places an error can appear
- Browser console: JavaScript errors. The page loaded but something in it broke.
- 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.
- 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 /healthreturningok) 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%splaceholders). 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
| Term | One line |
|---|---|
| localhost / 127.0.0.1 | This machine talking to itself. Not on the internet. |
| Port | Which door on a machine. 80, 443, 8000. |
| DNS | Name to IP address lookup. |
| A record | The DNS entry pointing a name at an IPv4 address. |
| TLS / HTTPS | Encryption plus proof of identity for a hostname. |
| HTTP | The request and response protocol of the web. |
| Endpoint / route | A path your app answers on, for example /submit. |
| API | A door for programs. Usually JSON over HTTP. |
| DOM | The live tree of a page inside the browser. |
| Reverse proxy | Nginx or Traefik. TLS and routing at the front door. |
| WSGI / ASGI | The plug between the web server and a Python app. |
| Gunicorn / Uvicorn | The application server that runs your Python code in worker processes. |
| Image / container | A frozen recipe, and a running instance of it. |
| Volume | Storage that survives a redeploy. |
| Environment variable | Configuration handed to the app at run time. |
| Migration | A versioned change to the database structure. |
| Repository / commit / diff | The project with its history, a snapshot, the change between snapshots. |
| Deploy | Get 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
- Twenty minutes in the terminal:
cd,ls,mkdir,cat,less,curl. Navigate to a folder, create a file, read it back. - Create a test repository on GitHub, clone it, add a file, commit, push. See it appear in the browser.
- Run
python -m http.server 8000and open it. Stop it. - Run
dig +shorton 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.