Skip to content

Repository files navigation

mysite

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.

Run locally

Install Rust, then run:

ADMIN_PASSWORD=choose-a-long-password cargo run

Open 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.

Run with Docker Compose

Create your local environment file:

cp .env.example .env

Set a long random ADMIN_PASSWORD, then start the site:

docker compose up --build -d

Docker 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 checks

Run the complete local verification suite with:

scripts/verify.sh

It 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 metadata

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://.

Code structure

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

Continuous integration

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.

Shared reverse proxy

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 -d

Only 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 down

Then 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: true

The 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 .env

For this example, /opt/notes/.env contains:

NOTES_DOMAIN=notes.axmouth.dev

Upload 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 -d

The 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.

Configuration

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

Production notes

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.

Backups

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 -d

Replace 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.

About

Basic personal site

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages