• Home
  • About

How This Website Is Built: Quarto, Netlify and the Freeze Setting

Tools & Platforms
Tools & Platforms/Quarto
Tools & Platforms/Netlify
Published

August 1, 2026

A website with code-heavy posts depends on three elements staying together: the document that holds both writing and code, the output that code produces and the page that goes online. This site ran on WordPress before, where keeping the three together was manual work. Each post was rendered locally into an HTML file, which was then uploaded to the WordPress media library. The post itself held a link to it, inserted with a plugin. What the site displayed was an uploaded artifact with no link back to the document that produced it. Refreshing a post on a schedule was harder still. WordPress only schedules PHP, so an analysis in any other language needed its own machinery outside WordPress, with the result pushed back in.

Quarto turns one .qmd document into one web page. That document holds the writing, the code and its output. A folder of such documents becomes a whole website. Netlify watches that website’s git repository. Whenever a change is pushed, it rebuilds the site from source. The page therefore always comes from the document. That is what keeps the three together. Publishing a post runs as a short chain:

write the post in its own project folder, where its data and packages live → copy it into the website repository → render it once there, which stores its output in the repository too → commit and push → Netlify remakes the whole site

Remaking the whole site is what Netlify calls a build.

Neither tool removes the manual step. Posts are still copied into the website repository by hand. What changes is what that copying moves: source files rather than rendered HTML. From there on nothing needs a further intervention, and the repository records each version.

This post explains that workflow and both kinds of post: those whose output is produced once and then reused by every build (frozen), and those whose code runs during every build to refresh their data (unfrozen).

1 Setting up the site

Setting the site up is one-time work. Everything after it is the routine of writing and publishing posts.

A folder becomes a Quarto website as soon as it holds a _quarto.yml at its root with a project block whose type is website. That file is all it takes, and the folder can be one that already exists. Every .qmd file in that folder then becomes a page of the site. For a ready-made starting point, quarto create project blog <name> creates a folder that is already a website project, with a posts/ folder, a listing page and a styles.css in place. The Quarto website reference lists every option _quarto.yml accepts.

Netlify builds the site on a Linux server, from a clone of the repository. Nothing outside that clone is available during the build. Eight files control what the clone holds and what the build does with it, and they all go in before the first push:

File What it does Covered in
_quarto.yml Makes the folder a website project and holds its settings Above
posts/_metadata.yml Sets the shared freeze default for every post Setting freeze per post
requirements.txt Lists the Python packages unfrozen posts import The requirements file
runtime.txt Names the Python version Netlify runs Pinning the Python version
netlify.toml Names the publish directory and the build command The Netlify build command
netlify-build.sh Installs Quarto, installs the packages, renders the site The Netlify build command
.gitattributes Keeps the build script’s line endings LF for Linux The Netlify build command
.gitignore Keeps _site/ and .quarto/ out of the repository Below

requirements.txt has to exist before the first push, even if no post needs a package yet. The build command installs from it on every run, so a missing file stops the build before Quarto renders anything. An empty file is enough until the first unfrozen post appears. From then on the file needs the Jupyter packages Quarto uses to run code, whether or not the post imports anything beyond the standard library.

_freeze/ must not be listed in .gitignore. Rendering generates it, like _site/ and .quarto/, so it looks like another folder to leave out. The difference is that _freeze/ holds output the build needs but cannot produce for itself.

With all eight files committed, two steps connect the folder to a live site:

  • Pushing the project to GitHub: Netlify deploys from a git repository, so the repo becomes the single source the live site is built from.
  • Importing the repo in Netlify: This links the two. Netlify watches the repo and starts a build whenever it changes. The import is done from the dashboard: Projects → “Add new project” → “Import an existing project” → the git provider, then the repository. Netlify then shows the build command and the publish directory already filled in, read from netlify.toml.

The import starts the first build. Two things decide whether it succeeds:

  • The publish directory is _site: After the build, Netlify puts one folder online and ignores the rest of the repository. The publish directory is how Netlify is told which folder that is, and Quarto renders the site into _site/.
  • Quarto is not preinstalled on Netlify: The build command has to install it before rendering.

From here on, publishing means starting a build. Every build remakes the whole site, not just the page that changed.

2 Why posts come in two kinds

Posts here cannot all be treated the same way, because the code behind them does not all belong in the same place. Most are finished work, each developed in its own project folder and in the language that suited it. Their code stays out of the build for these reasons:

  • A rerun would change nothing: The code already ran on the local machine and its output is stored in the repository, so running it again would produce the same page. Netlify counts the time a build takes against the account’s allowance, so a rerun on every push would waste it.
  • A rerun could fail: The required packages and data sit on the local machine, not in the repository.
  • R code could not run at all: Netlify does not provide R. The build command installs only Quarto and Python packages.

Some posts are the opposite. Their value is in data that has to be kept up to date, so their code has to run during every build, and everything it needs has to be available on Netlify.

freeze is the Quarto setting that decides which treatment a post gets. It controls whether the code blocks in a post are re-executed during a render, and it splits the posts here into two kinds:

Unfrozen post Frozen post
Value freeze: false freeze: true
Where it is set The post’s own header, overriding posts/_metadata.yml posts/_metadata.yml, for every post at once
When the code runs Every build, on Netlify Once, locally
What the page shows The output the code produces during that build The output stored in _freeze/
Packages on Netlify Needed, and listed in requirements.txt Not needed, they stay on the local machine

Both kinds live in the same repository and go through the same quarto render on Netlify, a command that renders the whole project. That command does two separate jobs: it turns each file into HTML, and it runs the code inside. Every post gets the first. Only freeze: false posts get the second.

2.1 Setting freeze per post

posts/_metadata.yml is a file Quarto reads for shared settings: anything set there applies to every post in that folder and its subfolders, unless a post overrides it in its own header. quarto create project blog already puts this file in place with freeze: true set.

posts/_metadata.yml:

# options specified here apply to every post in this folder and its subfolders
freeze: true

An unfrozen post overrides this freeze: true default in its own header:

---
title: "post title"
date: "2026-01-01"
freeze: false
---

Repeating freeze: true in each post’s own header is optional, and each way has its own advantage. The shared default keeps the setting in one place, where it can be changed for every post at once. Repeating it gives each post its own copy, so a wrong line in posts/_metadata.yml cannot flip them all together.

The posts here currently use the shared default. One wrong line in that file would send every post’s code to Netlify, where none of it can run, and the whole site would stop deploying.

2.2 Where _freeze/ lives and how it gets filled

_freeze/ sits at the root of the project. Frozen or unfrozen, every post that runs code stores its output there. Posts with no code store nothing under _freeze/. The path mirrors the post’s own:

website/
  posts/2026/my-post/index.qmd        ← the post
  _freeze/posts/2026/my-post/index/   ← its output, at the matching path

That output folder holds the result of the document’s code blocks as JSON (tables, printed output, plot references) plus any generated figure files, exactly as they were when the code last ran.

_freeze/ does not exist in a fresh project. Quarto creates it the first time it runs a post’s code in the website repository.

What a render does with the stored output depends on the command:

Where Command Executes the code? Effect on the stored output
Website repo quarto render posts/2026/my-post/index.qmd Yes, no matter which file sets freeze Creates it, or overwrites it if it already exists
Website repo quarto render posts/2026/my-post/index.qmd --use-freezer No Reads it, changes nothing
Website repo or Netlify quarto render Only freeze: false posts freeze: true posts read it, freeze: false posts overwrite their own record

The rule behind the table is short: a project render obeys freeze, a single-file render ignores it and executes. Quarto’s documentation states it directly: “If you do an incremental render of either a single document or a project sub-directory then code is always executed.”

A single-file render is therefore how a frozen post’s stored output gets created and refreshed. Editing the .qmd does not refresh it. The record holds the whole document, prose and headings as well as results, so with freeze: true a project render keeps showing the stored version until that one document is rendered on its own again.

Both the .qmd and its output folder have to be committed, because the build only sees what git holds. If the output is missing, the next build tries to run the code instead. The packages and the data are not there, so the render fails and the whole site stops deploying.

3 Publishing a post

A finished post is copied by hand out of its own project folder and into the website repository. The project folder keeps the working history, and the website repository holds the version that goes online.

The document is renamed to index.qmd on the way in. When a browser asks for a folder, the server sends back the index.html file inside it, so the post’s URL is just the folder, /posts/2026/my-post/. A file left as analysis.qmd would add its own name to the URL, giving /posts/2026/my-post/analysis.html.

On disk the website repository sits beside the projects it publishes from:

C:\projects\
  project-a\
    analysis.qmd                ← where the document was written
  website\                      ← the publication layer
    posts\2026\my-post\
      index.qmd                 ← the same document, renamed

3.1 Adding a frozen post

A frozen post is finished work. Its code has already run, and the website needs both the document and the output that run produced. The output is not carried across by hand. The post is rendered once after the copy, inside the website repository, because Quarto files output under a path built from the document’s location.

Adding a frozen post takes three steps:

  1. Create posts/2026/my-post/ in the website repository and copy the document in as index.qmd, with everything beside it: images, helper scripts, data files. Anything the code reads from outside that folder does not travel with it, so the code has to reach it by an absolute path. Written that way from the start, the path survives the copy and needs no edit.

  2. Activate the venv the post was developed in. Then render that one file: quarto render posts/2026/my-post/index.qmd. That render executes the code and writes what it produces:

    • The execution results and any figures land in _freeze/posts/2026/my-post/index/.
    • Any JavaScript and CSS the page needs in the browser land in _freeze/site_libs/, beside what other posts put there.
  3. git add, git commit, git push.

Two things to keep in mind:

  • The absolute path can stay in the committed file: A frozen post’s code never runs on Netlify, so that path is never read again.
  • Rendering it again replaces the stored output: That is what to do when the code has changed and the post needs a new result. At any other time it is a risk: if the data or the packages are no longer where the code expects them, the render fails, and if it succeeds it overwrites a result that was already correct. The code is edited in the original project folder, then the post is copied across again.

3.2 Adding an unfrozen post

An unfrozen post runs its code during every build, on Netlify, from a fresh clone of the repository. Everything the code needs must therefore be inside that clone. Local work on these posts happens in .venv_netlify. See Python environments: which venv for what?.

Adding an unfrozen post takes five steps:

  1. Create posts/2026/my-post/ in the website repository and copy the document in as index.qmd, with everything beside it.
  2. Add freeze: false to the post’s own header.
  3. Install any new packages into .venv_netlify, then add each one to requirements.txt with its exact version.
  4. With .venv_netlify active, run quarto render posts/2026/my-post/index.qmd to confirm the post still works from its new location. That render also leaves a record in _freeze/, which nothing uses because the code runs again on every build.
  5. git add, git commit, git push.

A local render only proves that the code runs on this machine. The build server is a different place, and everything the code touches at render time has to be reachable from there as well:

  • Packages must be listed in requirements.txt: The build installs what that file names and nothing else. See The requirements file.
  • Paths must be relative and must resolve from the post’s own folder: Quarto runs each document with its own directory as the working directory, so ../data/prices.csv reaches posts/2026/data/prices.csv in the repo. Windows is forgiving about two things that Linux is not. Use forward slashes rather than backslashes, and spell the filename exactly as it is on disk, capital letters included.
  • API keys go in Netlify, not in the repo: A .env file, the usual local place for keys, does not work on Netlify. Gitignoring it keeps it away from the build, and committing it makes the keys public. Each key is typed by hand into the dashboard under Project configuration → Environment variables. Python code then reads it from os.environ, the dictionary of environment variables a running program can see.
  • External sources must be reachable: If an API is down, or refuses the request because too many have been sent, the render fails and the deploy fails with it. Catching those errors in the code lets the page build without the missing data.

4 What the build needs

The steps above repeat for every post. What follows is set up once, for the whole site.

4.1 Python environments: which venv for what?

Projects do not share one interpreter. Which venv a project uses depends on what that project needs:

Venv Used by
C:\venvs\.venv_shared Everyday projects with ordinary dependencies
C:\venvs\.venv_netlify Every freeze: false post
C:\projects\project-a\.venv A single project with unusual or conflicting requirements

.venv_shared and the per-project venvs only matter on the local machine. .venv_netlify is the exception. An unfrozen post is written and tested in it, and requirements.txt lists the packages it holds. The build command installs from that file, so the venv and the build run the same versions as long as the file is kept up to date.

Creating .venv_netlify is one command:

py -3.14 -m venv C:\venvs\.venv_netlify   # 3.14 is the version runtime.txt names

4.2 The requirements file

Purpose Lists the Python packages that freeze: false posts need
Location Repository root
Contents One package per line with its exact version, such as pandas==2.2.3
Usage The build command installs from it during every build, before Quarto renders

Keeping it up to date: A package that is installed locally but never added to requirements.txt breaks the build. There are two ways to add it, and a third habit that keeps the file trustworthy:

  • By hand: pip show <package> reports the version pip settled on, and that name and version are typed into the file as one line.
  • By command: pip list --not-required --format=freeze --exclude pip > requirements.txt lets pip write the file. It reports what the active environment holds, so it has to be run in .venv_netlify, where those packages were installed. --not-required drops the indirect dependencies, the packages that were installed only because another package needed them. --exclude pip drops pip itself, which nothing depends on. The command replaces the file rather than adding to it.
  • By resetting: .venv_netlify can end up holding packages that were never added to requirements.txt, installed while trying something out and then forgotten. Deleting it and building it again from that file fixes that. Everything left in the venv then came from requirements.txt, so a post that stops rendering means the file is missing a package. Worth doing before an unfrozen post goes out.
Remove-Item -Recurse -Force C:\venvs\.venv_netlify
py -3.14 -m venv C:\venvs\.venv_netlify   # 3.14 is the version runtime.txt names
C:\venvs\.venv_netlify\Scripts\pip install -r requirements.txt

Other details:

  • Every line pins its package to one exact release. Without that, Netlify may install a newer version than the local venv holds.
  • pip freeze is the better-known command for this, but it lists everything in .venv_netlify, indirect dependencies included. Some of those exist only for Windows. pywinpty arrives with Jupyter, for instance, and the build would stop trying to install it on Linux.

4.3 Pinning the Python version

Without runtime.txt Netlify falls back to its own default, currently Python 3.8 and several releases behind the version the posts are written on. Pinning keeps the build on the same Python as the local venv.

Purpose Names the Python version Netlify runs
Location Repository root, in a file called runtime.txt
Contents One line with the version, written as x.y and with no line break at the end. 3.14 selects the current release of Python 3.14.
Usage Only Netlify reads it. Changing the version here does not touch .venv_netlify, which stays on the Python it was created with until it is deleted and built again with the new one.

Other details:

  • .venv_shared and the per-project venvs never run anything on Netlify, so their Python versions do not matter.
  • The build log shows which Python version Netlify used. Checking it after the first build confirms runtime.txt was read.
  • Netlify looks for the version in more than one place and stops at the first it finds. runtime.txt is that first place, so nothing else overrides it, not even a PYTHON_VERSION environment variable set in the dashboard.

4.4 The Netlify build command

Purpose Tells Netlify what to do with the repository after cloning it
Location Repository root, in netlify.toml and netlify-build.sh
Contents netlify.toml holds the settings, netlify-build.sh holds the steps. Both are shown below.
Usage Nothing is run by hand. Netlify reads netlify.toml at the start of every build and runs the script from the root of the clone.

Other details:

  • The settings in netlify.toml can be entered in the Netlify dashboard instead, but only the file is version controlled. If a setting is defined in both places, netlify.toml is the one Netlify uses.
  • Quarto is unpacked into the home directory, not the project. quarto render walks the project folder, and Quarto’s own installation carries .qmd files of its own that would fail to render.
  • The Quarto version is pinned, so a new Quarto release cannot change how the site builds. Moving to a newer one means editing netlify-build.sh.
  • The script runs under bash on Linux, so it has to reach the build with Unix line endings (LF). A Windows editor writing CRLF into it breaks the build with an error that says nothing about line endings. One line in a .gitattributes file at the repository root keeps that from happening in any editor: *.sh text eol=lf.


netlify.toml:

[build]
  publish = "_site"
  command = "bash netlify-build.sh"


netlify-build.sh:

set -e   # stop this script at the first command that fails

QUARTO_VERSION="1.8.26"

# curl downloads the release archive and pipes it into tar, which unpacks it
# -f makes curl fail on an HTTP error instead of saving the error page
curl -fsSL "https://github.com/quarto-dev/quarto-cli/releases/download/v${QUARTO_VERSION}/quarto-${QUARTO_VERSION}-linux-amd64.tar.gz" | tar -xz -C "$HOME"

# Add quarto to PATH for the rest of this script, so the render step can find it
export PATH="$HOME/quarto-${QUARTO_VERSION}/bin:$PATH"

# Packages that freeze: false posts import
pip install -r requirements.txt

quarto render

Why netlify-build.sh is a separate file

command is what Netlify executes, and it could hold those steps directly instead of naming a script. TOML accepts a multi-line string. Two things make the separate file better.

  • The Quarto version appears three times: QUARTO_VERSION is set once and read twice in the download URL and once in PATH. Moving to a new Quarto version is then one edit rather than three. Missing one of the three would end in quarto: command not found, which says nothing about versions.
  • The file stands on its own: It reads as an ordinary shell script, and a change to one step shows in git as a one-line diff rather than a rewrite of the whole command string.

5 How a build runs

A build is the only way anything reaches the live site. Every build runs the same sequence:

  1. Netlify starts the build on a clean server.
  2. It copies the repository onto that server, at the commit that triggered the build.
  3. It reads netlify.toml and runs the script named there.
  4. It uploads the publish directory to the live site. That upload is a deploy.

Every step is written to the build log, which Netlify keeps under Deploys → the deploy in question. It is the first place to look when a build fails.

5.1 What can start a build

A build starts in one of three ways:

Trigger Description
Git push The main method. A push to main starts a build, because continuous deployment is on by default. Turning it off under Project configuration → Build & deploy → Continuous deployment stops that, and every deploy then has to be started by hand.
Manual Netlify dashboard → “Trigger deploy”
Build hook A URL Netlify generates for the site, which starts a build when something sends a request to it. This is how a build can be started without a git push, by an external scheduler for instance. Scheduling builds that way is a subject for another post.

Eight files and one setting

The setup is eight files, written once before the first push. After that the only recurring decision is freeze, which says whether a post’s code runs again on Netlify. Either way the post is copied in, rendered once, then committed and pushed.

Built with Quarto and Netlify