🖥️

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.

ℹ️ Commands labelled Web server run in an SSH session on Ubuntu. Commands labelled Sync machine run on the computer that runs Local Notion. Notion editing and Nginx Proxy Manager administration are separate interface steps.

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 .

⚠️ This example serves public content. A CMS @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 .

⚠️ Docker-published ports are handled by Docker's networking rules and can bypass UFW filtering. Restrict port bindings in Compose and verify access externally; UFW status alone does not establish that a published container port is private. See Docker and UFW .

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.

⚠️ Treat the complete Local Notion repository as private. Its registry, cached objects, generated authorization mappings and Git history can contain secrets. Do not push this repository to a public Git service or publish its directory wholesale as a general file browser.

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 .

⚠️ Deployers in this arrangement are trusted website publishers: their repository supplies Nginx mappings as well as content. A read-only container mount does not make an untrusted configuration safe. Do not use this recipe as a multi-tenant hosting isolation boundary.

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.

ℹ️ Use this procedure only for an authorized staging outage. Stop automatic publishers and other repository writers first. Preserve a private recovery copy outside the active folder, including configuration, custom themes and Git history. The empty publication can stop Nginx when its generated include files disappear; a timeout, proxy error or 404 can be the expected outage result. Record the observed result rather than assuming a particular status.

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

ℹ️ Local Notion fetches native files uploaded to Notion. With download_external=false, external Image URLs remain external and their bytes are not downloaded into a new repository. If those URLs point to the website being emptied, the fresh pull cannot recreate the missing assets. Attach essential images to Notion, or retain independently recoverable media and theme packages with a defined restoration step.

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.