Environment Variables
Application configuration through environment variables.
Backend Configuration
Create a .env file in the sis_backendgo/ directory:
# Server
SERVER_PORT=8000
SERVER_HOST=0.0.0.0
ENVIRONMENT=production
# Database (PostgreSQL)
DB_HOST=localhost
DB_PORT=5432
DB_USER=sis_app
DB_PASSWORD=your-secure-password
DB_NAME=sis
DB_SSLMODE=require
DB_MAX_CONNS=25
# Session Store (Valkey/Redis)
REDIS_URL=localhost:6379
REDIS_PASSWORD=
REDIS_DB=0
# Session Lifetime
SESSION_TTL=4h
SESSION_SLIDING_EXPIRATION=true
# Session Cookies
COOKIE_DOMAIN=sis.youruniversity.edu
COOKIE_PATH=/api
COOKIE_SECURE=true
# CORS
CORS_ALLOWED_ORIGINS=https://sis.youruniversity.edu
CORS_ALLOW_CREDENTIALS=true
# Photo Storage
PHOTO_STORAGE_PATH=/opt/sis/photos
PHOTO_MAX_SIZE_MB=2
# Email
SMTP_HOST=smtp.youruniversity.edu
SMTP_PORT=587
SMTP_USER=sis@youruniversity.edu
SMTP_PASSWORD=your-email-password
SMTP_FROM=noreply@youruniversity.edu
SMTP_USE_TLS=true
EMAIL_SEND_PER_MINUTE=120
# Role network rules (Administration > Role Networks)
ROLE_NETWORK_ENFORCEMENT=log
ROLE_NETWORK_BREAKGLASS_CIDRS=Required Variables
Server
| Variable | Description | Default |
|---|---|---|
SERVER_PORT | HTTP listen port | 8000 |
SERVER_HOST | HTTP listen address | 0.0.0.0 |
ENVIRONMENT | Runtime environment (development or production) | development |
Database
| Variable | Description | Default |
|---|---|---|
DB_HOST | PostgreSQL server hostname | 127.0.0.1 |
DB_PORT | PostgreSQL port | 5432 |
DB_USER | Database username | postgres |
DB_PASSWORD | Database password | postgres |
DB_NAME | Database name | sis |
DB_SSLMODE | SSL mode (disable, require, verify-full) | disable |
DB_MAX_CONNS | Maximum database connections | 25 |
Session Store (Valkey/Redis)
| Variable | Description | Default |
|---|---|---|
REDIS_URL | Valkey/Redis server address | localhost:6379 |
REDIS_PASSWORD | Valkey/Redis password | (empty) |
REDIS_DB | Valkey/Redis database number | 0 |
Valkey or Redis is required for session management. The backend stores authenticated user sessions in Valkey/Redis and issues session cookies to the browser.
Session Lifetime
| Variable | Description | Default |
|---|---|---|
SESSION_TTL | Idle-timeout ceiling: the longest a session may sit unused | 4h |
SESSION_SLIDING_EXPIRATION | Extend the TTL on each request | true |
A session's actual idle timeout is the smallest of three values:
SESSION_TTL, the hard ceiling set here;- Session Timeout on Administration > System Settings (
SESSION_TIMEOUT_MINUTES); - the Idle timeout of the role the user is currently acting as, set on Administration > Roles.
A role's limit can only shorten a session, never lengthen it. The limit follows the role the user is acting as, so switching to a role with a shorter limit tightens the session at once and switching back restores it. The server enforces all three. Edits reach sessions that are already signed in within about 30 seconds.
A 12-hour absolute cap is compiled in
Whatever SESSION_TTL says, and regardless of sliding expiration, a session ends 12 hours after it was created. That cap is not configurable. Setting SESSION_TTL=24h does not produce a 24-hour session.
There is no JWT anywhere in this system, and no JWT_ACCESS_TOKEN_LIFETIME variable. Sessions are opaque IDs in a cookie, resolved against Valkey on every request.
Session Cookies
| Variable | Description | Default |
|---|---|---|
COOKIE_DOMAIN | Domain for session cookies | (empty) |
COOKIE_PATH | Path scope for session cookies | /api |
COOKIE_SECURE | Require HTTPS for cookies (true or false) | false |
TIP
In production, always set COOKIE_SECURE=true to ensure session cookies are only sent over HTTPS.
CORS
| Variable | Description | Default |
|---|---|---|
CORS_ALLOWED_ORIGINS | Comma-separated allowed origins | http://localhost:5173 |
CORS_ALLOW_CREDENTIALS | Allow credentials in CORS | true |
Photo Storage
| Variable | Description | Default |
|---|---|---|
PHOTO_STORAGE_PATH | Directory for photo uploads | ./photos |
PHOTO_MAX_SIZE_MB | Maximum photo upload size in MB | 2 |
Official Photo Repository (SMB)
Credentials for the read-only ID-picture share that the photo sweep mirrors. Leaving these blank is a supported state: the sync screen then reports itself as unconfigured and the Run button stays disabled.
| Variable | Description | Default |
|---|---|---|
PHOTO_SMB_HOST | Hostname or IP of the file server | (blank) |
PHOTO_SMB_PORT | SMB port | 445 |
PHOTO_SMB_SHARE | Share name, e.g. IDPictures$ | (blank) |
PHOTO_SMB_DIR | Optional subdirectory; blank means the share root | (blank) |
PHOTO_SMB_USER | Account: UPN, DOMAIN\user, or bare name | (blank) |
PHOTO_SMB_PASSWORD | Account password | (blank) |
PHOTO_SMB_DOMAIN | Domain, if not folded into the user name | (blank) |
PHOTO_SYNC_CONCURRENCY | Parallel file transfers | 4 |
Only HOST, SHARE and USER decide whether the repository counts as configured (SMBConfig.Configured()); the rest may be blank. An empty value is indistinguishable from an unset one, because getEnv falls through to the default on either.
See Photo Repository for the sweep itself, the three accepted forms of the account name, and the dotenv quoting traps that silently corrupt a password.
Email (SMTP)
| Variable | Description | Default |
|---|---|---|
SMTP_HOST | SMTP server hostname | localhost |
SMTP_PORT | SMTP port | 1025 |
SMTP_USER | SMTP username | (empty) |
SMTP_PASSWORD | SMTP password | (empty) |
SMTP_FROM | Fallback sender while the EMAIL_NOREPLY_ADDRESS setting (Admin > System Settings) is unset | no-reply@puc.edu |
SMTP_USE_TLS | Enable TLS | false |
EMAIL_DELIVERY | capture or live | capture |
EMAIL_SEND_PER_MINUTE | Throttle for the staff-to-student email outbox (one message per recipient) | 120 |
Who may email students, how many at a time and from which addresses is configured in the application, not here; see Email Sending.
For development, use Mailpit (port 1025 SMTP, port 8025 web UI).
EMAIL_DELIVERY — which instance is allowed to mail real people
internal/shared/email dials whatever SMTP host it is handed and has no notion of environment, and SendAsync is fire-and-forget, so a failure is a log line nobody reads. Before this variable existed, the only thing standing between an instance and 238,000 real mailboxes was the SMTP_HOST value in a compose file.
capture(the default) —SMTP_HOSTmust be a known mail sink:mailpit,localhost,127.0.0.1or::1. Anything else fails at startup.live— real delivery. RequiresENVIRONMENT=production, and rejects a capture host so an instance cannot believe it is sending while mailpit eats the mail.
Both mismatches are fatal rather than warnings. A crash-loop is cheap and obvious; mail that should never have left is neither. Only the /records production instance is ever set to live.
Write mode
| Variable | Description | Default |
|---|---|---|
WRITE_MODE | full or restricted | full |
restricted is for a deployment that is reachable and real but does not yet own the data it serves — the /records instance, which runs alongside Colleague until the final migration. Safe methods pass; every other method is refused with 403 write_disabled unless the route is on the allowlist in internal/middleware/writegate.go.
That allowlist is a hardcoded Go slice, deliberately, and it is described here by category rather than by path so it stops going stale every time one is added — read the file for the current list, and TestWriteGateAllowsOnlyTheIntendedRoutes for the exact set of routes it permits:
| Category | Why it is open |
|---|---|
| Authentication, the SAML assertion consumer, role switching | Gate these and the instance cannot be logged into at all |
| Role and permission administration | So a SUPER_ADMIN can still fix access on a locked-down instance; includes invalidate-cache |
| SAML identity provider administration | Instance authentication config, and /records is where SAML is wired |
| The workflow engine and the form responses it collects | Requests are the whole reason the instance is open for business; no Colleague counterpart |
| Reference data, extended-lookup types, and form-builder authoring | The institution's own configuration of the instance |
| Per-user UI state (dashboard layouts, shortcuts, filter presets) | Blocking it makes the app feel broken while protecting nothing |
Everything else is refused, including — deliberately — proxy invitations and proxy access, which hand a third party a view of a student's record.
Two things about the reference-data category are worth knowing before using it on a restricted instance, because the daily ODS importer resolves lookup rows by code:
- The generic update endpoint cannot change a value's
codeat all. It is the lookup's identity, and renaming one would break the next nightly import quietly — per-row warnings and exit 0, not a visible failure. - Deleting a lookup value is possible and is not guarded that way. A row that is referenced fails on its
NO ACTIONforeign keys, but an unused row that an importer step hard-codes will delete cleanly and break the next run. This is why the grant is held to a single role.
It sits in the global middleware chain rather than inside protect, because the routes it most needs to cover are the ones permission nodes do not reach: SelfService and Public. Narrowing a role's grants to VIEW is the companion measure — it makes the UI hide affordances instead of offering them and failing — but grants are database rows editable from inside the application, so they cannot be the only thing enforcing this. An unrecognised value fails startup rather than falling back to either mode.
Role network rules
| Variable | Description | Default |
|---|---|---|
ROLE_NETWORK_ENFORCEMENT | off, log or enforce — whether the rules on Administration > Role Networks refuse anything | log |
ROLE_NETWORK_BREAKGLASS_CIDRS | Comma-separated CIDRs (a bare address is one host) from which every role is usable whatever the rules say | (empty) |
Migration 291 lets each role be restricted to the networks it may be used from (see Role Networks). log, the default, evaluates the rules on every request and writes role_network_would_deny to the log for each user, role and address that enforce would refuse, but refuses nothing; it is how the real source ranges are confirmed before anyone is locked out. An empty value means log. Any other unrecognised value, or a break-glass entry that is not a valid address or CIDR, fails startup: guessing wrong in either direction is a security or an availability problem.
The address checked is the one the server settles on after Caddy, which overwrites X-Forwarded-For with the real peer. Users arriving over ZPA appear as the App Connector's address, not their own.
On the production host the two variables are set as SIS_ROLE_NETWORK_ENFORCEMENT and SIS_ROLE_NETWORK_BREAKGLASS_CIDRS in .env.local, shared by sdasis-backend and records-backend. The demo container hard-codes ROLE_NETWORK_ENFORCEMENT=off, deliberately not through the shared variable, because every role must work from wherever the demo is shown.
Frontend Configuration
For the Vue frontend, create .env.production:
VITE_API_URL=https://sis.youruniversity.edu/api/v1
VITE_APP_TITLE=Student Information SystemEnvironment-Specific Settings
Development
ENVIRONMENT=development
DB_PORT=5432
DB_SSLMODE=disable
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
COOKIE_SECURE=false
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_USE_TLS=falseProduction
Production does not use sis_backendgo/.env
The prod stack runs in containers, and a container gets its environment from docker-compose.prod.yml, whose values are interpolated from a root .env.local on the server. sis_backendgo/.env does not exist there at all.
Two consequences. Host-side names are SIS_/SIS2_-prefixed (SIS_PHOTO_SMB_USER) and compose maps them onto the plain names in this page's tables; there is no prefix handling in the application, only those explicit compose lines. And a variable that is absent from .env.local usually fails silently -- most references carry an empty default, so the feature just reports itself as unconfigured. .env.local.example at the repo root is the tracked template listing every variable and how each one fails.
Host-built CLI tools (migrate-daily, sanitize-db, backfills) are the other path: they read unprefixed names via godotenv relative to their working directory, so on prod they must be handed their environment explicitly.
ENVIRONMENT=production
DB_SSLMODE=require
CORS_ALLOWED_ORIGINS=https://sis.youruniversity.edu
COOKIE_DOMAIN=sis.youruniversity.edu
COOKIE_SECURE=true
SMTP_HOST=smtp.youruniversity.edu
SMTP_PORT=587
SMTP_USE_TLS=trueSecurity Best Practices
- Never commit
.envfiles to version control - Use strong passwords for database, Valkey/Redis, and email
- Set
COOKIE_SECURE=truein production to prevent session hijacking - Use HTTPS in production (required for secure cookies)
- Limit CORS origins to specific domains
- Enable DB SSL (
DB_SSLMODE=require) in production - Set
ENVIRONMENT=productionto disable development features - Set a Valkey/Redis password in production environments
Verifying Configuration
Test your configuration:
cd sis_backendgo
go build ./...
./api # or: go run ./cmd/api/main.go
# Check: http://localhost:8000/health