Hosting
This document describes a Git-backed hosting setup for a public Local Notion website. Notion remains the authoring source. A private sync machine downloads and renders the website, then pushes it to an Ubuntu server. A website-specific Nginx container serves the generated files behind Nginx Proxy Manager, which handles the public domain and HTTPS.
The commands form a documented example for a new server and a new website repository. Adapt them to the selected software versions and existing infrastructure. They are not a migration script for an already configured host.
Contents
1. Architecture and requirements
1.1 The three systems
| System | Responsibility | Stores |
| Notion | Website authoring | CMS rows, properties and content blocks |
| Private sync machine | Pull, render, review and Git push | Local Notion repository, integration credentials and SSH private key |
| Ubuntu web server | Receive files and serve the website | Private Git receiver, read-only serving mounts, proxy configuration and certificates |
The public request path is visitor → Nginx Proxy Manager → website Nginx container → generated files. The publication path is Notion → Local Notion on the sync machine → Git over SSH → server checkout. Nginx Proxy Manager does not pull Notion, and the web server does not need the Local Notion executable for this arrangement.
Keep the Notion integration key and the SSH private key on the sync machine. A generated registry can also contain the integration key, so the receiving repository and its history must remain private even though it serves a public website.
1.2 Example values
All addresses and names below are documentation examples. Replace them consistently before running commands.
| Example | Replace with |
example.com |
The website domain |
192.0.2.10 |
The web server's public IPv4 address |
198.51.100.25 |
The administrator's public source IP |
198.51.100.26 |
The sync machine's public source IP, if different |
YOUR_ADMIN_USER |
An existing Ubuntu administrator with sudo access |
webdeploy |
A dedicated website deployment account created below |
main |
The single deployment branch selected for this example |
example-deploy |
A local SSH connection alias |
website-proxy, example-site |
A Docker network and a unique backend alias |
The IP addresses are documentation-only ranges; they do not identify a working server. The paths /srv/nginx-proxy-manager and /srv/localnotion/example.com are example locations, not required Local Notion defaults.
Have a supported Ubuntu installation, provider-console access, a domain under your control, a working CMS, Local Notion and Git on the sync machine, and an SSH client. Use a currently supported Docker/Ubuntu combination from the official Docker installation prerequisites .
@Private Tag excludes sitemap entries; it does not deny access. Confidential pages and attachments require separately verified authorization on every relevant route. Do not place private material in this example repository on the assumption that a hidden listing protects it.
2. Prepare Ubuntu and install Docker
2.1 Establish administration and time
Web server — administrator session. Keep the provider console and the current SSH connection available while changing access rules. Test a second connection before closing the first. Use a sudo-capable administration account rather than deploying website files through a shared administrator login.
sudo apt update
sudo apt install ca-certificates curl git openssh-server ufw
sudo timedatectl set-timezone Etc/UTC
timedatectl status
Apply the operating system's security updates and schedule any required reboot. Confirm that time synchronization is working; do not replace the host's time service merely to follow an older recipe. Ubuntu documents the current time-service choices in time synchronization .
2.2 Restrict incoming access
Web server — new host example. The rules below assume SSH uses TCP 22. Substitute the real source addresses and actual SSH port before enabling the firewall.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from 198.51.100.25 to any port 22 proto tcp
sudo ufw allow from 198.51.100.26 to any port 22 proto tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose
If both computers use the same public source IP, one SSH allow rule is sufficient. Match these restrictions in the provider's firewall. Do not add a public rule for the proxy administration port or a backend website port. See Ubuntu firewall guidance .
2.3 Install Docker Engine and Compose
Web server. On a new host, use Docker's APT repository. If Docker or another container runtime is already installed, review the official conflicting-package guidance before changing it. The following adapts the current Docker Ubuntu repository procedure :
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
printf '%s\n' \
'Types: deb' \
'URIs: https://download.docker.com/linux/ubuntu' \
"Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")" \
'Components: stable' \
"Architectures: $(dpkg --print-architecture)" \
'Signed-By: /etc/apt/keyrings/docker.asc' | sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
sudo docker run --rm hello-world
sudo docker compose version
Use docker compose, the supported plugin command, rather than depending on the legacy standalone docker-compose program. See
Compose installation options
.
Keep Docker administration with trusted administrators. The deployment account below does not need membership of the docker group. Docker daemon access is a powerful host-level privilege; see
Docker's daemon security guidance
.
3. Install Nginx Proxy Manager
3.1 Create persistent storage and a shared network
Web server. The proxy's database/configuration and certificates must survive container replacement.
sudo install -d -m 0750 /srv/nginx-proxy-manager
sudo install -d -m 0750 /srv/nginx-proxy-manager/data
sudo install -d -m 0750 /srv/nginx-proxy-manager/letsencrypt
sudo docker network create website-proxy
sudoedit /srv/nginx-proxy-manager/compose.yaml
Save this Compose configuration. The image tag matches the official setup example reviewed for this document; check the selected release before installation or upgrade. SQLite is sufficient for this example, so no separate database container or copied example database password is required. The persistent paths follow Nginx Proxy Manager's setup documentation .
services:
proxy:
image: jc21/nginx-proxy-manager:2.16.0
restart: unless-stopped
environment:
TZ: Etc/UTC
ports:
- "80:80"
- "443:443"
- "127.0.0.1:81:81"
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
networks:
- website-proxy
networks:
website-proxy:
external: true
The shared Docker network lets the proxy reach website containers by a unique alias without publishing their ports. This follows NPM's Docker-network recommendation .
sudo docker compose -p edge-proxy -f /srv/nginx-proxy-manager/compose.yaml config --quiet
sudo docker compose -p edge-proxy -f /srv/nginx-proxy-manager/compose.yaml up -d
sudo docker compose -p edge-proxy -f /srv/nginx-proxy-manager/compose.yaml ps
3.2 Create the administrator through a private tunnel
Administrator computer. Open an SSH tunnel using the administrator's key:
ssh -N -L 127.0.0.1:8181:127.0.0.1:81 -i PATH_TO_ADMIN_PRIVATE_KEY [email protected]
Keep that process open, then browse to http://127.0.0.1:8181. Complete the selected release's first-user setup and use a unique administrator password. Current NPM documentation describes a first-user setup screen and optional initialization variables; this example uses the interface and does not place an administrator password in Compose. See
initial-user configuration
.
Do not assume a historical default login is valid or safe. If the selected version presents a different initial-login flow, follow its official release instructions within the tunnel. Keep port 81 bound to loopback. A loopback binding is different from publishing a port on all host interfaces; see Docker port publishing .
If NPM does not start, inspect its service status and limited error logs before changing configuration. Do not expose its database or certificate directory to resolve an administration problem.
4. Prepare SSH deployment and the Git receiver
4.1 Generate a deployment key
Sync machine. Create a dedicated SSH key and protect it with a passphrase. On Windows PowerShell:
ssh-keygen -t ed25519 -f "$env:USERPROFILE/.ssh/localnotion_example_ed25519" -C "Local Notion example deployment"
On Linux or macOS, use ~/.ssh/localnotion_example_ed25519 as the path. Keep the private file on the sync machine; transfer only the .pub file to the administrator. Load the private key through the operating system's SSH agent for interactive use. An unattended task needs a deliberately configured, protected credential session; do not remove key protection merely to suppress a prompt.
Ubuntu's OpenSSH documentation describes key authorization and SSH configuration. Verify the server's SSH host-key fingerprint through the provider console or another trusted channel before accepting its first connection.
4.2 Create an unprivileged receiving account
Web server — administrator. This example creates a dedicated account without sudo or Docker-group access:
sudo adduser --disabled-password --gecos "" webdeploy
sudo install -d -m 0700 -o webdeploy -g webdeploy /home/webdeploy/.ssh
sudoedit /home/webdeploy/.ssh/authorized_keys
sudo chown webdeploy:webdeploy /home/webdeploy/.ssh/authorized_keys
sudo chmod 0600 /home/webdeploy/.ssh/authorized_keys
Paste the deployment public key as one line in authorized_keys. Do not paste the private key. Keep the administrator's access separate.
Create a root-owned site directory and a deployment-owned checkout beneath it:
sudo install -d -m 0755 /srv/localnotion
sudo install -d -m 0750 -o root -g webdeploy /srv/localnotion/example.com
sudo install -d -m 0755 -o webdeploy -g webdeploy /srv/localnotion/example.com/repository
sudo -u webdeploy git init --initial-branch=main /srv/localnotion/example.com/repository
sudo -u webdeploy git -C /srv/localnotion/example.com/repository config --local receive.denyCurrentBranch updateInstead
sudo -u webdeploy git -C /srv/localnotion/example.com/repository config --local receive.denyNonFastForwards true
sudo -u webdeploy git -C /srv/localnotion/example.com/repository config --local receive.denyDeletes true
This is a non-bare receiving repository: its checked-out files are served. updateInstead updates the checked-out branch and normally refuses a dirty index or worktree. Use the same deployment branch on both machines, and do not edit generated website files on the receiver. The explicit local settings affect this receiver only. See
Git receiving-repository settings
.
4.3 Define the sync machine's SSH connection
Sync machine. Add this entry to the user's SSH config file ($env:USERPROFILE/.ssh/config on Windows, ~/.ssh/config elsewhere):
Host example-deploy
HostName 192.0.2.10
User webdeploy
IdentityFile ~/.ssh/localnotion_example_ed25519
IdentitiesOnly yes
Test the connection:
ssh example-deploy "git --version"
Use the same SSH client and credential session for Git and Local Notion's Git workflow. OpenSSH and PuTTY/Pageant have different key and agent arrangements. If Git already uses PuTTY, retain and verify that arrangement or configure this repository specifically; do not overwrite global SSH configuration as a generic setup step.
5. Generate and transfer the first website
5.1 Initialize on the sync machine
Sync machine — new repository only. Configure Git identity locally, then initialize Local Notion. This example enables local Git tracking but leaves automatic push disabled until the receiver has been tested.
mkdir website
git -C "./website" init --initial-branch=main
git -C "./website" config --local user.name "YOUR_COMMIT_NAME"
git -C "./website" config --local user.email "YOUR_COMMIT_EMAIL"
$notionKey = (Get-Content -LiteralPath "C:/Private/notion-key.txt" -Raw).Trim()
localnotion init --cms "YOUR_DATABASE_UUID" --profile website --path "./website" --git --nginx --override-base-url "https://example.com" --key $notionKey
Remove-Variable notionKey
localnotion pull --all --path "./website"
Replace the database ID and private key-file location. Keep the key file outside the website repository. init requires explicit --key for this recipe; it does not read NOTION_API_KEY_FILE itself. Supported later commands can use a stored key or their documented private-file lookup. Consult the
Local Notion Manual
for version-matched command options.
Check that the output contains the expected website and these generated configuration files:
website/.localnotion/nginx/conf/nginx.conf
website/.localnotion/nginx/conf/ln-urls.conf
website/.localnotion/nginx/conf/mime.types
The --nginx option generates configuration; it does not create a server, DNS record or certificate. The inspected init handler stores an operating-system-specific default reload command. Do not pass the local --nginx-reload options in this workflow: a requested local reload runs on the sync machine, while the serving Nginx in this example runs on the server.
5.2 Add the private receiver and push
Sync machine. Inspect the branch and existing remotes before adding a new remote. For this new example:
git -C ./website branch --show-current
git -C ./website status --short
git -C ./website remote add hosting example-deploy:/srv/localnotion/example.com/repository/.git
git -C ./website push --set-upstream hosting main
git -C ./website config --local remote.pushDefault hosting
git -C ./website config --local push.default simple
The first push supplies the generated files needed by the serving container. An empty receiver does not require --force or a +main refspec. If a push is rejected, resolve the actual branch, history, permissions or dirty-worktree problem. Do not use a force push as an installation repair. --set-upstream records branch tracking; the local default settings make this example's later unqualified push target explicit. See
Git push and upstream branches
.
Web server. Confirm the received branch and clean checkout without displaying credentials:
sudo -u webdeploy git -C /srv/localnotion/example.com/repository branch --show-current
sudo -u webdeploy git -C /srv/localnotion/example.com/repository status --porcelain
sudo -u webdeploy git -C /srv/localnotion/example.com/repository rev-parse HEAD
sudo test -f /srv/localnotion/example.com/repository/.localnotion/nginx/conf/ln-urls.conf
sudo test -f /srv/localnotion/example.com/repository/.localnotion/nginx/conf/mime.types
An empty status output means the receiver is clean. Compare the revision with git -C ./website rev-parse HEAD on the sync machine. Matching revisions establish transfer, not successful serving.
6. Configure the website Nginx container
6.1 Keep server policy separate from generated mappings
Web server. Keep Compose and the serving policy outside the deployment-owned repository. Mount the repository directory read-only. Mounting its parent content directory also avoids binding each generated configuration file to a separate host inode.
The public-site wrapper below uses Local Notion's generated exact-route mappings and MIME types. It permits the default public attachment and theme-asset directories, while returning 404 for other unmatched paths. The raw cache, registry, Git metadata and direct generated HTML filenames therefore have no general static-file fallback. If output paths are customized, review these locations against the actual generated URLs.
sudo install -d -m 0755 /srv/localnotion/example.com/nginx
sudoedit /srv/localnotion/example.com/nginx/nginx.conf
Save this host-owned wrapper:
worker_processes auto;
error_log /dev/stderr warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /usr/share/nginx/html/.localnotion/nginx/conf/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
sendfile on;
server {
listen 80;
server_name example.com;
root /usr/share/nginx/html;
include /usr/share/nginx/html/.localnotion/nginx/conf/ln-urls.conf;
location /files/ {
try_files $uri =404;
}
location ^~ /.localnotion/themes/ {
try_files $uri =404;
}
location / {
return 404;
}
}
}
Exact generated routes take precedence over the fallback locations. /files/ and the selected theme directory are explicitly public in this example. Do not store private files or credentials there. For protected websites, review attachment mappings and all alternate paths rather than assuming this public-site allowlist provides authorization. Nginx documents
request and location selection
.
6.2 Select an image and create the service
Web server. Pull a selected official Nginx image and record its immutable digest. This example starts from the stable Alpine channel; retain the resolved digest for reproducible recreation. Docker documents image digest pinning .
sudo docker pull nginx:stable-alpine
sudo docker image inspect nginx:stable-alpine
# Copy the nginx@sha256:... entry from RepoDigests in the JSON output.
sudoedit /srv/localnotion/example.com/.env
Copy the displayed image digest into .env, replacing the placeholder:
SITE_NGINX_IMAGE=nginx@sha256:REPLACE_WITH_THE_SELECTED_IMAGE_DIGEST
Open /srv/localnotion/example.com/compose.yaml with sudoedit and save:
services:
web:
image: ${SITE_NGINX_IMAGE:?Set SITE_NGINX_IMAGE in .env}
restart: unless-stopped
volumes:
- ./repository:/usr/share/nginx/html:ro
- ./nginx:/etc/nginx:ro
networks:
website-proxy:
aliases:
- example-site
networks:
website-proxy:
external: true
There is deliberately no ports entry for this backend. NPM reaches example-site:80 through the shared network. Use a unique alias for every site. Compose loads this example's image value from the project-directory .env file; see
Compose interpolation
.
Protect the administration files and confirm that both source directories already exist. Bind mounts expose host paths inside a container; a missing source can otherwise become an unintended directory with some mount forms. See Docker bind mounts .
sudo chown root:root /srv/localnotion/example.com/compose.yaml /srv/localnotion/example.com/.env
sudo chmod 0640 /srv/localnotion/example.com/compose.yaml /srv/localnotion/example.com/.env
sudo test -d /srv/localnotion/example.com/repository
sudo test -f /srv/localnotion/example.com/nginx/nginx.conf
sudo docker compose --project-directory /srv/localnotion/example.com -p example-site config --quiet
sudo docker compose --project-directory /srv/localnotion/example.com -p example-site run --rm --no-deps web nginx -t
sudo docker compose --project-directory /srv/localnotion/example.com -p example-site up -d
sudo docker compose --project-directory /srv/localnotion/example.com -p example-site ps
Stop if validation fails. A restart policy is not a substitute for a valid configuration. Keep the checkout root and host-owned configuration directory in place; replacing either whole mounted directory requires recreating the relevant container mount.
7. Connect the public domain and HTTPS
7.1 Set DNS
DNS provider interface. Create the domain's A record pointing to the real server IPv4 address. Add an AAAA record only if IPv6 routing and firewall access to the intended server work. Remove stale records that lead clients to another host.
Wait until authoritative and public DNS lookups return the intended values. For an HTTP certificate challenge, public port 80 must reach NPM. A DNS challenge has different requirements and can require a DNS-provider API credential; configure it according to the selected provider and NPM release. See Let's Encrypt challenge types and NPM's guide .
7.2 Add a Proxy Host
NPM interface through the SSH tunnel. Open Hosts → Proxy Hosts → Add Proxy Host. Labels can vary by release; configure these values:
| Setting | Example value |
| Domain Names | example.com |
| Scheme | http |
| Forward Hostname / IP | example-site |
| Forward Port | 80 |
| Websocket support | Enable only when the hosted application needs it; a static site normally does not |
| Cache / advanced snippets | Leave unset for the initial validated configuration |
Do not enter localhost as the backend: inside the proxy container it refers to that container. The shared-network alias identifies the website container.
Save the proxy host, verify that the route reaches the intended website, then request or select the appropriate certificate in the SSL settings. Supply the domain and a real certificate contact address in the interface. Enable HTTPS redirection only after a valid certificate is installed, and verify renewal arrangements. Do not add HSTS until HTTPS works reliably for every hostname it would cover.
If using a CDN or another proxy, verify the origin path and certificate requirements separately. A working CDN response alone does not establish which origin configuration is active.
7.3 Verify the exposed interfaces
From outside the server, check https://example.com and its images, links and downloads. NPM administration should remain reachable through the tunnel, not through the server's public port 81. The website backend has no published host port.
Confirm that requests for /.git/config, /.localnotion/registry.json, a raw object path and an unmapped physical HTML path do not disclose files. Also verify any access-protected routes with and without credentials. A successful authorized request does not prove unauthorized access is denied.
8. Publish updates and activate new mappings
8.1 Complete a manual publication cycle
Sync machine. Save the Notion edit, pull and inspect the result:
localnotion pull --all --path "./website"
git -C ./website status --short
git -C ./website push hosting main
With this example's local Git tracking enabled, Local Notion normally commits generated changes. Inspect any remaining worktree changes before pushing. Use render --all to regenerate stored content without fetching Notion edits; it does not replace a required pull.
Web server. After the push has completed and while no other publication is running, validate the complete current configuration, then reload the website service:
sudo docker compose --project-directory /srv/localnotion/example.com -p example-site run --rm --no-deps web nginx -t
sudo docker compose --project-directory /srv/localnotion/example.com -p example-site exec -T web nginx -t
sudo docker compose --project-directory /srv/localnotion/example.com -p example-site exec -T web nginx -s reload
Nginx checks a new configuration on reload and retains the old configuration if application fails. A successful nginx -t and reload still need a served-content check. See
Nginx control
and
command-line validation
.
The whole repository is updated in place in this simple arrangement. It is not an atomic, zero-downtime release system: content can change before configuration activation, and a transfer/activation failure can leave inconsistent states. Use a maintenance window when appropriate. A larger site can adopt a separately designed release-directory deployment.
8.2 Optional: a bounded server activation service
Web server — administrator. Once the manual cycle works, a root-owned oneshot service can perform the same validation and reload. Create /etc/systemd/system/localnotion-example-activate.service:
[Unit]
Description=Validate and activate the example Local Notion website
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
WorkingDirectory=/srv/localnotion/example.com
ExecStart=/usr/bin/docker compose --project-directory /srv/localnotion/example.com -p example-site run --rm --no-deps web nginx -t
ExecStart=/usr/bin/docker compose --project-directory /srv/localnotion/example.com -p example-site exec -T web nginx -t
ExecStart=/usr/bin/docker compose --project-directory /srv/localnotion/example.com -p example-site exec -T web nginx -s reload
TimeoutStartSec=120
Verify executable paths on the selected Ubuntu installation. With Type=oneshot, the start commands run in order; a failed validation prevents the reload command. This unit is invoked per publication, so it does not need a persistent file watcher or a restart loop. See the
systemd service specification
.
sudo systemd-analyze verify /etc/systemd/system/localnotion-example-activate.service
sudo systemctl daemon-reload
sudo systemctl start localnotion-example-activate.service
sudo systemctl status localnotion-example-activate.service --no-pager
A completed oneshot can be inactive after success; inspect its result and journal rather than expecting a permanently running process. Use systemctl start again for a later publication. The administrator owns the unit, Compose file, wrapper and image settings; the deployment account must not be able to modify them.
8.3 Optional: activate after a completed Git push
The following connects the receiver to the activation service. Use one publishing writer for this site. This avoids running a restart for each individual filesystem event during a multi-file checkout.
Web server — administrator. If unattended activation is required, grant only the exact service-start command. Use sudo visudo -f /etc/sudoers.d/localnotion-example-activate and add:
webdeploy ALL=(root) NOPASSWD: /usr/bin/systemctl start localnotion-example-activate.service
sudo chmod 0440 /etc/sudoers.d/localnotion-example-activate
sudo visudo -cf /etc/sudoers.d/localnotion-example-activate
sudoedit /srv/localnotion/example.com/repository/.git/hooks/post-receive
Save this hook, then make it executable:
#!/bin/sh
set -eu
activate=no
while read -r old_revision new_revision ref_name; do
if [ "$ref_name" = "refs/heads/main" ]; then
activate=yes
fi
done
if [ "$activate" = yes ]; then
sudo -n /usr/bin/systemctl start localnotion-example-activate.service
fi
sudo chmod 0755 /srv/localnotion/example.com/repository/.git/hooks/post-receive
Git runs post-receive after ref updates. Its failure does not undo the accepted push, so monitor activation failures and verify the public result. Do not interpret successful Git transfer as successful activation. See
Git post-receive semantics
.
8.4 Optional: enable Local Notion automatic push and sync
Sync machine. First verify that an ordinary noninteractive Git push reaches the correct remote and branch and that server activation succeeds. Then back up the existing .localnotion/registry.json and change only the intended Git fields:
"git": {
"enabled": true,
"push": true
}
This is an excerpt, not a replacement registry. Preserve credentials, paths, CMS registration and independent settings. git.enabled enables automatic change control; git.push additionally enables automatic pushes. Subsequent pull or render commands can therefore publish immediately.
After confirming another one-off cycle, continuous synchronization can run as:
localnotion sync --all --path "./website" --poll-frequency 30
The delay follows each completed pull; it is not a guarantee of a publication every thirty seconds. Run one Local Notion writer per repository, define how it is stopped, and use the operating system's task/service facilities if it must survive logout or reboot. Scheduled credentials and failure reporting require their own deliberate setup.
9. Verification, maintenance and recovery
9.1 Verify every boundary
| Stage | Check |
| Notion save | Reopen the row and confirm the intended blocks and properties |
| Pull | Confirm the current content reached the correct local repository |
| Render | Inspect affected routes, assets, framing and links |
| Git transfer | Compare the sync-machine revision with the clean receiver's revision |
| Activation | Confirm validation and the server-side reload result |
| Public website | Check HTTPS content, images, downloads, navigation and access restrictions |
Test both a wide and a narrow viewport. Verify a normal page, a Section route, a Gallery card, a download and a nonexistent path. Check that the public administration and cache paths remain unavailable. Use a harmless identifiable sentence for the first publishing test, then remove it in Notion and complete the cycle again.
9.2 Diagnose failures without destructive retries
| Symptom | Check first |
| SSH connection fails | Source-IP rules, SSH port, authorized public key, private-key/agent selection and trusted host-key identity |
| Push rejected | Deployment branch, receiver cleanliness, ownership and fast-forward history |
| Proxy returns 502 | Backend container state, shared network, unique alias and container port 80 |
| Certificate issuance fails | DNS, stale AAAA records, challenge method, public reachability and issuer error |
| Route or asset returns 404 | Generated mapping, configured path/base URL, transferred file and allowlisted asset location |
| New content is absent | Notion save, fetched source, rendered output, receiver revision, activation and any cache/CDN |
| Reload fails | Fresh validation against the intended files; retain the previous running configuration while diagnosing |
| Configuration appears stale | Mount source identity and contents; recreate a mount if its bound directory was replaced |
Use selected service logs without copying credentials, full registries or authorization mappings into support reports. Existing single-file bind mounts can retain old file identity after atomic host-side replacement; compare the intended host/container files before assuming a reload read the new version. The Docker engine's single-file bind-mount issue documents this distinction.
For the example activation service:
sudo journalctl -u localnotion-example-activate.service -n 50 --no-pager
sudo docker compose --project-directory /srv/localnotion/example.com -p example-site ps
Review log content before sharing it. A published website's access/error logs can also contain visitor information and private URLs.
9.3 Back up and upgrade deliberately
Back up the authoring configuration and theme overrides, the private website repository, and NPM's persistent data and letsencrypt directories. For the SQLite-based proxy example, stop the proxy or use a consistent database backup method rather than copying a live database arbitrarily. Store backups privately and test restoration on an isolated host.
Record image versions/digests and review upstream release notes before updating Docker, NPM or Nginx. Revalidate mounts, routes, authorization, certificates and renewal after an upgrade. Recreating the proxy must retain its persistent directories; restart: unless-stopped does not update an image or back up data. Follow
NPM's upgrade instructions
.
9.4 Roll back the source and the served result
Stop automatic publication before a rollback. Prefer a reviewed revert on the sync machine followed by the normal push and activation cycle. Do not force the receiving branch or discard server edits to conceal a divergence. Correct Notion as well when its current content would regenerate the unwanted change.
An activation failure may leave the previous Nginx configuration running while the checkout already contains new files. Identify the actual transfer and serving states, restore the intended deployable result, validate it and verify HTTPS before resuming synchronization. Returning to an older Git revision alone does not repair DNS, certificates, proxy state or a failed container.
10. Rebuild a staging website from scratch
An ordinary update needs a pull and render, not deletion. A clean rebuild deliberately discards the local resource cache and generated content, then fetches the CMS again. Publishing an empty website first is an additional downtime test: it proves that the previous deployed payload is unavailable before the replacement is published.
10.1 Preserve the configuration and retire the local folder
The examples use PowerShell 7 on the sync machine, a private DEPLOY_REMOTE, branch main, and https://staging.example.com. Replace these placeholders with verified staging targets. Do not reuse a production destination. Keep the recovery copy and logs outside the directory served by the website.
$site = [IO.Path]::GetFullPath('C:\Websites\staging.example.com')
$recovery = [IO.Path]::GetFullPath('C:\WebsiteRecovery\staging.example.com-before-rebuild')
$remote = 'DEPLOY_REMOTE'
$branch = 'main'
if ($site -ne 'C:\Websites\staging.example.com') { throw 'Unexpected active path' }
if (Test-Path -LiteralPath $recovery) { throw 'Preserve the existing recovery folder' }
if (-not (Test-Path -LiteralPath (Join-Path $site '.git'))) { throw 'Expected Git repository' }
New-Item -ItemType Directory -Path (Split-Path -Parent $recovery) -Force | Out-Null
Copy-Item -LiteralPath $site -Destination $recovery -Recurse
$savedRegistry = Join-Path $recovery '.localnotion\registry.json'
$activeRegistry = Join-Path $site '.localnotion\registry.json'
if ((Get-FileHash -LiteralPath $savedRegistry).Hash -ne (Get-FileHash -LiteralPath $activeRegistry).Hash) { throw 'Registry backup differs' }
if (-not (Test-Path -LiteralPath (Join-Path $recovery '.git'))) { throw 'Git history backup missing' }
Verify that the private backup is complete and restorable, including any untracked files and custom theme package. Preserve site configuration such as CMS connection, paths, Nginx settings, original Git policy and repository-specific Git configuration. The registry can contain an integration key; do not print it or attach it to a report. Once recovery is confirmed, remove the exact active folder:
if ($site -ne 'C:\Websites\staging.example.com') { throw 'Unexpected deletion target' }
Remove-Item -LiteralPath $site -Recurse -Force
Moving the whole folder to a private recovery location is an alternative when removal is unavailable. The active path must be absent before rebuilding, and the old cache must not be copied back. A backup stored inside the active folder does not satisfy this requirement.
10.2 Publish an empty payload and confirm the outage
Clone the private remote into the now-absent active path, remove its tracked payload, and publish that removal as a normal commit. This preserves the remote's history; it does not reset or force the receiving branch. Check the intended remote and branch before continuing.
git clone --branch $branch $remote $site
git -C $site remote -v
git -C $site branch --show-current
git -C $site rm -r --ignore-unmatch -- .
git -C $site status --short
git -C $site commit -m 'Temporarily empty staging website'
git -C $site push origin "HEAD:$branch"
git -C $site rev-parse HEAD
Check the receiver's revision and worktree. Git removes tracked files, but ignored or untracked files can remain. If the test requires zero non-Git files, first preserve any needed receiver-only logs or files privately, then remove the remaining payload within the verified receiver directory. The following commands run on the example web server; the path must be the actual staging checkout, with .git preserved:
site=$(realpath -e -- /srv/localnotion/staging.example.com/repository)
test "$site" = /srv/localnotion/staging.example.com/repository || exit 1
test -d "$site/.git" || exit 1
git -C "$site" rev-parse HEAD
git -C "$site" status --porcelain
find "$site" -mindepth 1 -maxdepth 1 ! -name .git -print
# After checking the listed staging payload and its private recovery copy:
find "$site" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf -- {} +
find "$site" -mindepth 1 -maxdepth 1 ! -name .git -print
From outside the server, make a bounded request and verify that the old website is unavailable. A nonzero curl exit code or HTTP 000 can mean a connection timeout; inspect the actual error and proxy/container state. If a CDN still serves the old page, bypass or purge the relevant cache and test the origin separately.
curl.exe --silent --show-error --max-time 15 --output NUL --write-out '%{http_code}' 'https://staging.example.com/'
10.3 Start again from the empty remote
Remove the temporary local empty checkout using the same exact-path guard, then clone the empty remote again. This new checkout contains Git history but no previous local cache or generated website. Restore repository-specific Git settings and verified transport/identity configuration from the private recovery copy as needed; do not overwrite another user's global Git configuration.
if ($site -ne 'C:\Websites\staging.example.com') { throw 'Unexpected deletion target' }
Remove-Item -LiteralPath $site -Recurse -Force
git clone --branch $branch $remote $site
Seed only the existing connection/configuration and reusable theme packages before opening Local Notion. Clear the resource and CMS indexes and disable both automatic Git settings. Retain the saved CMS ID, key, paths and Nginx policy in memory/file storage; no API key is passed on the command line.
$seed = Get-Content -LiteralPath $savedRegistry -Raw | ConvertFrom-Json
$savedEnabled = $seed.git.enabled.ToString().ToLowerInvariant()
$savedPush = $seed.git.push.ToString().ToLowerInvariant()
$seed.resources = @()
$seed.cms_items = @()
$seed.git.enabled = $false
$seed.git.push = $false
New-Item -ItemType Directory -Path (Join-Path $site '.localnotion') -Force | Out-Null
$json = $seed | ConvertTo-Json -Depth 100
[IO.File]::WriteAllText((Join-Path $site '.localnotion\registry.json'), $json, [Text.UTF8Encoding]::new($true))
foreach ($relative in @('.localnotion\themes', 'themes')) {
$source = Join-Path $recovery $relative
if (Test-Path -LiteralPath $source) {
Copy-Item -LiteralPath $source -Destination (Join-Path $site $relative) -Recurse
}
}
Remove-Variable seed, json
Check the custom theme package's private definitions and public asset destinations; both are required when the package uses both. Do not restore the old object cache, graphs, downloaded content, generated routes, HTML, sitemap or generated Nginx mappings. The preserved registry must describe the same active path or use valid relative paths. A UTF-8 BOM retains compatibility with older executables.
10.4 Pull the entire CMS and render it again
Check that the selected executable supports the documented options and git command. Run Local Notion commands sequentially. The existing private registry supplies the connection; the following commands contain no key argument. Keep their logs private because content or authentication details may appear in diagnostic output.
localnotion git --help
localnotion git --path $site
$pullLog = Join-Path $recovery 'fresh-pull.log'
$renderLog = Join-Path $recovery 'fresh-render.log'
localnotion pull --all --force --render true --fault-tolerant false --path $site *> $pullLog
if ($LASTEXITCODE -ne 0) { throw 'Fresh pull failed; inspect its private log' }
localnotion render --all --fault-tolerant false --path $site *> $renderLog
if ($LASTEXITCODE -ne 0) { throw 'Full render failed; inspect its private log' }
Read the complete logs locally. Confirm error totals, explain warnings and check whether resources were skipped; an exit code alone is insufficient. Verify that the fresh registry now contains the expected resources and CMS routes, and that the generated site includes its framing, documents, images, theme assets and route mappings. Inspect representative pages, section order, galleries, breadcrumbs and links at wide and narrow widths. Do not publish a partially successful pull merely because a subsequent command returned success.
10.5 Restore the Git policy and publish the replacement
Restore the original automatic Git policy only after the generated result passes review. The configuration command changes settings without making a Git commit or push. If the original policy was false/false, retain it; a rebuild is not permission to enable automatic publication permanently.
localnotion git --path $site --enabled $savedEnabled --push $savedPush
git -C $site status --short
git -C $site add --all
git -C $site diff --cached --stat
git -C $site commit -m 'Rebuild staging website from Notion'
git -C $site push origin "HEAD:$branch"
git -C $site rev-parse HEAD
Review all staged files before the commit. Keep this repository private: the registry, cached Notion objects, authorization mappings and history can contain secrets. Compare the new local revision with the receiver, confirm a clean receiver worktree and verify the required files. Use a normal fast-forward push; investigate a rejected push instead of forcing it.
10.6 Validate and activate Nginx
Restore the correct serving arrangement, including its host-owned wrapper and the newly generated mappings. Use the Compose project and service defined for this website; the example Hosting setup uses service web. Fresh validation tests the new files even if the old container stopped during the empty publication.
sudo docker compose --project-directory /srv/localnotion/staging.example.com -p example-site config --quiet
sudo docker compose --project-directory /srv/localnotion/staging.example.com -p example-site run --rm --no-deps web nginx -t
sudo docker compose --project-directory /srv/localnotion/staging.example.com -p example-site ps
Compare hashes of the intended host configuration/mappings with the files visible inside the container. Adapt the paths to the actual mounts. With this guide's directory mounts, compare the host-owned wrapper with /etc/nginx/nginx.conf, and the generated ln-urls.conf and mime.types with their paths under /usr/share/nginx/html/.localnotion/nginx/conf. Inspect hashes rather than displaying authorization mapping contents.
If a mounted source directory or an individually bound file was replaced, recreate the affected website container so it receives current bindings. Start a stopped service before testing it. Otherwise validate and reload the existing service. Apply only the action required by the verified state:
# Recreate only when stopped or mount identity requires it:
sudo docker compose --project-directory /srv/localnotion/staging.example.com -p example-site up -d --force-recreate web
# Verify the running service, then reload when appropriate:
sudo docker compose --project-directory /srv/localnotion/staging.example.com -p example-site exec -T web nginx -t
sudo docker compose --project-directory /srv/localnotion/staging.example.com -p example-site exec -T web nginx -s reload
Recheck host/container hashes after activation and inspect the intended site's reload service or watcher and logs. A receiver commit, successful fresh validation or reload does not individually prove that the running service uses the correct files.
10.7 Verify HTTPS and record the result
Check the public homepage and representative documents, images, navigation and downloads. Confirm that private paths remain unavailable and protected pages reject unauthenticated requests. Test the actual website's routes; do not assume every site has a particular article or gallery.
curl.exe --silent --show-error --max-time 15 --output NUL --write-out '%{http_code}' 'https://staging.example.com/'
curl.exe --silent --show-error --max-time 15 --output NUL --write-out '%{http_code}' 'https://staging.example.com/.git/HEAD'
curl.exe --silent --show-error --max-time 15 --output NUL --write-out '%{http_code}' 'https://staging.example.com/.localnotion/registry.json'
Record the empty-deployment revision and observed outage, fresh pull/render commands and exit/error summaries, replacement revision, receiver state, Nginx/mount checks and public results. Include sanitized commands and paths; omit API keys, credentials, raw private configuration and sensitive log lines. Resume scheduled sync only after the replacement is verified and the original Git policy is restored.