Publishing
Deploy encrypted Quarto output safely with GitHub Pages
Publishing quarto-lock
quarto-lock runs after Quarto renders. The deployment rule is therefore simple:
Render first, encrypt second, publish only the encrypted output.
The recommended GitHub layout depends on whether your Quarto source may be public.
Choose a deployment pattern
| Source repository | GitHub Pages setup | Use when |
|---|---|---|
| Public | Same repository | The .qmd source may be public |
| Private, plan supports private Pages | Same repository | You want one repository and your plan supports it |
| Private, Pages must come from a public repository | Private source → separate public output repository | The source must stay private while the encrypted site is public |
The live demo uses the third pattern:
quarto-lock-demo-source PRIVATE
Quarto source / data / scripts
|
| GitHub Actions
| quarto render
| quarto-lock
v
quarto-lock-demo PUBLIC
encrypted _site only
|
| GitHub Pages
v
https://lsbjordao.github.io/quarto-lock-demo/
Pattern A — same repository
Use this when the repository itself may be public, or when your GitHub plan supports Pages for the repository visibility you need.
1. Install the extension
From the consuming Quarto project:
quarto add lsbjordao/quarto-lockEnable it in _quarto.yml:
filters:
- quarto-lock2. Create the password secret
In the repository:
Settings → Secrets and variables → Actions → New repository secret
Create:
Name: QUARTO_LOCK_PASSWORD
Secret: your long private passphrase
Never commit the real password to _quarto.yml, .env, the workflow, or the generated site.
3. Add a Pages workflow
name: Publish locked Quarto site
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
runs-on: ubuntu-latest
env:
QUARTO_LOCK_PASSWORD: ${{ secrets.QUARTO_LOCK_PASSWORD }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
- uses: quarto-dev/quarto-actions/setup@v2
- uses: actions/configure-pages@v5
- name: Render and lock
run: quarto render
- uses: actions/upload-pages-artifact@v4
with:
path: _site
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4The important point is that actions/upload-pages-artifact receives _site after the quarto-lock post-render step has encrypted it.
Pattern B — private source → public encrypted output
This pattern is useful when the Quarto source must remain private, while GitHub Pages is served from a public repository.
The public repository is not a mirror of the source repository. It is a deployment target containing only the generated lock shell, runtime files and ciphertext.
1. Create two repositories
For example:
YOUR-USER/my-site-source Private
YOUR-USER/my-site Public
The private repository contains:
*.qmd
_quarto.yml
data/
notebooks/
scripts/
.github/workflows/
The public repository will contain generated files such as:
index.html
index.html.qlock
other-page.html
other-page.html.qlock
quarto-lock-sw.js
quarto-lock-bridge.js
.nojekyll
site_libs/
It should not contain your .qmd source, private datasets, notebooks, build scripts or password.
2. Test the locked build locally first
In the private source repository:
export QUARTO_LOCK_PASSWORD='replace-with-a-strong-passphrase'
quarto renderCheck that encrypted payloads were created:
find _site -name '*.qlock' -printServe the already-rendered directory directly:
python3 -m http.server 3073 -d _siteOpen:
http://127.0.0.1:3073/
quarto preview can perform another render and recreate clear output in _site. After locking, serve _site directly instead.
3. Create a fine-grained deployment token
The private source workflow needs permission to write the encrypted build to the public output repository.
In your GitHub account settings, not the repository settings:
Profile photo → Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token
A direct GitHub route is also available under account settings at Fine-grained personal access tokens.
Suggested token configuration:
Token name:
quarto-lock-pages-deploy
Resource owner:
YOUR-USER
Repository access:
Only select repositories
→ YOUR-PUBLIC-OUTPUT-REPO
Repository permissions:
Contents → Read and write
Everything else can remain at No access.
The token name is only a label for you. The important restriction is that the token can write to only the public output repository.
Copy the token when GitHub displays it. Treat it like a password.
4. Save the token as a secret in the private source repository
Open the private source repository:
Settings → Secrets and variables → Actions → New repository secret
Create:
Name: PAGES_DEPLOY_TOKEN
Secret: github_pat_...
Do not paste the token into the workflow itself.
5. Save the real lock password as another secret
For a real project, create:
Name: QUARTO_LOCK_PASSWORD
Secret: your long private passphrase
The live quarto-lock-demo is intentionally different: its password is public because the purpose is to let anyone exercise the unlock flow.
6. Add the deployment workflow to the private source repository
Create .github/workflows/publish.yml:
name: Publish encrypted Quarto site
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: encrypted-pages-publish
cancel-in-progress: true
jobs:
publish:
runs-on: ubuntu-latest
env:
QUARTO_LOCK_PASSWORD: ${{ secrets.QUARTO_LOCK_PASSWORD }}
steps:
- name: Check out private source
uses: actions/checkout@v6
- name: Set up Node
uses: actions/setup-node@v7
with:
node-version: "22"
- name: Set up Quarto
uses: quarto-dev/quarto-actions/setup@v2
- name: Install quarto-lock
run: quarto add --no-prompt lsbjordao/quarto-lock
- name: Render and encrypt
run: quarto render
- name: Verify encrypted output exists
shell: bash
run: |
test -f _site/index.html
test -f _site/index.html.qlock
find _site -type f -name '*.qlock' -print -quit | grep -q .
- name: Check out public output repository
uses: actions/checkout@v6
with:
repository: YOUR-USER/YOUR-PUBLIC-OUTPUT-REPO
token: ${{ secrets.PAGES_DEPLOY_TOKEN }}
path: pages-target
- name: Publish encrypted output only
shell: bash
run: |
cd pages-target
find . -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +
cp -a "$GITHUB_WORKSPACE/_site/." .
touch .nojekyll
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
if git diff --cached --quiet; then
echo "No deployment changes."
exit 0
fi
git commit -m "Deploy encrypted Quarto site"
git push origin HEAD:mainThis workflow never copies the private repository itself into the public repository. Only _site crosses the boundary.
7. Enable Pages in the public output repository
Open the public output repository:
Settings → Pages → Build and deployment
Select:
Source: Deploy from a branch
Branch: main
Folder: / (root)
Save the setting.
For a repository named YOUR-USER/my-site, the default project Pages URL is:
https://YOUR-USER.github.io/my-site/
8. Push the private source
Your normal workflow is now simply:
git add .
git commit -m "Update site"
git pushGitHub Actions performs the rest:
private source push
↓
Quarto render
↓
quarto-lock post-render
↓
encrypted _site
↓
copy only _site to public repo
↓
GitHub Pages
Optional: stronger leakage checks
For sensitive projects, make CI prove that known private markers do not survive in clear text.
For example, put a test marker in a protected source page:
MY_PRIVATE_BUILD_MARKER_12345
Then add after rendering:
- name: Fail if protected marker leaked in clear text
shell: bash
run: |
if grep -R -I -F \
--exclude='*.qlock' \
--exclude='quarto-lock-sw.js' \
--exclude='quarto-lock-bridge.js' \
'MY_PRIVATE_BUILD_MARKER_12345' _site; then
echo "Protected marker leaked into clear-text output." >&2
exit 1
fiThe live demo uses this pattern.
GitHub Actions minutes and private repositories
GitHub-hosted Actions in private repositories can be subject to the included usage and billing rules of your account plan.
If a private-repository workflow is marked failed before any step starts, check:
- Settings → Billing and licensing → Usage for Actions usage;
- whether Actions are enabled for the repository/account;
- spending limits or billing configuration;
- the workflow run’s GitHub-generated annotation.
A public repository can use standard GitHub-hosted runners without the same private-repository minute accounting. An alternative architecture is therefore to run the workflow from the public output repository and use a fine-grained read-only token to fetch the private source. The security trade-off is different: the public repository then contains a secret capable of reading the private source, so keep that token restricted to one repository and Contents: Read-only.
Updating the password
A password change is a rebuild:
- update
QUARTO_LOCK_PASSWORDin the source repository’s Actions Secret; - rerun or push the workflow;
quarto-lockgenerates a new build salt and encrypts the site again;- publish the new encrypted output.
Old browser session keys do not unlock a newly generated build.
Troubleshooting
Cannot find module ... _extensions/quarto-lock/run.mjs
Current releases install from GitHub under a namespaced extension path such as:
_extensions/lsbjordao/quarto-lock/
Update/reinstall the extension:
rm -rf _extensions/lsbjordao/quarto-lock
quarto add --no-prompt lsbjordao/quarto-locksrc refspec main does not match any
The local repository has no commit on main. Check:
git status
git branch --show-current
git log --oneline -5Create the first commit before pushing.
non-fast-forward
The remote branch already has commits. Fetch and inspect before forcing anything:
git fetch origin
git log --oneline --graph --decorate --all -10Do not use --force unless you intentionally want to replace the remote history.
The site is clear after a render
Confirm that you performed a full render with the password present:
QUARTO_LOCK_PASSWORD='...' quarto renderThen verify:
find _site -name '*.qlock' -printDo not run quarto preview afterward.
Live reference implementation
The project used to exercise this deployment pattern is:
- documentation and extension: https://lsbjordao.github.io/quarto-lock/
- encrypted live demo: https://lsbjordao.github.io/quarto-lock-demo/
- public encrypted-output repository: https://github.com/lsbjordao/quarto-lock-demo
The live demo password is intentionally public:
quarto-lock-demo
That password is for demonstration only.