Dioxus fullstack (SSR + hydration) portfolio served by Axum.
The source of https://tim-schoenle.de, as one Rust workspace.
apps/web is a single crate with two feature-selected builds. The server build is a native Axum
binary that renders every route. The web build is a WASM bundle that hydrates it in the browser.
The Dioxus CLI produces both.
The configuration tables below are generated, not written. They come out of the Rust types that
load the configuration, as do config.example.toml and the contract document the image publishes
about itself, so renaming a field corrects all three in the commit that renames it.
docker run --rm -p 8080:8080 timschoenle/portfolio:v2.10.0Then open http://localhost:8080. The runtime image is FROM scratch: one statically linked
binary, the client bundle beside it, and the configuration contract at /config/contract.json. It
runs as 1001:1001 and needs nothing writable.
- Features
- Installation
- Usage
- Configuration
- Operations
- Compatibility
- Documentation
- Contributing
- Security
- License
- Every route renders on the server and then hydrates. Per-route
<head>metadata and JSON-LD ship in the first response, and the locale is negotiated from request headers before the document is serialised, so nothing arrives in the wrong language and gets swapped a moment later. - EN and DE throughout, including both legal pages and both resumes.
translation_key_sets_matchincrates/datafails the build when the two translation files disagree on a key, which otherwise shows up as one English string in a German page rather than as an error. - The resumes are typeset during the build. Typst lays out one A4 page per language, scaling the type down and re-typesetting until the content fits, and each PDF carries a SHA-256 fingerprint that the contact card shows.
- The Content-Security-Policy is built per response. The server hashes the inline scripts that
document actually carries and reserves a nonce for the script Cloudflare injects at the edge, so
script-srcneeds no'unsafe-inline'. /licenseslists every crate the client and the server link, the licence it ships under, and the verbatim text of every licence file found. cargo-about produces it during the image build, and an unlisted licence fails that build.- The project list is fetched from the GitHub API at build time, with archived, blacklisted and
year-stale repositories dropped, then embedded into the binary by
build.rs. - Cmd+K opens a command palette with fuzzy search and keyboard navigation. The stack section is a skill radar with per-skill tooltips and category filtering.
docker pull timschoenle/portfolio:v2.10.0Both architectures are pushed as one manifest list, so docker pull resolves the right image per
node. The push carries an SBOM and max-mode provenance and is signed with cosign. Pin by digest in
production. The Helm chart does.
helm repo add timschoenle https://timschoenle.github.io/helm-charts
helm install portfolio timschoenle/portfolioThe chart is in TimSchoenle/helm-charts and each release bumps it to the new image digest.
rustup target add wasm32-unknown-unknown
cargo install --locked dioxus-cli cargo-about
git clone https://github.com/TimSchoenle/Portfolio.git
cd PortfolioNode.js is needed for the Tailwind step, and just runs every recipe CI runs.
Two artefacts have to exist before the web build, because apps/web/build.rs embeds both. Without
them it substitutes empty defaults and the pages render their empty state.
cargo run -p resume-generator -- apps/web/generated # resume PDFs, fingerprints, social card
just licenses # third-party inventory for /licenses
cd apps/web && npm ci && npm run build:css
dx serve --platform web # SSR + hydration on http://localhost:8080Run the checks CI runs, in one recipe:
just verify # fmt, lint, testRefresh the project list from the GitHub API:
PORTFOLIO_GITHUB__TOKEN_FILE=/run/secrets/gh_token \
cargo run --release -p update-repos -- apps/web/repos.jsonWithout a token the run still works, against the anonymous rate limit. docs/PROJECT_DATA.md has the filtering rules and the caching.
Values are resolved in five layers, each overriding the one above it. The last three are mutually exclusive per key: a key supplied by two of them fails the boot rather than letting one win, because a stale environment variable shadowing a rotated mounted secret keeps the process running on the old credential.
- Defaults — the
serdedefaults of each typed block. - TOML at
$PORTFOLIO_CONFIG— a file, or every*.tomlinside it when it names a directory. - Environment —
PORTFOLIO_-prefixed variables,__for nesting. - Secrets directory at
$PORTFOLIO_SECRETS_DIR— one file per key, named after it (github__token). This is what a KubernetesSecretvolume mounts. - File indirection —
PORTFOLIO_<KEY>_FILE=/pathnames a file holding the value.
An empty value counts as unset in every layer, because container platforms routinely inject KEY=
for a declared-but-unset variable.
The two variables the loader reads before any layer exists, then every key apps/web loads. Each
environment spelling also accepts a _FILE suffix naming a file that holds the value.
| Variable | Role | Default | Purpose |
|---|---|---|---|
PORTFOLIO_CONFIG |
config | config.toml |
Names the TOML layer: a file, or a directory whose *.toml files are all merged in name order. |
PORTFOLIO_SECRETS_DIR |
secrets dir | — | Names a directory of key-named files — a mounted Kubernetes Secret volume. Each file supplies the key its name spells. |
| TOML | Type | Environment | Default | Flags | Purpose |
|---|---|---|---|---|---|
assets.dist_dir |
PathBuf |
PORTFOLIO_ASSETS__DIST_DIR |
public |
— | Directory holding the dx bundle output, relative to the working directory. |
csp.hash_inline_scripts |
bool |
PORTFOLIO_CSP__HASH_INLINE_SCRIPTS |
true |
— | Hash every inline <script> in the document being served instead of admitting all inline script with 'unsafe-inline'. |
csp.cloudflare.script_nonce |
bool |
PORTFOLIO_CSP__CLOUDFLARE__SCRIPT_NONCE |
true |
— | Reserve a per-response nonce in script-src for the script Cloudflare injects at the edge. |
csp.cloudflare.turnstile |
bool |
PORTFOLIO_CSP__CLOUDFLARE__TURNSTILE |
false |
— | Admit https://challenges.cloudflare.com in script-src and frame-src, for a Turnstile widget. |
csp.cloudflare.web_analytics |
bool |
PORTFOLIO_CSP__CLOUDFLARE__WEB_ANALYTICS |
false |
— | Admit the Cloudflare Web Analytics beacon and the endpoint it reports to. |
isr.cache_dir |
PathBuf |
PORTFOLIO_ISR__CACHE_DIR |
unset (ISR off; the image sets /tmp/isr) |
— | Writable directory rendered HTML is cached into. Unset or empty disables ISR. |
isr.ttl_secs |
u64 |
PORTFOLIO_ISR__TTL_SECS |
0 (permanent) |
— | Revalidation interval in seconds. Zero means a permanent cache. |
sentry.enabled |
bool |
PORTFOLIO_SENTRY__ENABLED |
false |
— | Initialise the Sentry client. false installs no client, no panic hook, no tracing layer and no HTTP middleware, so every other key here is inert and nothing leaves the process. |
sentry.dsn |
SecretString |
PORTFOLIO_SENTRY__DSN |
unset | secret | Ingest URL, https://<key>@<host>/<project>. |
sentry.environment |
String |
PORTFOLIO_SENTRY__ENVIRONMENT |
unset (production in a release build, development otherwise) |
— | Environment tag on every event. |
sentry.release |
String |
PORTFOLIO_SENTRY__RELEASE |
unset (portfolio@ and the built version) |
— | Release tag on every event. |
sentry.server_name |
String |
PORTFOLIO_SENTRY__SERVER_NAME |
unset | — | Host tag on every event. |
sentry.sample_rate |
f32 |
PORTFOLIO_SENTRY__SAMPLE_RATE |
1 |
— | Fraction of captured events actually sent, 0.0–1.0. |
sentry.traces_sample_rate |
f32 |
PORTFOLIO_SENTRY__TRACES_SAMPLE_RATE |
0 |
— | Fraction of request traces recorded, 0.0–1.0. |
sentry.capture_level |
SentryLevel: off | error | warn | info | debug | trace |
PORTFOLIO_SENTRY__CAPTURE_LEVEL |
error |
— | Least severe tracing level reported as a Sentry issue. |
sentry.breadcrumb_level |
SentryLevel: off | error | warn | info | debug | trace |
PORTFOLIO_SENTRY__BREADCRUMB_LEVEL |
info |
— | Least severe tracing level kept as a breadcrumb, the trail attached to the next issue. |
sentry.max_breadcrumbs |
usize |
PORTFOLIO_SENTRY__MAX_BREADCRUMBS |
100 |
— | How many breadcrumbs one event carries. |
sentry.attach_stacktraces |
bool |
PORTFOLIO_SENTRY__ATTACH_STACKTRACES |
true |
— | Attach a stack trace to events that carry none of their own. |
sentry.send_default_pii |
bool |
PORTFOLIO_SENTRY__SEND_DEFAULT_PII |
false |
— | Send personally identifying data with every event: the client IP, the full request header set (Cookie included) and the resolved user. |
sentry.http_transactions |
bool |
PORTFOLIO_SENTRY__HTTP_TRANSACTIONS |
true |
— | Record one Sentry transaction per request, named by the matched route rather than by the URI — so /api/repos/{name} does not become one transaction name per repository. |
sentry.span_attributes |
bool |
PORTFOLIO_SENTRY__SPAN_ATTRIBUTES |
false |
— | Copy tracing span fields onto the Sentry span as attributes. |
sentry.debug |
bool |
PORTFOLIO_SENTRY__DEBUG |
false |
— | Print the SDK's own diagnostics to stderr. For proving a DSN works, not for running. |
What update-repos loads. It runs during the image build and exits. The server never reads these
keys, so a deployment needs no GitHub token. That is why there are two tables and not one.
| TOML | Type | Environment | Default | Flags | Purpose |
|---|---|---|---|---|---|
github.username |
String |
PORTFOLIO_GITHUB__USERNAME |
unset (the site's own CONFIG.github_username) |
— | User whose repositories to list. |
github.token |
SecretString |
PORTFOLIO_GITHUB__TOKEN |
unset | secret | Bearer token lifting the GitHub API rate limit. |
github.repos |
Vec<String> |
PORTFOLIO_GITHUB__REPOS |
[] (every active repository the user owns) |
— | Explicit repository set, bypassing the "every active repository" listing and its filtering. |
There are two secrets in the workspace, and each should arrive as a file rather than as an
environment variable: /proc/<pid>/environ, a crash dump and docker inspect all carry the
environment, and child processes inherit it.
github.token is the builder's, and the Docker build already mounts it as a
BuildKit secret and hands update-repos the path. sentry.dsn is the server's, read
only when error reporting is switched on — supply it as PORTFOLIO_SENTRY__DSN_FILE=/path, or as
sentry__dsn in a mounted Secret volume.
IP, PORT and RUST_LOG sit outside this namespace deliberately. They are the Dioxus toolchain's
contract with the binary, and it keeps reading them itself.
config.example.toml carries every key at its default, commented out, and is
rendered from the same payload as these tables.
| Endpoint | Alias | Purpose |
|---|---|---|
GET /api/health |
— | Health report with the current UTC time |
GET /api/health/live |
GET /livez |
Liveness — the process is running; failure restarts the container |
GET /api/health/ready |
GET /readyz |
Readiness — index.html under assets.dist_dir (default public/) is present and servable; failure removes the pod from the Service endpoints |
All probe responses are no-store. Readiness returns 503 until the assets are there.
The server reads its bundle directory and writes to stdout. It runs as numeric non-root
1001:1001, so runAsNonRoot verifies statically, and it satisfies the restricted Pod Security
Standard with a read-only root filesystem, no privilege escalation, every capability dropped and
seccompProfile: RuntimeDefault.
Incremental static regeneration is the one thing that wants a writable path. The image points the
cache at /tmp/isr and bakes an empty one owned by the runtime user; where that path is not
writable, every request is rendered fresh instead.
Optional, off, and the only thing that would give the process an outbound connection. Set
sentry.enabled with a DSN and the server installs a Sentry client, a panic hook, a
tracing layer feeding it, and one transaction per request named by the matched route. Enabled
without a usable DSN it refuses to boot, rather than reporting into a dashboard whose emptiness
looks like a healthy site.
Two keys decide how much goes with it. sentry.traces_sample_rate is 0, so switching reporting on
does not also switch performance data on. sentry.send_default_pii is false and should stay
there: on, every event carries the client IP and the full request header set, Cookie included.
While Sentry is off, RUST_LOG and the log format are the Dioxus toolchain's as before; while it is
on, the server installs the subscriber itself — same variable, same default verbosity, plus the
layer that reports.
| Supported | |
|---|---|
| Rust | 1.97 (edition 2024) |
| Platforms | linux/amd64, linux/arm64 |
| Pod Security Standard | restricted |
| Document | Purpose |
|---|---|
| Architecture | Six packages in one Cargo workspace: two libraries every binary reads, three binaries, and a placeholder at the root that exists so release-please has a version to move. |
| Deployment | What the image contains, how it is built reproducibly, what it publishes about its own configuration, and where the chart that runs it lives. |
| Project data | How apps/web/repos.json is fetched, filtered, cached and embedded, and what the projects section reads out of it. |
| Security posture | The Content-Security-Policy the server builds per response, the headers around it, and what the process needs from the filesystem it runs on. |
| docs/config.contract.json | — |
CONTRIBUTING.md has the development setup, the commit convention and the checks a pull request has to pass. Note what the licence grants before you fork: reading the source and running it locally, and nothing about redistributing or deploying it.
Several files here are generated, this one included. Each says so in its opening lines, and an edit made to the output instead of to the template is replaced by the next render.
Do not open a public issue for a vulnerability. SECURITY.md has the reporting route and the supported versions.
LicenseRef-Proprietary. Viewing the source and running it locally for personal, non-commercial
evaluation is granted. Copying, modifying, redistributing, deploying and training on it are not.
LICENSE has the terms. The bundled fonts keep their own: Inter and Liberation Sans are
both under the SIL Open Font License, in apps/resume-generator/fonts/.