Skip to content

Environment Variable & Secrets Management

Matías Valle Trapiella edited this page Apr 13, 2026 · 1 revision

Environment Variable & Secrets Management

Overview

The application needs several secrets and runtime configuration values to operate: Firebase credentials, project IDs, Redis addresses, and public hostnames. These cannot be baked into Docker images (security) and cannot always be passed as files (portability). The solution is a layered pipeline:

´´´ GitHub Secrets │ ▼ build.yml (CI/CD workflow) │ exports secrets as env vars ▼ docker-compose.yml │ injects env vars into each container ▼ entrypoint.sh (Rust containers only) │ decodes FIREBASE_CREDENTIALS_B64 → /tmp/firebase-credentials.json │ exports GOOGLE_APPLICATION_CREDENTIALS ▼ Running process reads env vars at startup ´´´


Table of Contents


GitHub Secrets

Secrets are stored in the GitHub repository under Settings → Secrets and variables → Actions. They are never written to logs or exposed in workflow output.

Secret name What it contains
SONAR_TOKEN SonarQube authentication token for code quality analysis
FIREBASE_PROJECT_ID The Firebase project identifier string (e.g. my-project-12345)
FIREBASE_CREDENTIALS_B64 The full Firebase service account JSON, base64-encoded (see below)
DEPLOY_HOST The public hostname of the production server (e.g. yovi-en2a.com)

build.yml — CI/CD Workflow

The workflow file lives at .github/workflows/build.yml. It triggers on every push to master and on every pull request.

name: Build
on:
  push:
    branches:
      - master
  pull_request:
    types: [opened, synchronize, reopened]

Jobs

The single tests job runs on ubuntu-latest and executes in order:

Step What it does
actions/checkout@v4 Clones the repository with full history (fetch-depth: 0, required by SonarQube for blame data)
dtolnay/rust-toolchain@stable Installs the stable Rust toolchain with rustfmt and clippy
actions/setup-node@v4 Installs Node.js 22
npm ci (users + webapp) Installs dependencies from the lockfile
npm run test:coverage (users + webapp) Runs tests and generates LCOV coverage reports
cargo install cargo-llvm-cov Installs the Rust coverage tool
cargo llvm-cov Generates an LCOV coverage report for the gamey crate
Normalize Rust coverage paths Strips the absolute $GITHUB_WORKSPACE/ prefix from SF: lines so SonarQube can match them to source files
SonarSource/sonarqube-scan-action Uploads all coverage reports to SonarQube

Secret injection into the workflow

The SONAR_TOKEN secret is passed to the SonarQube step via the env block:

- name: Analyze with SonarQube
  uses: SonarSource/sonarqube-scan-action@master
  env:
    SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

The ${{ secrets.SONAR_TOKEN }} syntax is GitHub Actions' secret interpolation. The value is injected as an environment variable inside that step only — it is never written to the shell history or printed in logs.


The Firebase Credentials Problem

Firebase's Node.js and Rust SDKs authenticate via a service account JSON file — a structured JSON object containing a private key, project ID, client email, and other metadata. This file must be available on disk at the path pointed to by the GOOGLE_APPLICATION_CREDENTIALS environment variable.

This creates a problem for containerised deployments: the credentials file cannot be baked into the Docker image (it would be exposed in the image layers and in the registry), and it cannot be mounted as a host volume in portable or cloud deployments where there is no persistent host filesystem.

The solution: encode the JSON file as a base64 string, store the string as a GitHub Secret (and as a Docker Compose variable), pass it into the container as a single environment variable, and decode it back to a file at container startup via entrypoint.sh.


Base64 Encoding — How to Generate FIREBASE_CREDENTIALS_B64

The value stored in the FIREBASE_CREDENTIALS_B64 secret is produced by encoding the service account JSON file with base64. On Linux/macOS:

base64 -w 0 firebase-service-account.json

The -w 0 flag disables line wrapping, producing a single unbroken string. This is important because multiline values cannot reliably be stored in environment variables or GitHub Secrets without escaping.

The output looks like: ´´´ eyJwcm9qZWN0X2lkIjoibXktcHJvamVjdCIsInR5cGUiOiJzZXJ2aWNlX2FjY291bnQiLCJ... ´´´

This string is then stored as the FIREBASE_CREDENTIALS_B64 GitHub Secret. It is injected into the auth-engine and game_manager containers at runtime via docker-compose.yml.


entrypoint.sh — Decoding at Container Startup

Both Rust containers (userAuthentification and game_manager) use an identical entrypoint.sh. It runs as the Docker ENTRYPOINT, before the application binary starts.

#!/bin/sh
if [ -n "$FIREBASE_CREDENTIALS_B64" ]; then
    echo "$FIREBASE_CREDENTIALS_B64" | base64 -d > /tmp/firebase-credentials.json
    export GOOGLE_APPLICATION_CREDENTIALS=/tmp/firebase-credentials.json
    echo "INFO: Firebase credentials written to /tmp/firebase-credentials.json"
else
    echo "WARNING: FIREBASE_CREDENTIALS_B64 is not set. Firebase will use default credentials lookup."
fi

exec "$@"

Line by line

Line What it does
[ -n "$FIREBASE_CREDENTIALS_B64" ] Checks that the variable is set and non-empty. If missing, the script warns and continues — the Firestore SDK will fall back to its own credentials lookup chain (e.g. Application Default Credentials on GCP)
echo "$FIREBASE_CREDENTIALS_B64" | base64 -d Pipes the base64 string into base64 -d, which decodes it back to the original JSON bytes
> /tmp/firebase-credentials.json Writes the decoded JSON to a temporary file. /tmp is always writable inside a container and is not persisted between restarts
export GOOGLE_APPLICATION_CREDENTIALS=... Sets the standard Google SDK environment variable pointing to the credentials file. Both the Rust firestore crate and the Node.js Firebase SDK read this variable automatically
exec "$@" Replaces the shell process with the application binary (passed as CMD arguments). Using exec instead of a plain call ensures the binary becomes PID 1, so it receives OS signals (e.g. SIGTERM for graceful shutdown) correctly

How the Dockerfile wires it in

COPY entrypoint.sh /entrypoint.sh
RUN sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]
CMD ["./game_manager", "--mode", "server", "--port", "5000"]

The sed -i 's/\r$//' strips Windows-style line endings (\r\n) that can be introduced if the file is edited on Windows. Without this, the shell interpreter would fail to parse the script on Linux. chmod +x makes it executable.

When Docker starts the container, it runs /entrypoint.sh ./game_manager --mode server --port 5000. The script decodes the credentials, then exec "$@" hands off to the binary.


docker-compose.yml — Variable Injection

docker-compose.yml is the runtime orchestration file. It reads variables from the host shell environment (or a .env file) using the ${VAR_NAME} syntax and injects them into containers.

Host-to-container injection

Variables prefixed with ${} in the environment block are read from the host shell at docker compose up time:

game_manager:
  environment:
    - FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID}
    - FIREBASE_CREDENTIALS_B64=${FIREBASE_CREDENTIALS_B64}

In production, the deployment server runs docker compose up from a shell that already has these variables exported (set by the CI/CD pipeline or a .env file on the server). Docker Compose substitutes the values at startup.

Static values

Variables without ${} are hardcoded directly in the compose file:

users:
  environment:
    - GAMEMANAGER_URL=http://game_manager:5000
    - AUTH_URL=http://auth-engine:4001
    - REDIS_HOST=redis_db
    - REDIS_PORT=6379

These use Docker's internal DNS — containers on the same monitor-net bridge network can reach each other by service name (e.g. redis_db resolves to the Redis container's IP).

Build-time vs runtime variables

The webapp service uses Docker build arguments (ARG) rather than runtime environment variables, because Vite bakes VITE_* variables into the compiled JavaScript bundle at build time:

webapp:
  build:
    context: ./webapp
    args:
      - VITE_API_URL=http://localhost:3000

These are passed to docker build --build-arg and are only available during the RUN npm run build step — they do not exist in the running container.


Per-service Variable Reference

auth-engine (Rust, port 4001)

Variable Source Required Description
FIREBASE_PROJECT_ID Host env / GitHub Secret Firebase project identifier, read by env::var() in firebase.rs
FIREBASE_CREDENTIALS_B64 Host env / GitHub Secret Base64-encoded service account JSON, decoded by entrypoint.sh
GOOGLE_APPLICATION_CREDENTIALS Set by entrypoint.sh Path to decoded credentials file; read automatically by the Firestore SDK

game_manager (Rust, port 5000)

Variable Source Required Description
FIREBASE_PROJECT_ID Host env / GitHub Secret Firebase project identifier
FIREBASE_CREDENTIALS_B64 Host env / GitHub Secret Base64-encoded service account JSON
GOOGLE_APPLICATION_CREDENTIALS Set by entrypoint.sh Path to decoded credentials file
REDIS_HOST Compose static Redis hostname (redis_db in Docker)
REDIS_PORT Compose static Redis port (6379)
GAMEY Compose static Hostname of the game engine container (gamey)

users (Node.js, port 3000)

Variable Source Required Description
REDIS_HOST Compose static Redis hostname
REDIS_PORT Compose static Redis port
AUTH_URL Compose static URL of the Auth service
GAMEMANAGER_URL Compose static URL of the Game Manager
DEPLOY_HOST Host env / GitHub Secret Public hostname; added to the CORS allowlist as https://{DEPLOY_HOST}
NODE_ENV Not set in compose Set to production to enable secure cookies

webapp (nginx, port 80/443)

Variable Source Required Description
VITE_API_URL Build arg Base URL for all API calls, baked into the JS bundle at build time
DEPLOY_HOST Build arg Domain name baked into the nginx config via sed substitution
CERTBOT_EMAIL Compose static Email used by Let's Encrypt for certificate renewal notifications

webapp — Build-time Variables

The webapp is a static site built by Vite. Environment variables that start with VITE_ are inlined into the JavaScript bundle at build time — they do not exist at runtime and cannot be changed without rebuilding the image.

How Vite reads them

Inside the webapp/Dockerfile:

ARG VITE_API_URL="http://localhost:3000"
RUN export VITE_API_URL=${VITE_API_URL} && npm run build

The ARG is promoted to a shell variable with export, making it visible to Vite's build process. Vite replaces every occurrence of import.meta.env.VITE_API_URL in the source code with the literal string value at compile time.

The fallback in the source code handles local development (where no build arg is set):

const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';

The DEPLOY_HOST nginx substitution

The production nginx config contains a placeholder instead of a hard-coded domain:

ARG DEPLOY_HOST=localhost
COPY nginx.conf /tmp/nginx.conf.template
RUN sed "s/DEPLOY_HOST_PLACEHOLDER/${DEPLOY_HOST}/g" /tmp/nginx.conf.template \
       > /etc/nginx/user_conf.d/app.conf

sed replaces every occurrence of the literal string DEPLOY_HOST_PLACEHOLDER in the template with the value of DEPLOY_HOST. The resulting file is written to the path nginx reads at startup. This allows the same image to be built for any domain without modifying the nginx config manually.


Rust Services — Runtime Variable Consumption

The Rust services read environment variables directly at the point of use with std::env::var(). There is no central config loader — each function reads only what it needs, when it needs it.

// firebase.rs — read on every connection attempt
let project_id = env::var("FIREBASE_PROJECT_ID")
    .map_err(|_| "Environment variable FIREBASE_PROJECT_ID is not set")?;

// api_rest.rs — read once at startup
let redis_host = std::env::var("REDIS_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let redis_port = std::env::var("REDIS_PORT").unwrap_or_else(|_| "6379".to_string());
let gamey_host = std::env::var("GAMEY").unwrap_or_else(|_| "localhost".to_string());

FIREBASE_PROJECT_ID uses the ? operator — if missing, the connection attempt returns an error immediately. The Redis and gamey variables use unwrap_or_else with safe defaults so the service can still start in a local development environment without any environment configuration.

The GOOGLE_APPLICATION_CREDENTIALS variable is never read explicitly in Rust — it is consumed automatically by the firestore crate (via the underlying Google auth library) when the Firestore client is initialised.


Node.js Service — Runtime Variable Consumption

users-service.js reads variables at module load time, before any requests are handled:

const GAME_MANAGER_URL = process.env.GAMEMANAGER_URL || 'http://localhost:5000';
const AUTH_URL         = process.env.AUTH_URL         || 'http://localhost:4001';
const deployHost       = process.env.DEPLOY_HOST;      // undefined if not set

redis-client.js reads Redis connection details at client construction time:

const redisClient = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  enableOfflineQueue: false,
});

All variables fall back to localhost defaults, so the Node.js service can run directly with node users-service.js during development without any configuration. In production, Docker Compose overrides these defaults by injecting the correct values as container environment variables.

Clone this wiki locally