This is a small personal website written in Rust. It has a public homepage,
a project archive, and a private admin area at /admin.
The public site uses server rendered HTML. The admin area stores content as Markdown and uses a local copy of EasyMDE for editing. Images, footer links, site metadata, and project publishing can all be managed from the admin area.
Install Rust, then run:
ADMIN_PASSWORD=choose-a-long-password cargo runOpen http://127.0.0.1:3000.
The site creates data/site.db and data/uploads/. The data directory is
ignored by Git because it contains local content.
Create your local environment file:
cp .env.example .envSet a long random ADMIN_PASSWORD, then start the site:
docker compose up --build -dDocker stores the SQLite database and uploaded images in the site-data
volume. The container health check uses /healthz.
The local Compose file has a development-only password fallback so commands
such as docker compose ps and docker compose down keep working if .env is
missing. Set your own password before using the admin area. The production
Compose file always requires an explicit password.
Run the complete local verification suite with:
scripts/verify.shIt checks formatting, parses the browser scripts, runs cargo check, runs the
Rust tests, and starts a temporary server for HTTP smoke tests.
The smoke tests cover health checks, security headers, search metadata,
robots.txt, sitemap.xml, origin checks, admin login, settings changes,
project publishing, Markdown rendering, image uploads, and local editor assets.
Project bodies are Markdown. A project can also start with a small metadata block. The metadata is removed from the rendered Markdown body and shown as badges and links near the project heading.
---
date: 2026
status: Active
tech: Rust, Axum, SQLite
source: https://github.com/example/project
live: https://example.com
docs: https://example.com/docs
---
Project notes start here.Supported keys are date, status, tech, source, repo, live,
demo, and docs. tech is a comma separated list. External link fields
must start with http:// or https://.
The Rust server starts in src/main.rs. Startup and router assembly live
there, while handlers and shared pieces are split into focused modules:
| Path | Purpose |
|---|---|
src/db.rs |
SQLite setup, reads, and writes |
src/models.rs |
Shared state, records, and error type |
src/render.rs |
Markdown rendering, page layout, icons, and error pages |
src/routes.rs |
Public and admin route handlers |
src/security.rs |
Security headers, origin checks, and admin-cookie checks |
src/template.rs |
Small placeholder renderer for template files |
src/uploads.rs |
Upload validation, storage, and cleanup |
src/utils.rs |
Slugs, escaping, upload validation, and other helpers |
templates/ |
HTML templates used by the server |
GitHub Actions runs the verification suite for pull requests and pushes to
main. The workflow is stored in .github/workflows/ci.yaml.
The deployment workflow is stored in .github/workflows/deploy.yaml. After
verification passes, it builds one Docker image, pushes a commit tag and a
latest tag to GHCR, uploads the Compose file over SSH, pulls the new image on
the server, recreates the service, and checks /healthz.
Configure these GitHub Actions secrets:
| Secret | Purpose |
|---|---|
DEPLOY_SSH_HOST |
Server hostname or IP address |
DEPLOY_SSH_PORT |
SSH port, usually 22 |
DEPLOY_SSH_USER |
SSH user allowed to run Docker Compose |
DEPLOY_SSH_KEY |
Private SSH key for deployment |
DEPLOY_KNOWN_HOSTS |
Output from ssh-keyscan for the server |
Configure these GitHub Actions variables:
| Variable | Purpose |
|---|---|
DEPLOY_PATH |
Server directory for the Compose files, such as /opt/mysite |
DEPLOY_HEALTH_URL |
Public site URL used after deployment |
Create .env inside DEPLOY_PATH on the server before the first deployment.
For example, use /opt/mysite/.env when DEPLOY_PATH is /opt/mysite. Use the
same fields as .env.example. Set SITE_URL to the public HTTPS URL, set
SITE_DOMAIN to the public hostname without https://, and set
COOKIE_SECURE=true.
The server must have Docker with the Compose plugin. If the GHCR package is
private, run docker login ghcr.io once on the server with a read-only package
token.
The production Compose file joins the shared external Docker network named
web. It declares its public hostname with Traefik labels. The shared proxy
must be installed once on the server before deploying the app.
The server should run one Traefik stack that owns ports 80 and 443. Each
app remains in its own repository and declares its own hostname using Docker
labels. Traefik discovers those labels and requests TLS certificates
automatically.
The proxy template is stored in deploy/proxy. Copy compose.yaml to
/opt/proxy/compose.yaml on the server and copy .env.example to
/opt/proxy/.env. Then install it once from the server:
docker network create web
sudoedit /opt/proxy/.env
cd /opt/proxy
docker compose up -dOnly ACME_EMAIL is required in /opt/proxy/.env.
When migrating from the older bundled Caddy setup, stop the old app stack
before starting Traefik because both proxies need ports 80 and 443:
cd /opt/mysite
SITE_IMAGE=unused docker compose -f compose.deploy.yaml downThen install the shared proxy and deploy this app again. The site-data volume
is kept by docker compose down. The old Caddy volumes can be removed later
after the migration is confirmed.
For another app, add a production Compose file inside that app's own
repository. For example, a notes app could store this as
compose.deploy.yaml:
services:
notes:
image: ${NOTES_IMAGE:?Set NOTES_IMAGE}
restart: unless-stopped
expose:
- "8080"
networks:
- web
labels:
- "traefik.enable=true"
- "traefik.docker.network=web"
- "traefik.http.routers.notes.rule=Host(`${NOTES_DOMAIN:?Set NOTES_DOMAIN in .env}`)"
- "traefik.http.routers.notes.entrypoints=websecure"
- "traefik.http.routers.notes.tls.certresolver=letsencrypt"
- "traefik.http.routers.notes.middlewares=notes-compress"
- "traefik.http.middlewares.notes-compress.compress=true"
- "traefik.http.services.notes.loadbalancer.server.port=8080"
networks:
web:
external: trueThe labels go under the app service, next to image, expose, and networks.
expose is the port that the app listens on inside its container. It does not
publish that port directly on the server. Traefik receives HTTPS traffic and
forwards it to that private port.
Create a server directory and .env file for that app:
mkdir -p /opt/notes
cd /opt/notes
nano .envFor this example, /opt/notes/.env contains:
NOTES_DOMAIN=notes.axmouth.devUpload or copy the notes app's compose.deploy.yaml to
/opt/notes/compose.deploy.yaml, then deploy its image:
cd /opt/notes
NOTES_IMAGE=ghcr.io/your-user/notes:latest docker compose -f compose.deploy.yaml pull
NOTES_IMAGE=ghcr.io/your-user/notes:latest docker compose -f compose.deploy.yaml up -dThe shared Traefik container notices the labels automatically. There is no central proxy route file to edit and no proxy restart is needed.
Use unique router, service, and middleware names such as notes and
notes-compress for each app. The compression middleware reduces the size of
text responses such as HTML, CSS, and JavaScript. If another project uses port
8080 inside its own container, that is fine. Add a DNS
record for each subdomain, or add one wildcard *.axmouth.dev record pointing
to the server so new subdomains work without further DNS edits.
For an app that should redirect its www alias to the apex hostname, use a
second router and a redirect middleware. This lets Traefik request a valid
certificate for www.example.com before returning the redirect:
- "traefik.http.routers.mysite.rule=Host(`${SITE_DOMAIN:?Set SITE_DOMAIN in .env}`)"
- "traefik.http.routers.mysite-www.rule=Host(`www.${SITE_DOMAIN:?Set SITE_DOMAIN in .env}`)"
- "traefik.http.routers.mysite-www.entrypoints=websecure"
- "traefik.http.routers.mysite-www.tls.certresolver=letsencrypt"
- "traefik.http.routers.mysite-www.middlewares=mysite-www-redirect"
- "traefik.http.middlewares.mysite-www-redirect.redirectregex.regex=^https://www\\.(.+)"
- "traefik.http.middlewares.mysite-www-redirect.redirectregex.replacement=https://$${1}"
- "traefik.http.middlewares.mysite-www-redirect.redirectregex.permanent=true"The basic proxy template mounts the Docker socket read-only so Traefik can discover containers. Access to the Docker API is security sensitive. For a more hardened server, put a restricted Docker socket proxy in front of Traefik.
| Variable | Default | Purpose |
|---|---|---|
ADMIN_PASSWORD |
required | Password for the private admin area |
BIND_ADDRESS |
127.0.0.1:3000 |
Address used by the Rust server |
DATA_DIR |
data |
Directory for SQLite and uploaded images |
SITE_URL |
http://127.0.0.1:3000 |
Public base URL used in metadata and the sitemap |
SITE_DOMAIN |
example.com |
Public hostname used by Traefik in production |
COOKIE_SECURE |
false |
Set to true when the site is served over HTTPS |
PORT |
3000 |
Host port used by Docker Compose |
The production Compose file publishes Traefik labels for HTTPS and response
compression. Set SITE_URL to the public HTTPS URL, set SITE_DOMAIN to the
hostname, and set COOKIE_SECURE=true.
The site adds search metadata, Open Graph metadata, robots.txt, and
sitemap.xml. The site title, homepage SEO title, author name, description,
social image, copyright claim, and footer links can be edited from
/admin/settings.
The admin area has same-origin checks for changes, login throttling, secure response headers, and upload validation. Raw HTML in Markdown is ignored.
The data volume contains both the SQLite database and uploaded images. Keep
that volume when replacing or upgrading the container. Normal
docker compose up -d deployments and docker compose down keep named
volumes. Do not use docker compose down --volumes in production unless you
intend to delete the stored content.
Keep each production Compose file in a stable server directory such as
/opt/mysite. Docker Compose derives the default volume name from that
directory. Moving the Compose file to a differently named directory creates a
new empty volume unless the volume name is configured explicitly.
The shared Traefik stack stores its TLS certificate state in its own
traefik-data volume. Keep /opt/proxy/.env, /opt/mysite/.env, and the
Docker volumes when maintaining the server. The .env files contain runtime
configuration and are not uploaded by the deployment workflow.
The app state lives in the site-data Docker volume. It contains the SQLite
database and uploaded images. If the Compose file lives in /opt/mysite, the
default volume name is usually mysite_site-data.
Create a backup from the server:
cd /opt/mysite
mkdir -p backups
docker run --rm \
-v mysite_site-data:/data:ro \
-v "$PWD/backups:/backup" \
debian:bookworm-slim \
tar czf "/backup/mysite-site-data-$(date +%F).tar.gz" -C /data .Restore a backup:
cd /opt/mysite
docker compose -f compose.deploy.yaml down
docker run --rm \
-v mysite_site-data:/data \
-v "$PWD/backups:/backup" \
debian:bookworm-slim \
sh -c 'rm -rf /data/* && tar xzf /backup/mysite-site-data-YYYY-MM-DD.tar.gz -C /data'
docker compose -f compose.deploy.yaml up -dReplace YYYY-MM-DD with the backup date. Keep a copy of /opt/mysite/.env
too. For the shared proxy, back up /opt/proxy/.env and the traefik-data
volume separately.