MeetMaster is a lightweight application for tracking attendance in meetings at the lab. The organizer opens a meeting on their machine, displays the auto-generated QR code on a projector or shared screen, and attendees scan it on their phones to confirm participation. The participant list updates live for everyone viewing the meeting page.
The app is open-source, with sources and example deployment script available on Github.
Every page is gated behind authentication. The first interaction any user has is being redirected to our Authentik's login screen. Once signed in, they land on the meeting list, which is the home of the application. From here they can review past meetings or create a new one.
Creating a meeting takes a single click in the header and a name in the resulting popup. The new meeting appears at the top of the list with the current user as both creator and first participant.
Clicking any meeting opens its detail page. This is the central screen during a live meeting: a large QR code linking back to the page itself, a button to join, and the live list of participants. An attendee who scans the QR on their phone gets a single-tap "Join this meeting" button. The organizer also gets a small text input for adding attendees by name, useful for guests or people without accounts who can't sign in to join themselves.
Two automatic behaviors round out the experience. The participant list refreshes silently in the background, so the organizer watches names appear in real time as people scan and join. And meetings older than eight hours are marked inactive automatically; once that happens, the QR code, the join button, and the manual add form all disappear, and anyone scanning a stale QR sees a clear "this meeting has ended" notice.
The end-to-end lifecycle of a meeting, from creation through to automatic expiry, is summarized in the diagram below.
The runtime is structured around two main concerns: persistent storage in PostgreSQL, and authentication delegated to an external OpenID Connect provider. The high level component layout is shown below, followed by sections describing each piece in detail.
flowchart LR
User[Browser] -->|HTTPS| Proxy[Reverse proxy<br/>Caddy / nginx]
Proxy -->|HTTP + X-Forwarded-*| App[Spring Boot app]
App <-->|JDBC| DB[(PostgreSQL)]
App <-->|OIDC code flow| IdP[OIDC Provider<br/>Authentik / Keycloak / etc.]
Persistent state is stored in a single PostgreSQL instance accessed through Spring Data JPA on top of Hibernate, with HikariCP as the connection pool. Schema management is owned by Hibernate using ddl-auto: update, which evolves the schema in place on startup; for stricter production deployments this is the natural place to swap in an external migration tool.
The schema contains two business tables. The meets table holds one row per meeting, keyed by the four-digit id, with columns for name, creator, an active boolean, and a unix-second creation timestamp. The participant set lives in a separate meet_participants table mapped through a JPA element collection. Each row in it links one participant name to one meeting id.
A secondary index on the active column keeps the periodic expiry query fast as the table grows; without it, that query would degrade into a slow sequential scan.
Beyond these main business tables, Spring Session JDBC keeps its session data in the same database, with its schema created automatically on first startup. Persisting sessions to disk rather than memory means logins survive application restarts and can be shared across multiple instances if the deployment ever scales out.
Table meets |
||
|---|---|---|
| Column | Type | Notes |
| id | string | Primary key, four-digit code |
| name | string | Meeting name |
| creator | string | Display name of the organizer |
| active | boolean | Indexed for the expiry query |
| createTime | long | Unix seconds |
Table meet_participants |
||
|---|---|---|
| Column | Type | Notes |
| meet_id | string | Foreign key to meets |
| participant | string | Participant name |
Authentication is delegated to an external OpenID Connect (OIDC) provider, which in our deployment is Authentik. Every endpoint requires an authenticated session, and the only public surface is what the OIDC handshake itself needs. This applies uniformly: a user opening the app to create a meeting and a user landing on the page after scanning a QR code go through the same login flow. After signing in, the user is sent back to the original URL they tried to reach, so the operation resumes where it left off, whether that was the meeting list or a specific meeting's join page.
Every provider URL (authorization, token, userinfo, JWKS) is read from an environment variable, so the application is provider agnostic and works against Authentik, Keycloak, Auth0, Google, Okta, or any other compliant OIDC issuer.
The user's display name comes from a configurable JWT claim, defaulting to name. This value is used both for the creator field and for the implicit first participant when a meeting is created. To use a different claim like preferred_username or email, override a single environment variable.
Two security details are worth highlighting. CSRF protection is enabled by default, and Thymeleaf's th:action integration injects the token into every form. This makes the POST handlers (create meeting, participate, manual add) CSRF-safe without any explicit code. A Content Security Policy header is also set on every response. It limits scripts and styles to the same origin plus inline (required by the Thymeleaf templates), allows images from any HTTP or HTTPS source so externally hosted logos work, blocks framing with frame-ancestors 'none', and locks form submissions to the same origin.
The application is shipped as a single Docker image built and published automatically on each version tag. A reference docker compose deployment lives in the repository for self-hosting, parameterized entirely through environment variables. The following sections describe the Dockerfile, the publishing workflow, and the bundled compose example.
The Dockerfile uses a two-stage build. The build stage starts from eclipse-temurin:26-jdk and installs Maven through apt. The pom file is copied before the source, so Docker can cache the dependency resolution layer separately from code changes. The runtime stage starts fresh from the same JDK image, copies only the produced jar, exposes port 8080, and launches the JVM with -XX:+UseContainerSupport and -XX:MaxRAMPercentage=75.0 so the heap respects the container's memory limit.
The image is intentionally a JDK rather than a JRE because Temurin only ships Java 26 as a JDK at the time of writing. If image size ever becomes a concern, the natural follow-up is to switch to a JLink-produced minimal runtime and distroless container images.
The release workflow triggers on git tags matching v*. It checks out the repository, sets up Docker Buildx for multi-platform support, logs into the GitHub Container Registry using the workflow-issued GITHUB_TOKEN, and then publishes a tagged image.
Two small steps deserve mention. github.repository preserves the original casing, but ghcr.io requires lowercase image names. An intermediate shell step pipes the repository name through tr to lowercase it. The Docker metadata-action then derives the image tag from the git tag using type=ref,event=tag, which yields the literal tag value (for example v1.2.3). The build step uses GitHub Actions cache for both inbound and outbound layers, keeping subsequent builds short.
A reference deployment is included at deploy/docker-compose.yml together with deploy/.env. The compose file defines two services: the application itself, pulled from ghcr.io/alacrity-education/meetmaster, and a PostgreSQL 17 container with a pg_isready healthcheck that the application waits on. Every configuration knob is exposed in the .env file: database credentials, OIDC endpoints, the JWT name claim, the optional logo and favicon URLs, and the proxy header trust toggle. The compose file is a starting point. Production deployments will typically sit behind a reverse proxy with TLS termination.
A handful of implementation choices are worth recording for anyone making changes.
The Thymeleaf template setup uses a custom dual-resolver configuration. By default Spring Boot adds an .html suffix to view names. But the application includes a shared CSS fragment as ~{style.css}, with the file at templates/style.css (deliberately not style.css.html). To support both, a configuration class registers two SpringResourceTemplateResolver beans. The first uses no suffix and checkExistence=true, so it only matches the bare CSS fragment. The second is the standard .html suffix resolver and handles every regular view.
Common page elements like the logo URL and favicon URL are injected into every view through a @ControllerAdvice class that exposes them as model attributes. This includes the custom error views, because the standard BasicErrorController is itself a regular controller and the controller advice applies to it as well.
Reverse-proxy header handling is conditional. When TRUST_PROXY_HEADERS=true, a ForwardedHeaderFilter bean is registered at the highest precedence using Spring's @ConditionalOnProperty. The servlet container then honors X-Forwarded-Proto, X-Forwarded-Host, and X-Forwarded-For, so OIDC redirect URIs, QR code URLs, and HSTS headers all reflect the externally visible scheme and host. When the property is false or unset the filter is not registered at all, and forwarded headers are silently ignored. That is the safe default for an app exposed directly without a proxy.
Custom error pages live at templates/error/4xx.html and templates/error/5xx.html. Spring Boot's BasicErrorController resolves error view names by status series. Any unhandled 4xx response renders the 4xx template, and any 5xx response renders the 5xx template. The most common cases are 404 from missing meetings and 403 from authorization checks. Both templates pull in the shared style fragment and the global header, so they look like part of the application rather than the default Whitelabel page.
The live participant refresh is intentionally simple. The detail page embeds a hash of the sorted-and-joined participant set into the rendered HTML. A small client-side setInterval polls a JSON endpoint once per second for the current hash. When the hashes differ the page calls location.reload(). This pushes the work back to server-side rendering, with all its styling and authorization checks, instead of duplicating that logic on the client. The hash uses Java's String.hashCode(), which is fine for change detection because the worst case is a missed refresh that the next tick catches. The same hash would be inappropriate for any security-relevant comparison.
The four-digit meeting ID generation is a default method on the repository interface. It loops, generating a random four-digit string and calling existsById until it finds a free slot. With ten thousand possible IDs and active meeting counts in the dozens or hundreds, collisions are rare. The loop is only bounded by available IDs in the namespace, which becomes a concern at thousands of concurrent active meetings. If that ever turns into a real constraint, the natural extension is to widen the namespace.
Server-side input validation is enforced on both text inputs (meeting name and participant name) for length and emptiness. The HTML forms also set maxlength attributes as a client-side hint, but the server-side checks are the authoritative ones, because the HTML caps are trivially bypassed by a hand-crafted POST.
Finally, Spring Data REST is intentionally not on the dependency list. An earlier iteration accidentally included spring-boot-starter-data-rest, which auto-exposes every JPA repository as a CRUD REST API and publishes ALPS profile metadata at /profile. The only authorization at the time was anyRequest().authenticated(), so any logged-in user could have used the auto-generated endpoints to delete or modify any meeting, bypassing the controller-level checks entirely. The dependency has been removed from the pom.xml. If it is ever reintroduced, repositories should be marked @RepositoryRestResource(exported = false) or only specific methods exposed.
This document is the first attempt at AI writing technical documentation for a project it knows well.
It performed OK from an correctness perspective, but overincluded aspects that were previously worked on by AI in the code and is overly verbose is phrases.