Website for PreserveMyGames.org. Go server-rendered HTML with optional HTMX and Fuse.js. No tracking, ads, or telemetry.
Supported languages: English (en), German (de), Russian (ru).
This project ships as one static binary. Templates, translations, blog posts, and static assets are embedded at build time. There is no database, no Node runtime, and no external services required at run time.
make build
export SITE_URL=https://your-domain.example
export APP_ENV=production
./bin/preservemygames-webCopy bin/preservemygames-web to any Linux server (or build for your OS with GOOS/GOARCH), set environment variables, and run it behind a reverse proxy if you like. Idle memory is typically around 16 MB.
Docker is optional and runs the same binary in a minimal Alpine image.
- Go 1.26+ (only for building from source)
- curl and unzip for
make vendor(refreshing frontend assets) - Docker (optional, for container deployment)
make vendor # only when updating vendored JS, fonts, or icons
make buildBinary output: bin/preservemygames-web
Cross-compile example:
GOOS=linux GOARCH=arm64 make buildexport SITE_URL=http://localhost:8080
make runOpen http://localhost:8080. The root path redirects using Accept-Language. Locale URLs use a prefix:
http://localhost:8080/en/http://localhost:8080/de/http://localhost:8080/ru/
make ci
make test-race
make test-fuzzmake ci runs formatting checks, go fix, go vet, gosec, tests, and a release build.
The server applies Linux Landlock sandboxing when supported (internal/sandbox). Docker deployments use a read-only root filesystem, dropped capabilities, and resource limits in compose files.
Local compose:
make docker-test # build, wait for healthy, curl smoke checks
make docker-downFor a long-running local container without smoke checks:
make docker-upCoolify uses docker/docker-compose.coolify.yml. Set that path in Coolify and assign a domain with container port 8080.
Copy .env.example to .env for local reference. The binary reads environment variables directly.
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
Listen port |
APP_ENV |
development |
Set production for production mode |
SITE_URL |
http://localhost:8080 |
Canonical site URL (used in RSS, sitemap, SEO) |
CONTACT_EMAIL |
contact@preservemygames.org |
Contact address |
ACCESS_LOG |
false |
Enable request logging |
SITE_NOTICE |
(empty) | Site banner: construction, maintenance, info, or warning |
SITE_NOTICE_MESSAGE |
(empty) | Optional custom banner text (overrides default i18n message) |
Wiki and Forums links in the navbar are derived from SITE_URL (wiki.{domain} and forums.{domain}).
- Blog posts:
internal/blog/content/blog/{locale}/ - UI translations:
internal/i18n/locales/ - HTML templates:
internal/render/templates/(use{{t "message.id"}}for translated strings) - Static assets:
internal/staticfiles/static/
- Message strings live in JSON files under
internal/i18n/locales/(one file per language, e.g.en.json,de.json). - Templates call the
tfunction:{{t "nav.home"}}. - Each locale gets its own render engine at startup. The active language comes from the URL prefix (
/de/about). - The header includes a language switcher built from the same message keys (
lang.en,lang.de,lang.ru). hreflangalternates and the sitemap include every supported locale automatically.
Locales are discovered from locales/*.json at startup. You do not register languages in Go code.
Example for French (fr):
- Copy
internal/i18n/locales/en.jsontointernal/i18n/locales/fr.json. - Translate every
translationvalue. Keep eachidunchanged. - Add labels for the switcher:
- In every locale file, add or update
lang.frwith the display name (e.g.Françaisinfr.json,Frenchinen.json).
- In every locale file, add or update
- Add blog content under
internal/blog/content/blog/fr/(optional but recommended for a complete site). - Run tests:
go test ./internal/i18n/...
make ciTestLocaleKeyParity fails if any locale file is missing keys compared to the others.
- Add the message
idto all locale JSON files. - Use
{{t "your.message.id"}}in the template. - For dynamic values, use go-i18n template data in the handler (
TWith) and{{.Field}}in the JSON string, as withcontact.body.
Posts are not auto-translated. Each locale has its own Markdown files:
internal/blog/content/blog/en/my-post.md
internal/blog/content/blog/de/my-post.md
internal/blog/content/blog/ru/my-post.md
Slugs can match across languages so the language switcher can link to the same post path where translations exist.
MIT. See LICENSE.