Overview
Tiny Wiki is a small webserver that runs on a microcontroller and hosts a minimal Wiki system backed by markdown files. You can create and edit pages, linking them together to form a Wiki structure. This guide features a CircuitPython port of the Tiny Wiki project. It is built using the CircuitPython HTTPServer and TemplateEngine libraries. It was designed for the Adafruit Fruit Jam, but could be adapted for other CircuitPython devices with access to WiFi and a microSD card.
Page last edited February 16, 2026
Text editor powered by tinymce.
Install CircuitPython
CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.
CircuitPython Quickstart
Follow this step-by-step to quickly get CircuitPython running on your board.
Click the link above to download the latest CircuitPython UF2 file.
Save it wherever is convenient for you.
To enter the bootloader, hold down the BOOT/BOOTSEL button (highlighted in red above), and while continuing to hold it (don't let go!), press and release the reset button (highlighted in red or blue above). Continue to hold the BOOT/BOOTSEL button until the RP2350 drive appears!
If the drive does not appear, release all the buttons, and then repeat the process above.
You can also start with your board unplugged from USB, press and hold the BOOTSEL button (highlighted in red above), continue to hold it while plugging it into USB, and wait for the drive to appear before releasing the button.
A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.
You will see a new disk drive appear called RP2350.
Drag the adafruit-circuitpython-boardname-language-version.uf2 file to RP2350.
The RP2350 drive will disappear and a new disk drive called CIRCUITPY will appear.
That's it, you're done! :)
Safe Mode
You want to edit your code.py or modify the files on your CIRCUITPY drive, but find that you can't. Perhaps your board has gotten into a state where CIRCUITPY is read-only. You may have turned off the CIRCUITPY drive altogether. Whatever the reason, safe mode can help.
Safe mode in CircuitPython does not run any user code on startup, and disables auto-reload. This means a few things. First, safe mode bypasses any code in boot.py (where you can set CIRCUITPY read-only or turn it off completely). Second, it does not run the code in code.py. And finally, it does not automatically soft-reload when data is written to the CIRCUITPY drive.
Therefore, whatever you may have done to put your board in a non-interactive state, safe mode gives you the opportunity to correct it without losing all of the data on the CIRCUITPY drive.
To enter safe mode when using CircuitPython, plug in your board or hit reset (highlighted in red above). Immediately after the board starts up or resets, it waits 1000ms. On some boards, the onboard status LED (highlighted in green above) will blink yellow during that time. If you press reset during that 1000ms, the board will start up in safe mode. It can be difficult to react to the yellow LED, so you may want to think of it simply as a slow double click of the reset button. (Remember, a fast double click of reset enters the bootloader.)
In Safe Mode
If you successfully enter safe mode on CircuitPython, the LED will intermittently blink yellow three times.
If you connect to the serial console, you'll find the following message.
Auto-reload is off. Running in safe mode! Not running saved code. CircuitPython is in safe mode because you pressed the reset button during boot. Press again to exit safe mode. Press any key to enter the REPL. Use CTRL-D to reload.
You can now edit the contents of the CIRCUITPY drive. Remember, your code will not run until you press the reset button, or unplug and plug in your board, to get out of safe mode.
Flash Resetting UF2
If your board ever gets into a really weird state and CIRCUITPY doesn't show up as a disk drive after installing CircuitPython, try loading this 'nuke' UF2 to RP2350. which will do a 'deep clean' on your Flash Memory. You will lose all the files on the board, but at least you'll be able to revive it! After loading this UF2, follow the steps above to re-install CircuitPython.
Page last edited February 16, 2026
Text editor powered by tinymce.
Create Your settings.toml File
CircuitPython works with WiFi-capable boards to enable you to make projects that have network connectivity. This means working with various passwords and API keys. As of CircuitPython 8, there is support for a settings.toml file. This is a file that is stored on your CIRCUITPY drive, that contains all of your secret network information, such as your SSID, SSID password and any API keys for IoT services. It is designed to separate your sensitive information from your code.py file so you are able to share your code without sharing your credentials.
CircuitPython previously used a secrets.py file for this purpose. The settings.toml file is quite similar.
CircuitPython settings.toml File
This section will provide a couple of examples of what your settings.toml file should look like, specifically for CircuitPython WiFi projects in general.
The most minimal settings.toml file must contain your WiFi SSID and password, as that is the minimum required to connect to WiFi. Copy this example, paste it into your settings.toml, and update:
your_wifi_ssidyour_wifi_password
CIRCUITPY_WIFI_SSID = "your_wifi_ssid" CIRCUITPY_WIFI_PASSWORD = "your_wifi_password"
Many CircuitPython network-connected projects on the Adafruit Learn System involve using Adafruit IO. For these projects, you must also include your Adafruit IO username and key. Copy the following example, paste it into your settings.toml file, and update:
your_wifi_ssidyour_wifi_passwordyour_aio_usernameyour_aio_key
CIRCUITPY_WIFI_SSID = "your_wifi_ssid" CIRCUITPY_WIFI_PASSWORD = "your_wifi_password" ADAFRUIT_AIO_USERNAME = "your_aio_username" ADAFRUIT_AIO_KEY = "your_aio_key"
Some projects use different variable names for the entries in the settings.toml file. For example, a project might use ADAFRUIT_AIO_ID in the place of ADAFRUIT_AIO_USERNAME. If you run into connectivity issues, one of the first things to check is that the names in the settings.toml file match the names in the code.
Here is an example settings.toml file.
# Comments are supported CIRCUITPY_WIFI_SSID = "guest wifi" CIRCUITPY_WIFI_PASSWORD = "guessable" CIRCUITPY_WEB_API_PORT = 80 CIRCUITPY_WEB_API_PASSWORD = "passw0rd" test_variable = "this is a test" thumbs_up = "\U0001f44d"
In a settings.toml file, it's important to keep these factors in mind:
- Strings are wrapped in double quotes; ex:
"your-string-here" - Integers are not quoted and may be written in decimal with optional sign (
+1,-1,1000) or hexadecimal (0xabcd).- Floats (decimal numbers), octal (
0o567) and binary (0b11011) are not supported.
- Floats (decimal numbers), octal (
- Use
\uescapes for weird characters,\xand\oooescapes are not available in .toml files- Example:
\U0001f44dfor 👍 (thumbs up emoji) and\u20acfor € (EUR sign)
- Example:
- Unicode emoji, and non-ASCII characters, stand for themselves as long as you're careful to save in "UTF-8 without BOM" format
When your settings.toml file is ready, you can save it in your text editor with the .toml extension.
In your code.py file, you'll need to import the os library to access the settings.toml file. Your settings are accessed with the os.getenv() function. You'll pass your settings entry to the function to import it into the code.py file.
import os
print(os.getenv("test_variable"))
In the upcoming CircuitPython WiFi examples, you'll see how the settings.toml file is used for connecting to your SSID and accessing your API keys.
Page last edited February 16, 2026
Text editor powered by tinymce.
Code
Getting the Program's Files
To use the application, you need to obtain code.py with the program, and the other project files to place on the Fruit Jam CIRCUITPY drive.
Thankfully, this can be done in one go. In the example below, click the Download Project Bundle button below to download the necessary libraries, the code.py file, and other project files in a zip file.
Connect your board to your computer via a known good data+power USB cable. The board should show up in your File Explorer/Finder (depending on your operating system) as a flash drive named CIRCUITPY.
Extract the contents of the zip file, copy the lib directory files to CIRCUITPY/lib. Copy the code.py and Home.md files, as well as the tiny_wiki/ and templates/ folders to your CIRCUITPY drive. The program should self start.
Drive Structure
After copying the files, your drive should look like the listing below. It can contain other files as well, but must contain these at a minimum.
# SPDX-FileCopyrightText: 2026 Tim C, written for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""CircuitPython port of Tiny Wiki for the Fruit Jam.
This project was ported from tiny_wiki for Micropython by Kevin McAleer:
https://github.com/kevinmcaleer/tiny_wiki
This script runs on CircuitPython with the ESP32SPI WiFi coprocessor. It uses
Adafruit's HTTP Server and TemplateEngine libraries to serve a small Wiki system.
Wiki pages are stored as markdown pages on the micro SD card.
"""
import hashlib
import json
import os
import board
import busio
from digitalio import DigitalInOut
import adafruit_esp32spi
import adafruit_connection_manager
from adafruit_httpserver import Response, Redirect, Server
from adafruit_httpserver.methods import POST
from adafruit_templateengine import render_template
from tiny_wiki.markdown import SimpleMarkdown
from tiny_wiki.wiki_storage import WikiStorage
# directory for HTML template files
TEMPLATES_DIR = "/templates"
# title to show at the top of the Wiki pages
WIKI_TITLE = os.getenv("WIKI_TITLE", "CircuitPython Tiny Wiki")
# name of the auto created default page
DEFAULT_PAGE_NAME = "Home"
# location of the directory to store Wiki page markdown files
PAGES_DIR = os.getenv("WIKI_PAGES_DIR", "/sd/pages")
# port to host the webserver on
SERVER_PORT = int(os.getenv("WIKI_SERVER_PORT", "8000"))
# location of the auth data file, None / auth disabled by default
WIKI_AUTH_DATA_FILE = os.getenv("WIKI_AUTH_DATA_FILE", None)
# static salt string used when hashing passwords
PASSWORD_SALT = os.getenv("WIKI_PASSWORD_SALT", "tinywiki_salt")
# secret key used to generate authenticated session tokens
WIKI_AUTH_SECRET_KEY = os.getenv("WIKI_AUTH_SECRET_KEY", "Sup3r$ecre7")
# valid characters for random dynamic string used to generate authenticated session tokens
RANDOM_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!#$%&-_()^"
# WIFI credentials
WIFI_SSID = os.getenv("CIRCUITPY_WIFI_SSID")
WIFI_PASSWORD = os.getenv("CIRCUITPY_WIFI_PASSWORD")
if not WIFI_SSID or not WIFI_PASSWORD:
raise ValueError("Set WIFI_SSID (or CIRCUITPY_WIFI_SSID) and WIFI_PASSWORD in the environment")
# list to hold valid session tokens if auth is enabled
valid_session_tokens = []
with open("Home.md") as f:
DEFAULT_PAGE_CONTENT = f.read()
# --- Hardware and networking -------------------------------------------------
def _init_wifi():
"""Connect to WiFi via the ESP32SPI coprocessor and return the socket pool."""
spi = busio.SPI(board.SCK, board.MOSI, board.MISO)
esp32_ready = DigitalInOut(board.ESP_BUSY)
esp32_reset = DigitalInOut(board.ESP_RESET)
esp32_cs = DigitalInOut(board.ESP_CS)
_radio = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset)
print(f"Connecting to {WIFI_SSID}...")
_radio.connect(WIFI_SSID, WIFI_PASSWORD)
print("WiFi connected")
pool = adafruit_connection_manager.get_radio_socketpool(_radio)
_ssl_context = adafruit_connection_manager.get_radio_ssl_context(_radio)
connection_manager = adafruit_connection_manager.get_connection_manager(pool)
# Keep the connection manager alive to ensure sockets stay open
return _radio, pool, connection_manager
# initialize WiFi and server object
radio, socket_pool, _connection_manager = _init_wifi()
server = Server(socket_pool, debug=True)
# --- Storage & Markdown helpers ---------------------------------------------
storage = WikiStorage(PAGES_DIR)
markdown_parser = SimpleMarkdown()
# create the default first page if it doesn't exist
if not storage.page_exists(DEFAULT_PAGE_NAME):
storage.write_page(DEFAULT_PAGE_NAME, DEFAULT_PAGE_CONTENT)
# --- Helper functions -------------------------------------------------------
def _url_decode(value: str) -> str:
"""Decode URL-encoded form values (spaces, percent escapes)."""
if not value:
return value
result_chars = []
index = 0
length = len(value)
while index < length:
char = value[index]
if char == "+":
result_chars.append(" ")
index += 1
elif char == "%" and index + 2 < length:
hex_value = value[index + 1 : index + 3]
try:
result_chars.append(chr(int(hex_value, 16)))
index += 3
except ValueError:
result_chars.append(char)
index += 1
else:
result_chars.append(char)
index += 1
return "".join(result_chars)
def _url_encode(value: str, safe: str = "/") -> str:
"""Encode URL values for redirects, keeping safe characters unescaped."""
if not value:
return value
safe_chars = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~" + safe)
encoded = []
for char in value:
if char in safe_chars:
encoded.append(char)
else:
encoded.append(f"%{ord(char):02X}")
return "".join(encoded)
def _load_auth_entries():
"""Load authentication entries from the configured data file.
Decode both single object and list of objects syntax."""
if not WIKI_AUTH_DATA_FILE:
return [], "WIKI_AUTH_DATA_FILE is not configured."
try:
with open(WIKI_AUTH_DATA_FILE, "r") as auth_file:
data = json.load(auth_file)
except OSError as exc:
return [], f"Unable to read auth data file: {exc}"
except ValueError:
return [], "Auth data file is not valid JSON."
if isinstance(data, dict):
entries = [data]
elif isinstance(data, list):
entries = [entry for entry in data if isinstance(entry, dict)]
else:
entries = []
return entries, ""
def _is_authenticated(request):
"""Return True if authentication is disabled or token is valid."""
if not WIKI_AUTH_DATA_FILE:
return True
token = request.cookies.get("wiki_token", "")
return token in valid_session_tokens
# --- Routes -----------------------------------------------------------------
@server.route("/")
def homepage(request):
"""Render the configured default wiki page as the home route."""
page_name = DEFAULT_PAGE_NAME
context = {"wiki_title": WIKI_TITLE, "page_name": page_name}
if storage.page_exists(page_name):
markdown_content = storage.read_page(page_name) or ""
context["html_content"] = markdown_parser.to_html(markdown_content)
template_name = "view_page_exists.html"
else:
template_name = "view_page_missing.html"
body = render_template(f"{TEMPLATES_DIR}/{template_name}", context=context)
return Response(request, body, content_type="text/html")
@server.route("/list")
def list_pages(request):
"""Return the page listing."""
pages = storage.list_pages()
context = {
"wiki_title": WIKI_TITLE,
"page_count": len(pages),
"pages": pages,
"has_pages": bool(pages),
}
body = render_template(f"{TEMPLATES_DIR}/list_pages.html", context=context)
return Response(request, body, content_type="text/html")
@server.route("/credentials")
def credentials_form(request):
"""Render the credential hash generator form."""
context = {
"wiki_title": WIKI_TITLE,
"error_message": "",
"response_json": "",
"username": "",
}
body = render_template(f"{TEMPLATES_DIR}/credentials.html", context=context)
return Response(request, body, content_type="text/html")
@server.route("/credentials", POST)
def credentials_hash(request):
"""Hash a password with the configured salt and show the JSON output."""
form = request.form_data
username = (form.get("username", "") or "").strip()
password = (form.get("password", "") or "").strip()
error_message = ""
response_json = ""
if not username or not password:
error_message = "Username and password are required."
else:
input_data = f"{password}{PASSWORD_SALT}".encode("utf-8")
password_hash = hashlib.new("sha256", input_data).digest().hex()
response_json = json.dumps(
{"username": username, "password_hash": password_hash}
)
context = {
"wiki_title": WIKI_TITLE,
"error_message": error_message,
"response_json": response_json,
"username": username,
}
body = render_template(f"{TEMPLATES_DIR}/credentials.html", context=context)
return Response(request, body, content_type="text/html")
@server.route("/login")
def login_form(request):
"""Render the login form."""
redirect_to = _url_decode(request.query_params.get("redirect_to", "/"))
context = {
"wiki_title": WIKI_TITLE,
"error_message": "",
"info_message": "",
"username": "",
"redirect_to": redirect_to,
}
body = render_template(f"{TEMPLATES_DIR}/login.html", context=context)
return Response(request, body, content_type="text/html")
@server.route("/login", POST)
def login_submit(request):
"""Validate credentials and set session token auth cookie."""
# pylint: disable=too-many-locals
form = request.form_data
username = (form.get("username", "") or "").strip()
password = (form.get("password", "") or "").strip()
redirect_to = (form.get("redirect_to", "") or "").strip() or "/"
redirect_to = _url_decode(redirect_to)
error_message = ""
info_message = ""
response_cookies = {}
if not username or not password:
error_message = "Username and password are required."
else:
entries, load_error = _load_auth_entries()
if load_error:
error_message = load_error
else:
matching_entry = None
for entry in entries:
if entry.get("username") == username:
matching_entry = entry
break
# username not found
if not matching_entry:
error_message = "Invalid username or password."
else:
input_data = f"{password}{PASSWORD_SALT}".encode("utf-8")
password_hash = hashlib.new("sha256", input_data).digest().hex()
# username found, but incorrect password
if password_hash != matching_entry.get("password_hash"):
error_message = "Invalid username or password."
else:
# username and password correct
random_bytes = os.urandom(12)
random_str = "".join(
RANDOM_ALPHABET[byte % len(RANDOM_ALPHABET)] for byte in random_bytes
)
# combine password_hash + server secret_key + random str
token_input_data = (f"{password_hash}:)"
f"{WIKI_AUTH_SECRET_KEY}=D"
f"{random_str}").encode("utf-8")
# session token will be the hash of above str
token = hashlib.new("sha256", token_input_data).digest().hex()
valid_session_tokens.append(token)
response_cookies = {"wiki_token": token}
info_message = "Login successful."
# if successful login, redirect to specified page
if not error_message and response_cookies:
redirect_target = _url_encode(redirect_to, safe="/")
return Redirect(request, redirect_target, cookies=response_cookies)
# unsuccessful login return to login page with error message
context = {
"wiki_title": WIKI_TITLE,
"error_message": error_message,
"info_message": info_message,
"username": username,
"redirect_to": redirect_to,
}
body = render_template(f"{TEMPLATES_DIR}/login.html", context=context)
return Response(
request,
body,
content_type="text/html",
cookies=response_cookies,
)
@server.route("/new")
def new_page(request):
"""Render the "New Page" form."""
if not _is_authenticated(request):
return Redirect(request, "/login?redirect_to=new")
context = {"wiki_title": WIKI_TITLE, "error_message": ""}
body = render_template(f"{TEMPLATES_DIR}/new_page.html", context=context)
return Response(request, body, content_type="text/html")
@server.route("/create", POST)
def create_page(request):
"""Accept a page name and redirect to the editor."""
if not _is_authenticated(request):
return Redirect(request, "/login?redirect_to=new")
form = request.form_data
page_name = _url_decode((form.get("page_name", "") or "")).strip()
print(f"page_name: {page_name}")
if not page_name:
error_message = "Page name cannot be empty."
body = render_template(
f"{TEMPLATES_DIR}/new_page.html",
context={"wiki_title": WIKI_TITLE, "error_message": error_message},
)
return Response(request, body, content_type="text/html")
if storage.page_exists(page_name):
error_message = f"Page '{page_name}' already exists."
body = render_template(
f"{TEMPLATES_DIR}/new_page.html",
context={"wiki_title": WIKI_TITLE, "error_message": error_message},
)
return Response(request, body, content_type="text/html")
return Redirect(request, f"/edit/{page_name}")
@server.route("/edit/<page_name>")
def edit_page(request, page_name):
"""Render the markdown editor for the requested page."""
if not _is_authenticated(request):
return Redirect(request, f"/login?redirect_to=edit/{page_name}")
page_name = _url_decode(page_name)
existing_content = storage.read_page(page_name)
if existing_content is None:
existing_content = f"# {page_name}\n\nWrite your content here..."
context = {
"wiki_title": WIKI_TITLE,
"page_name": page_name,
"markdown_content": existing_content,
}
body = render_template(f"{TEMPLATES_DIR}/edit_page.html", context=context)
return Response(request, body, content_type="text/html")
@server.route("/save/<page_name>", POST)
def save_page(request, page_name):
"""Persist edits and redirect back to the page view."""
if not _is_authenticated(request):
return Redirect(request, f"/login?redirect_to=edit/{page_name}")
page_name = _url_decode(page_name)
raw_body = request.body.decode("utf-8")
prefix = "content="
if raw_body.startswith(prefix):
content = raw_body[len(prefix):]
else:
content = raw_body
if content.endswith("\r\n"):
content = content[:-2]
elif content.endswith("\n"):
content = content[:-1]
storage.write_page(page_name, content)
return Redirect(request, f"/wiki/{page_name}")
@server.route("/delete/<page_name>", POST)
def delete_page(request, page_name):
"""Delete the requested page and redirect back to the list."""
if not _is_authenticated(request):
return Redirect(request, f"/login?redirect_to=wiki/{page_name}")
page_name = _url_decode(page_name)
storage.delete_page(page_name)
return Redirect(request, "/list")
@server.route("/wiki/<page_name>")
def view_page(request, page_name):
"""Render an existing page or the "missing" CTA."""
page_name = _url_decode(page_name)
context = {"wiki_title": WIKI_TITLE, "page_name": page_name}
if storage.page_exists(page_name):
markdown_content = storage.read_page(page_name) or ""
context["html_content"] = markdown_parser.to_html(markdown_content)
template_name = "view_page_exists.html"
else:
template_name = "view_page_missing.html"
body = render_template(f"{TEMPLATES_DIR}/{template_name}", context=context)
return Response(request, body, content_type="text/html")
# --- Application startup ----------------------------------------------------
def main() -> None:
"""Start the HTTP server."""
print("Starting CircuitPython TinyWiki server")
print(f"Listening on http://{radio.ipv4_address}:{SERVER_PORT}")
try:
server.serve_forever(str(radio.ipv4_address), SERVER_PORT)
except KeyboardInterrupt:
print("Shutting down TinyWiki server...")
if __name__ == "__main__":
main()
Page last edited February 16, 2026
Text editor powered by tinymce.
Code Explanation
The code for this project is split into a few main components:
- code.py
- tiny_wiki/ helpers wiki_storage.py and markdown.py
- HTML template files inside of templates/
code.py
This file contains the setup for the WiFi connection and initializes the server. The configuration environment variables are read from the settings.toml file.
Helper functions _url_decode() and _url_encode() exist for encoding and decoding strings to make them safe for use in URLs. _load_auth_entries() loads login details from the configured JSON file if authentication is enabled. _is_authenticated() checks if the request contains a valid authentication token returning True or False as appropriate.
Server Endpoints
The remainder of the code.py file contains functions used to define endpoints for the Tiny Wiki server. Each endpoint represents either an HTTP GET request like fetching an HTML page to show the user in a browser, or an HTTP POST request like a form sending data back to the server when a Wiki page gets created, updated, or deleted.
These are the endpoints defined by the server:
-
/-homepage(): Shows the default Home.md getting started Wiki page. -
/list-list_pages(): Shows a list of all pages that exist in the Wiki system. -
/credentials-credentials_form(): Shows the credential hash page for generating valid authentication info. -
/credentials-credentials_hash(): Handles POST request from credential hash form. Hashes the given password and returns JSON to the front end ready to be copied into the auth JSON file. -
/login-login_form(): Shows the login form. Only needed if authentication is enabled. -
/login-login_submit(): Handles the POST request for logging in. Checks if the provided credentials are valid. Redirects user back to where the came from if so, or returns them to the login page if not. -
/new-new_page(): Shows the new Wiki page form with input for the title of the new page. -
/create-create_page(): Handles POST request from the new Wiki page form. Creates a new markdown file under the specified name and redirects the user to the edit page for it. -
/edit/-edit_page(): Shows the edit Wiki page form with text area input for the user to modify the wiki page content. -
/save/-save_page(): Handles the POST request for editing a Wiki page. Updates the content of the specified page. Redirects back to view the page. -
/delete/-delete_page(): Handles POST request from the delete button on the edit Wiki page. Deletes the specified page and returns the user to the list page. -
/wiki/-view_page(): Shows the specified Wiki page.
tiny_wiki Helpers
The tiny_wiki folder contains two Python helper modules: wiki_storage.py and markdown.py.
wiki_storage.py is responsible for initializing the micro SD card and providing an interface to read and write Wiki page files on it. It contains functions: page_exists(), read_page(), write_page(), delete_page(), and list_pages() to access and manage the .md files stored on the micro SD card.
markdown.py contains a minimal markdown syntax parser that converts markdown to HTML. When you load a Wiki page in the browser this is what translates the content from the .md file into HTML elements that your browser is capable of rendering.
Templates
All of the front end HTML pages are stored inside of the templates/ directory. These files contain the HTML that make up the pages as well as template variables which have values passed in from the server endpoint functions in code.py. When the page is rendered variable values get substituted in to the appropriate places by the TemplateEngine library.
- base.html - All other templates extend this one, meaning they'll include the content that is in this file too. It contains the core CSS style rules for the site and the header with navigation links in it.
- credentials.hml - Credential hashing page used for getting the details to put in the authentication file if you want to password protect write access to the Wiki pages.
-
edit_page.html - Form used to modify or delete Wiki pages. It contains a
textareaelement that holds the content of the Wiki page and allows editing. - list_pages.html - Shows a list of Wiki pages currently in the system.
- login.html - Username and password form used to login if authentication is enabled.
- new_page.html - Form to enter a name for a new Wiki page to be created.
- view_page_exists.html - Page shown when a Wiki page is viewed if the page exists. The converted HTML content from the markdown file gets added to the page by a template variable.
- view_page_missing.html - Page shown when request Wiki page does not exist. It informs the user the page was not found and provides a link that can be used to create it.
Page last edited February 16, 2026
Text editor powered by tinymce.
Use
Basic usage of Tiny Wiki is very straightforward. The Home page of the Wiki that gets created automatically when the server is first run contains the same usage instructions as this guide page.
- Use Edit to modify the page you are currently viewing.
- Create new articles with New Page.
- Provide links to other Wiki pages using
[[AnotherPage]]. - Browse everything via All Pages.
These configuration options are available by setting environment variables in the settings.toml file.
-
WIKI_TITLE: The title to show at the top of all pages. -
WIKI_PAGES_DIR: The directory to store page files. "/sd/pages" by default. Must be writable. -
WIKI_SERVER_PORT: The HTTP port to host the server on. Defaults to 8000 -
WIKI_AUTH_DATA_FILE: Optional path to JSON file containing valid user(s) authentication details. Defaults to None (auth disabled). -
WIKI_PASSWORD_SALT: Pepper used when hashing passwords if auth is enabled. You should change it from the default value. -
WIKI_AUTH_SECRET_KEY: Key string used when hashing values to derive session tokens if auth is enabled. You should change it from the default value.
The authentication system is optional, and disabled by default. If enabled it will require logging in with valid credentials in order to create, update, or delete Wiki pages.
To enable authentication, create a JSON file on the CIRCUITPY drive and set the path to it as the value for WIKI_AUTH_DATA_FILE environment variable.
For example in settings.toml:
WIKI_AUTH_DATA_FILE="wiki_auth.json"
Go to the Credential Hash page in your browser, enter the desired username and password, and click Generate Hash. Copy the resulting username/hash JSON object into the auth data file mentioned above. It can contain either a single entry with one object containing a username and hash, or a list of objects containing usernames and hashes. Single user example:
{"password_hash": "dbd1bdabd1c22032e7cb64217cc9d4bca7242f68bfba46aa0b9418a58138304f", "username": "wiki_user"}
Multiple user example:
[
{"password_hash": "3eb1d64cde9d156c852dab00bccf9194932ed6e540a61f2bd53fdf1c03044dac", "username": "wiki_user"},
{"password_hash": "12cdd16b8cec46aa1defc576342aaef5c0dd0773ba5c8a0c3a726c3ee56cd50f", "username": "test_user"}
]
Page last edited February 16, 2026
Text editor powered by tinymce.