NLP · Bidirectional LSTM · ONNX · FastAPI · React · Neon · CI/CD

SMS Spam Classifier: Research‑to‑Production NLP Platform

An end-to-end SMS intelligence system built from the UCI/Kaggle corpus: exploratory text analysis and Optuna-tuned Bidirectional LSTM training lead into portable ONNX inference, a validated FastAPI service, encrypted Neon PostgreSQL persistence, a responsive React + TypeScript interface served by Nginx, Docker-based runtime parity, layered automated tests, and migration-first CI/CD deployments on Render.
Research Lab Web App App Repository Kaggle Dataset
Total SMS Messages
0
Ham Share
0
Spam Share
0
Best Validation Accuracy
0
Best Validation Loss
0
Learned Vocabulary Size
0
Exploration Blueprint

Project Overview

The EDA notebook profiles the SMS corpus, measures structural text properties, and surfaces the lexical patterns that separate legitimate messages from spam.

This exploratory notebook starts from the raw label and text columns, then builds a richer analytical layer around them. The analysis quantifies class imbalance, compares message lengths, studies token frequency, inspects n-grams, detects URLs / emails / phone numbers, and ranks the strongest TF-IDF signals for both classes. The result is a clear behavioral profile: ham messages are shorter, conversational, and dominated by everyday language, while spam messages are longer, more promotional, more numeric, and far more likely to contain marketing cues like free, claim, mobile, and prize.

Dataset scaleThe notebook works on 5,572 labeled SMS messages: 4,825 ham and 747 spam.
Key numerical signalAverage spam length reaches 138.87 characters versus only 71.02 for ham.
Operational purposeThe EDA findings directly justify later modeling choices such as class_weight, sequence truncation, and vocabulary-based text vectorization.
Infrastructure

EDA Libraries & Dependencies

Core tools used for text inspection, statistical summaries, and visualization.

Pandas & NumPyDataset loading, label counts, text-length statistics, feature aggregation, and class-wise summaries.
Regex, Counter & N-gramsURL, email, phone, duplicate-pattern detection, common-word extraction, bigrams, and trigrams.
Scikit-Learn TF-IDFTfidfVectorizer converts raw SMS text into weighted lexical features and ranks spam/ham-specific terms.
Matplotlib, Seaborn & WordCloudPie charts, histograms, boxplots, TF-IDF bar charts, and word-cloud visualizations.
Dataset Anatomy

Raw Fields & Engineered EDA Features

The notebook extends the original SMS dataset with interpretable text statistics and pattern flags.

Field / FeatureTypeRole in the EDA Notebook
labelTargetBinary class indicating whether a message is ham or spam.
textRaw textThe SMS body used for lexical analysis, word clouds, TF-IDF, n-grams, and downstream sequence modeling.
text_length, char_count, word_countNumericLength descriptors showing that spam messages are typically longer and denser than ham messages.
punct_count, num_count, upper_count, emoji_count, num_numbersNumericSurface-level text features that highlight promotional formatting, numerical codes, and punctuation-heavy spam behavior.
has_url, has_phone, has_emailBooleanRegex-based detection signals for external contact / redirection patterns common in unsolicited messages.
duplicate_texts and spam-specific vocabularyAnalyticalUsed to reveal repeated campaigns, shared templates, and uniquely spam-oriented vocabulary.
Exploratory Workflow

EDA Pipeline From Raw Messages to Text Signals

The notebook moves from descriptive statistics to semantic and pattern-based analysis.

01
Dataset ingestionLoad the Kaggle/UCI SMS dataset, retain v1 and v2, and rename them to label and text.
02
Class balance & length inspectionMeasure label counts, build the spam-vs-ham pie chart, and compare message lengths with histograms.
03
Text statisticsEngineer char_count and word_count, then compute overall and per-class descriptive summaries.
04
Lexical analysisUse stopword filtering, common-word ranking, word clouds, bigrams, and trigrams to identify conversational vs promotional language.
05
Regex feature miningDetect URLs, emails, phone numbers, numeric digits, HTML tags, and duplicated messages to expose spam templates.
06
TF-IDF feature rankingCompute class-wise average TF-IDF weights to surface the most discriminative words for ham and spam.
Diagnostic Signals

What the Numbers Say About Spam vs Ham

The strongest quantitative contrasts appear in message length, numeric content, and redirection patterns.

Length profile

Spam is much longer

The notebook reports 71.02 average characters and 14.20 average words for ham, versus 138.87 characters and 23.85 words for spam. That means spam messages are almost twice as long on average and carry more packed information per message.

Pattern mining

Spam contains far more machine-like artifacts

Regex-based checks show 104 spam URLs against only 2 ham URLs, 18 spam emails against 2 ham emails, and an average of 15.76 digits per spam message versus only 0.30 for ham.

Campaign repetition

Duplicate message behavior is visible

The notebook finds 684 duplicated texts, which is consistent with mass-sent campaigns and template-driven promotional spam. This is a useful operational clue because spam often scales by repeating a few message forms.

Lexical contrast

Everyday speech vs incentive language

Common ham tokens include u, go, come, know, and good. In contrast, spam heavily features call, free, txt, mobile, claim, and reply.

Visualization Gallery

EDA Visual Evidence

The notebook’s plots show class imbalance, structural differences, and the token-level contrast between spam and ham.

Class balance & structural length

These figures show the dataset skew toward ham and demonstrate how message length and digit density shift for spam.

Pie chart of ham vs spam distribution
Spam vs. Ham Distribution
Message length distribution for spam and ham
Message Length Distribution
Boxplot of number of digits in ham vs spam
Digit Count Contrast
Semantic word clouds

The clouds reveal the underlying language style of the two classes: casual personal texting for ham, incentive and redirection language for spam.

Ham messages word cloud
Ham Word Cloud
Spam messages word cloud
Spam Word Cloud
TF-IDF ranking

These bar charts summarize the most discriminative TF-IDF features in each class, making the lexical split more explicit.

Top ham TF-IDF features
Top Ham TF-IDF Features
Top spam TF-IDF features
Top Spam TF-IDF Features
Text Feature Insights

TF-IDF & N-gram Takeaways

The notebook captures both broad vocabulary differences and high-value spam phrases.

Top ham indicators

Ham TF-IDF is dominated by conversational tokens such as ok, ll, come, just, good, know, home, and later. These words reflect everyday coordination, social interaction, and informal speech.

  • Common bigrams: ll later, let know, good morning
  • Common trigrams: sorry ll later, happy new year

Top spam indicators

Spam TF-IDF is strongly anchored by free, txt, mobile, claim, prize, reply, won, cash, and urgent. These are classic conversion-oriented spam terms.

  • Common bigrams: po box, 1000 cash, prize guaranteed
  • Common trigrams: draw shows won, private 2003 account, urgent trying contact
EDA Implementation

Representative Feature Engineering Code

A compact excerpt showing how the notebook engineers structural features and TF-IDF summaries.

sms_spam_eda_features.py
# structural text features
df['char_count'] = df['text'].astype(str).apply(len)
df['word_count'] = df['text'].astype(str).apply(lambda x: len(x.split()))
df['num_numbers'] = df['text'].str.count(r'\d')
df['has_url'] = df['text'].str.contains(r'https?://\S+|www\.\S+', regex=True)
df['has_email'] = df['text'].str.contains(r'\S+@\S+', regex=True)

# class-wise TF-IDF diagnostics
tfidf = TfidfVectorizer(stop_words='english', max_features=5000)
tfidf_matrix = tfidf.fit_transform(df['text'])
features = tfidf.get_feature_names_out()

spam_tfidf_mean = tfidf_matrix[(df['label'] == 'spam').values].mean(axis=0).A1
ham_tfidf_mean  = tfidf_matrix[(df['label'] == 'ham').values].mean(axis=0).A1

def top_terms(scores, features, k=20):
    idx = np.argsort(scores)[-k:][::-1]
    return [(features[i], scores[i]) for i in idx]
EDA Synthesis

Exploration Conclusions

The EDA notebook establishes why spam detection is both a lexical and structural classification problem.

Class imbalance is real: spam represents only about 13.41% of the corpus, which makes imbalance-aware modeling essential.

Spam looks different at multiple levels: it is longer, more numeric, more repetitive, and more likely to contain URLs, marketing phrases, and urgency-driven wording.

EDA directly informs modeling: the later notebook’s use of class_weight, controlled sequence length, and learned vocabulary is strongly supported by the exploratory evidence gathered here.

Model Blueprint

Project Overview

The modeling notebook transforms raw SMS strings into padded token sequences and trains a tuned Bidirectional LSTM classifier for binary spam detection.

The second notebook upgrades the problem from descriptive analysis to a deployable text classification pipeline. It uses TextVectorization for vocabulary learning, StringLookup for label encoding, tf.data for efficient batching and prefetching, and balanced class weights to compensate for the heavy ham majority. The core architecture is a Bidirectional LSTM backed by an embedding layer and a tuned dense head. Instead of manual trial-and-error, the notebook runs an Optuna study over 30 trials to identify the most effective hyperparameter configuration before launching the final training stage.

Best Optuna Trial Accuracy
98.87%
Final Best Validation Loss
0.0564
Trainable Parameters
143,601
Training Strategy
Bi‑LSTM
Infrastructure

Modeling Libraries & Dependencies

Main libraries behind tokenization, sequence modeling, hyperparameter search, and training diagnostics.

TensorFlow / KerasTextVectorization, StringLookup, Embedding, Bidirectional LSTM, callbacks, metrics, and tf.data pipelines.
OptunaRuns the hyperparameter study over embedding size, LSTM units, dropout, dense layers, learning rate, and weight decay.
Scikit-LearnStratified train/validation split and balanced class-weight computation for the rare spam class.
TensorBoard & Keras CallbacksTraining logs, early stopping, checkpoint saving, and dynamic learning-rate reduction.
System Workflow

From Raw Strings to Binary Predictions

The modeling notebook organizes preprocessing, tuning, and final training as a staged NLP pipeline.

01
Environment setupInitialize TensorFlow 2.20, detect the execution strategy, and establish deterministic seeds for reproducibility.
02
Dataset splittingSplit the corpus with train_test_split using TEST_SPLIT_RATIO = 0.2 and stratification to preserve class balance.
03
Text & label transformationLearn a vocabulary with TextVectorization, encode targets with StringLookup, and build optimized tf.data pipelines.
04
Imbalance correctionCompute balanced class weights: {0: 0.5774, 1: 3.7296}, giving more importance to spam examples during training.
05
Optuna searchExplore embedding size, LSTM units, dropout, dense-layer depth, learning rate, and weight decay across 30 trials.
06
Final convergenceRetrain the champion configuration with callbacks such as EarlyStopping, ReduceLROnPlateau, and ModelCheckpoint.
Sequence Mechanics

Core Modeling Theory

The notebook combines neural sequence modeling with binary decision theory and Bayesian search.

Embedding Projection
Representation
$$x_t \rightarrow e_t \in \mathbb{R}^{d}$$
Each token index produced by TextVectorization is mapped into a dense semantic vector. In the best run, the learned embedding dimension is 16.
Bidirectional LSTM
Context Modeling
$$h_t = [\overrightarrow{h_t};\ \overleftarrow{h_t}]$$
The Bi-LSTM reads the message from both left-to-right and right-to-left, allowing the classifier to capture context from both preceding and following words.
Binary Cross-Entropy
Optimization
$$\mathcal{L} = -\left[y\log(\hat{y}) + (1-y)\log(1-\hat{y})\right]$$
Because the task is binary, the final dense layer uses a sigmoid output and trains against binary cross-entropy, while precision, recall, AUC, and accuracy are all monitored.
Optuna Search Logic
Bayesian Tuning
$$\theta^* = \arg\max_{\theta \in \Theta}\ \mathrm{ValAccuracy}(\theta)$$
Optuna’s TPE sampler searches the hyperparameter space efficiently, replacing manual tuning with a data-driven search over architecture and optimizer controls.
Architecture Visual

LSTM Cell Intuition

The provided diagram highlights the gating structure that lets LSTMs manage long-range sequence memory.

LSTM architecture diagram
LSTM Architecture Reference
Interpretation: the forget, input, candidate-memory, and output gates make the LSTM especially suitable for text classification because they help preserve or discard information across the message sequence instead of treating words as isolated tokens.
Configuration Layer

Model Parameters & Training Controls

The notebook keeps the configuration explicit and reproducible across preprocessing, search, and final training.

ComponentChosen ValueWhy It Matters
MAX_LENGTH100Every SMS is padded or truncated to a fixed sequence length, making batching consistent.
VOCAB_SIZE10000 (actual learned size: 8439)Caps vocabulary growth while still preserving most useful tokens in the corpus.
BATCH_SIZE128Used in the optimized tf.data pipeline for throughput and stable updates.
Train / Validation Split80% / 20%Provides a held-out set for tuning and callback-driven training decisions.
Best Embedding Dim16Compact semantic representation learned jointly with the classifier.
Best LSTM Units16Controls sequence modeling capacity in the Bi-LSTM encoder.
Best Dense Head1 dense layer with 128 unitsActs as the final nonlinear decision layer before binary prediction.
Best Dropout Rate0.4442Regularizes the post-LSTM representation and reduces overfitting.
Best Learning Rate0.0003231Tuned to balance convergence speed and stability.
Best Weight Decay0.0006193Supports generalization by softly penalizing overly large weights.
Optimization Review

Hyperparameter Search & Final Performance

Optuna identifies the champion model, then the final training stage sharpens its validation performance.

Best Trial
Optuna Champion Configurationembedding_dim=16, lstm_units=16, dropout=0.4442, dense_layers=1, dense_0=128
Validation Accuracy
0.9887
Learning Rate
3.23e-4
Final Fit
Best validation checkpointDuring the final training run, the model reaches its lowest validation loss at approximately epoch 11 and stores the best checkpoint automatically.
Val Loss
0.0564
Val Accuracy
0.9878
Best Epoch
Minority-class quality remains highAt the strongest checkpoint the model preserves not only accuracy but also precision and recall, which is critical for the rare spam class.
Val Precision
0.9732
Val Recall
0.9355
Important context: the notebook also monitors AUC, with best validation AUC values around 0.99. Combined with class weighting, this shows the model is not simply learning the majority class but genuinely separating spam from ham.
Implementation Core

Key Modeling Code Snippets

Two excerpts capture the pipeline’s transformation logic and the final neural architecture.

preprocessing_pipeline.py
# learn the vocabulary from raw training text
def text_vectorize(train_dataset):
    vectorizer = tf.keras.layers.TextVectorization(
        max_tokens=10000,
        output_sequence_length=100,
        standardize='lower_and_strip_punctuation'
    )
    text_only_dataset = train_dataset.map(lambda text, label: text).batch(64)
    vectorizer.adapt(text_only_dataset)
    return vectorizer

# encode text + labels and optimize the tf.data pipeline
def preprocessing(dataset, text_vectorizer, label_encoder):
    dataset = (dataset
        .map(lambda text, label: (text_vectorizer(text), label_encoder(label)))
        .filter(lambda x, y: tf.shape(x)[0] == 100)
        .shuffle(1024)
        .batch(128, drop_remainder=True)
        .prefetch(tf.data.AUTOTUNE)
    )
    return dataset
bidirectional_lstm_model.py
def create_final_model(hparams):
    inputs = tf.keras.Input(shape=(100,))
    x = tf.keras.layers.Embedding(10000, hparams['embedding_dim'])(inputs)
    x = tf.keras.layers.Bidirectional(
        tf.keras.layers.LSTM(hparams['lstm_units'])
    )(x)
    x = tf.keras.layers.Dropout(hparams['dropout_rate'])(x)

    for i in range(hparams['num_dense_layers']):
        units = hparams[f'dense_layers_{i}']
        x = tf.keras.layers.Dense(units, activation='relu')(x)

    outputs = tf.keras.layers.Dense(1, activation='sigmoid')(x)
    return tf.keras.Model(inputs, outputs)

optimizer = tf.keras.optimizers.AdamW(
    learning_rate=3.230576384522256e-4,
    weight_decay=6.193087602825927e-4
)
Training Strategy

Callback Design & Robust Optimization

The notebook protects the final model with early stopping, checkpointing, learning-rate decay, and TensorBoard logging.

Overfitting control

EarlyStopping

The notebook monitors val_loss with patience=10 and restores the best weights, preventing the network from drifting after its strongest validation phase.

Best-model retention

ModelCheckpoint

The best-performing checkpoint is saved to SMS_Spam_best_model.keras, ensuring that the lowest-validation-loss model survives regardless of how later epochs behave.

Learning dynamics

ReduceLROnPlateau

Whenever validation loss stalls, the learning rate is multiplied by 0.3, which helps the model settle into a better local optimum rather than oscillating.

Observability

TensorBoard logging

Training histories are exported to TensorBoard logs so the run can be inspected, compared, and audited beyond just the final metric snapshot.

Model Synthesis

Final Conclusion & Performance Summary

The modeling notebook turns the EDA findings into a strong, well-regularized sequence classifier.

End-to-end result: the project advances from raw SMS strings to a tuned Bidirectional LSTM with 98.87% best validation accuracy and a best recorded validation loss of 0.0564.

Why it works: the model design aligns closely with the EDA findings — it respects class imbalance, leverages tokenized sequential context, and optimizes for minority-class sensitivity with precision / recall monitoring.

Engineering takeaway: this notebook is more than a simple baseline. It demonstrates a clean NLP workflow with dataset splitting, vocabulary learning, label encoding, TensorFlow data pipelines, Optuna tuning, callback-driven convergence, and saved checkpoint artifacts.

Possible next steps:

  • Compare the Bi-LSTM against GRU, CNN-text, or Transformer baselines.
  • Evaluate threshold tuning for spam recall vs false positives.
  • Export the saved model and preprocessing artifacts for a lightweight inference API.
  • Test character-level or subword tokenization for noisy SMS variants.
Backend Infrastructure

FastAPI Tools & Libraries

The production API combines validated HTTP contracts, lightweight ONNX inference, transactional persistence, and startup-time dependency checks.

FastAPI & UvicornFastAPI defines the typed HTTP interface and OpenAPI documentation, while Uvicorn serves the ASGI application in development and production containers.
Pydantic 2Validates incoming SMS text and the public prediction response, enforcing non-empty input, a 1,000-character limit, valid labels, and probabilities between 0 and 1.
ONNX Runtime & NumPyLoad the exported neural network, transform message tokens into model-ready arrays, and execute portable CPU inference without the full TensorFlow training stack.
SQLAlchemy, Psycopg & CryptographyCoordinate request-scoped database sessions, PostgreSQL connectivity, repository transactions, and authenticated Fernet encryption before any message reaches storage.
Request Lifecycle

From HTTP Request to Persisted Prediction

Inference and persistence are deliberately separated into middleware, route, predictor, service, and repository layers.

01
Bound and identify the requestThe ASGI body-limit middleware rejects payloads above 16,384 bytes, and request-ID middleware assigns a UUID returned in the X-Request-ID response header.
02
Validate the contractPredictRequest strips surrounding whitespace and rejects empty or oversized SMS input before inference begins.
03
Normalize and inferThe predictor applies the saved preprocessing contract, runs the ONNX graph, and compares the spam probability against the configurable default threshold of 0.5.
04
Encrypt and transactThe service encrypts the original message in memory, creates a prediction record through the repository, commits once, and rolls back cleanly on persistence errors.
05
Return a minimal responseThe public response exposes only label and spam_probability; internal ciphertext and database details never leave the backend.
API Contract & Reliability

Routes, Startup Gates & Failure Boundaries

The API fails early when artifacts or secrets are invalid and returns consistent, traceable responses during normal operation.

SurfacePurposeProduction Behavior
GET /MetadataReturns the service name, version 1.0.0, and the OpenAPI documentation path at /docs.
GET /healthReadinessExecutes a PostgreSQL readiness query, so Render only treats the service as healthy when the API and database path both work.
POST /predictInferenceClassifies one validated SMS, saves an encrypted record, and returns the label plus spam probability.
Application lifespanStartupValidates artifact paths, creates and warms the predictor, verifies the Fernet key, and disposes pooled database connections on shutdown.
Error handlersSafetyTranslate validation, encryption, inference, and persistence failures into a stable error envelope without leaking plaintext or internal exception details.
Portable runtimeThe Docker image serves the ONNX artifact instead of importing the complete training environment.
TraceabilityThe same request UUID links the HTTP response, server logs, and the corresponding PostgreSQL row.
ConfigurationDatabase URL, encryption key, threshold, request size, message length, and connection timeout are supplied through environment variables.
Runtime Contract

Inference Architecture & Configuration Snapshot

The deployed backend is intentionally smaller than the research environment: three model artifacts, one ONNX session, one database pool, and one encrypted write per prediction.

ClientJSON body with one SMS text field.
Middleware16 KB body cap and UUID correlation.
ONNXNormalize, tokenize, infer, threshold.
PersistenceEncrypt, flush, commit, return result.
Public Routes
3
Decision Threshold
0.50
Max SMS Length
1,000
Max Body Size
16 KB
Python 3.13 FastAPI 0.117.1 ONNX Runtime 1.26 Uvicorn 0.40 Pydantic 2.12 CPU inference
Implementation Lab

Route, Lifespan & Safe Error Code

Representative production code shows where inference is validated, where dependencies are warmed, and how internal exceptions become stable client responses.

app/api/routes.py
@router.post("/predict")
def predict(payload, request, session):
    result = PredictionResponse.model_validate(
        request.app.state.predictor.predict(payload.text)
    )
    PredictionService(
        session=session,
        repository=PredictionRepository(session),
        message_cipher=request.app.state.message_cipher,
    ).save_prediction(
        request_id=UUID(request.state.request_id),
        message=payload.text,
        label=result.label,
        spam_probability=result.spam_probability,
        threshold=request.app.state.predictor.threshold,
    )
    return result
app/main.py
@asynccontextmanager
async def lifespan(app):
    validate_artifact_paths()
    predictor = create_predictor()
    warmup_predictor(predictor)
    cipher = MessageCipher(
        AppConfig.get_message_encryption_key()
    )
    app.state.predictor = predictor
    app.state.message_cipher = cipher
    try:
        yield
    finally:
        database.dispose()
FailurePublic StatusStable CodeWhat Remains Private
Invalid request422validation_errorFramework internals and rejected data beyond safe field feedback.
Oversized body413request_too_largeBuffered body content.
Inference failure503prediction_unavailableModel exception and stack trace.
Persistence failure503persistence_unavailableSQL, credentials, parameters, and submitted plaintext.
Readiness failure503database_unavailableNeon hostname, driver exception, and connection details.
Design principle: detailed tracebacks stay in server logs, while the client receives a stable machine-readable code, a safe explanation, and the same request ID used to locate the corresponding log event.
Persistence Infrastructure

Database Tools & Libraries

Managed PostgreSQL, explicit migrations, pooled application connections, and application-level encryption form the persistence layer.

Neon PostgreSQLProvides the managed production database independently from Render, with TLS-required public connection strings and scale-to-zero behavior suitable for the demo workload.
SQLAlchemy 2Defines the typed prediction model, check constraints, session lifecycle, repository boundary, and explicit transaction commit or rollback behavior.
Psycopg 3Supplies the PostgreSQL driver used by SQLAlchemy; standard Neon URLs are normalized to the postgresql+psycopg dialect at runtime.
AlembicVersions the schema, upgrades fresh and existing databases, verifies migration state in CI, and applies production changes before a new API release is triggered.
Fernet CryptographyEncrypts each original SMS with authenticated symmetric encryption; only ciphertext is given to the repository or stored in PostgreSQL.
Schema Contract

The predictions Record

The schema keeps the minimum useful audit metadata while protecting the submitted message body.

ColumnType / ConstraintReason
idInteger PKInternal database identity for the prediction row.
request_idUnique UUIDCorrelates the database record with the API response and request logs while preventing duplicate request records.
message_ciphertextLargeBinaryStores authenticated Fernet bytes; plaintext is never sent to PostgreSQL.
labelham / spamA check constraint restricts the public classification to the two supported labels.
spam_probabilityFloat 0–1Preserves the model confidence used to form the public result.
thresholdFloat 0–1Records the exact decision boundary used at prediction time.
message_lengthPositive integerRetains safe operational metadata without exposing message content.
created_atTimezone timestampUses the PostgreSQL server clock for a consistent creation time.
Security & Migration Flow

Pooled Runtime, Direct Migrations & Reversible Encryption

The application and deployment workflow use different connection modes for different jobs, while one protected key governs message recovery.

01
Serve through the pooled URLThe long-running API uses Neon’s pooled connection endpoint so concurrent web requests share database resources efficiently.
02
Migrate through the direct URLGitHub Actions injects NEON_DIRECT_DATABASE_URL and runs alembic upgrade head before asking Render to deploy application code.
03
Encrypt before the repositoryFernet produces authenticated ciphertext in application memory; plaintext never crosses the persistence boundary.
04
Recover only with the same keyThe original SMS can be decrypted later by authorized application code using the unchanged MESSAGE_ENCRYPTION_KEY; losing or rotating it without a migration makes old ciphertext unreadable.
05
Verify the stateCI applies migrations, runs alembic check, persists a smoke-test prediction, and queries the table to confirm the row exists.

Separation of responsibilities: Neon owns durable PostgreSQL operation, Alembic owns schema evolution, SQLAlchemy owns application transactions, and Fernet protects sensitive message content before storage.

Important operational rule: database URLs and the encryption key remain environment secrets. They belong in local uncommitted .env files or host secret stores, never in Git history or browser code.

Connection Topology

Local PostgreSQL, Neon Pooling & Alembic Direct Access

Three connection contexts use the same schema but different lifecycles: persistent local development, pooled web traffic, and short-lived migration sessions.

Local Compose Database
postgres:16

A named volume preserves developer data. The migrate container waits for pg_isready, upgrades to Alembic head, exits successfully, and only then allows the API to start.

Neon Pooled Runtime
API traffic

The Render API uses Neon’s pooler hostname. SQLAlchemy adds pool_pre_ping, a bounded pool checkout timeout, a network connect timeout, and hidden parameters in engine logs.

Neon Direct Migration
Alembic

GitHub Actions uses the direct production URL with Alembic’s NullPool. The connection exists only for schema work and is closed before Render deployment begins.

URL Normalization
Psycopg 3

postgres:// and postgresql:// provider URLs are normalized to postgresql+psycopg://, keeping deployment configuration compatible with SQLAlchemy 2.

ContextConnection StrategyLifetimeFailure Policy
API requestPooled engine + request-scoped sessionSession closes after request; engine survives processRollback incomplete work and return safe 503.
Health checkSELECT 1 through engineOne round trip per readiness probeService becomes unhealthy if PostgreSQL cannot answer.
Local migrationCompose migrate serviceOne-shot containerAPI dependency remains blocked on non-zero exit.
Production migrationDirect Neon URL + NullPoolOne GitHub Actions stepRender hooks are never called when Alembic fails.
Persistence Lab

Encryption, Transactions & Useful SQL

The data path makes plaintext exposure difficult by construction and keeps enough metadata for auditing, debugging, and aggregate monitoring.

service transaction
ciphertext = message_cipher.encrypt(message)
try:
    prediction = repository.create(
        request_id=request_id,
        message_ciphertext=ciphertext,
        label=label,
        spam_probability=probability,
        threshold=threshold,
        message_length=len(message),
    )
    session.commit()
except SQLAlchemyError:
    session.rollback()
    raise PersistenceError(
        "Prediction transaction failed."
    )
Neon SQL editor
SELECT
    request_id,
    label,
    ROUND(spam_probability::numeric, 4) AS probability,
    threshold,
    message_length,
    octet_length(message_ciphertext) AS cipher_bytes,
    created_at
FROM predictions
ORDER BY created_at DESC
LIMIT 25;
Authenticated encryptionFernet detects modified or corrupted ciphertext and raises a dedicated decryption error instead of returning damaged plaintext.
Key continuityThe same key can recover the original message later. A planned rotation must decrypt and re-encrypt old rows before the previous key is retired.
Safe analyticsLabel, probability, threshold, length, timestamp, and ciphertext size support monitoring without decrypting message bodies.
Why ciphertext can be longer than the SMS: Fernet stores versioning, timestamp, initialization material, authentication data, padding, and URL-safe base64 representation in addition to the encrypted message bytes.
Frontend Infrastructure

UI Tools & Libraries

The interface is a typed React application built as static assets and served by a small Nginx production container.

React 19 & React DOMProvide the component model, state-driven prediction experience, responsive navigation, inspector panels, theme controls, and reusable research views.
TypeScript 5Adds static contracts for API payloads, prediction state, navigation data, UI components, and build-time safety across the frontend.
Vite 7Runs the fast local development server and compiles an optimized production bundle after TypeScript validation.
TanStack React Query & ZodManage server state, loading and error transitions, and runtime validation of health and prediction responses crossing the API boundary.
Lucide React & React IconsSupply consistent product, navigation, GitHub, notebook, research, contact, theme, and status iconography with accessible labels.
NginxServes the compiled SPA, exposes a lightweight frontend health endpoint, caches hashed assets, and proxies same-origin /api requests to FastAPI.
Experience Architecture

One Interface for Prediction, Data & Training Context

The UI turns a simple classifier endpoint into an explorable product without moving training or inference logic into the browser.

01
App workspaceThe default route presents the SMS composer, example messages, submit action, probability meter, classification result, request ID, encrypted-save confirmation, and session-only activity list.
02
Dataset workspaceSummarizes corpus size, ham/spam balance, learned vocabulary, message patterns, and source context from the research notebooks.
03
Training workspacePresents model architecture, split strategy, metrics, threshold, runtime artifact information, and a source-of-truth link to the training repository.
04
Same-origin API callThe browser posts to /api/predict; Nginx removes the prefix and forwards the request securely to the Render API host, avoiding client-side production host configuration.
05
Validated result renderingZod checks the response before React updates the probability, label, inspector facts, and session metadata. The submitted plaintext is not retained in browser history.
Responsive & Accessible UI

Desktop Shell, Mobile Navigation & Interaction States

The interface adapts its navigation and detail surfaces while preserving keyboard access, readable contrast, and explicit feedback.

Desktop

Three-area application shell

A persistent sidebar orders App, Datasets, Training; the central canvas holds the active workspace; and a right inspector presents the selected prediction or research context.

Mobile

Compact route navigation

The layout removes desktop-only chrome, keeps core actions reachable, and converts secondary detail into a mobile-friendly sheet or stacked section without horizontal page overflow.

Accessibility

Keyboard and semantic controls

Interactive icons have names or tooltips, focus remains visible, routes are keyboard reachable, the search shortcut uses a single-line K hint, and live result states remain understandable without color alone.

Themes

Dark, light & high contrast

Theme controls preserve legibility across neutral surfaces, status colors, cards, charts, borders, and focus indicators while respecting reduced-motion preferences in tested interactions.

Status

Honest server-state feedback

The API badge, loading action, validation message, error state, probability result, persistence confirmation, and request ID make each stage of a prediction visible to the user.

External context

Source, notebooks & research

Header links connect the deployed product to the application repository, training notebooks, and this research page without mixing those destinations into primary navigation.

Product State Model

Prediction UX From Idle to Result or Recovery

The frontend treats prediction as a visible state machine, not a single button click, so latency and free-tier wake-up behavior remain understandable.

ComposeType or select a realistic SMS example.
PendingDisable duplicate submits and show progress.
ValidateZod checks label and probability.
ExplainLabel, meter, threshold, save state, request ID.
Ready
API online

Health polling reports a ready service, the composer is enabled, example chips can populate the textarea, and the current message count remains visible.

Sleeping / Unavailable
free tier

The UI explains that the API may be waking and does not pretend the browser or model has failed permanently. A later health refresh can recover naturally.

Spam Result
probability ≥ 0.5

The result region announces “Spam detected,” emphasizes the probability, places it against the threshold, confirms encrypted persistence, and exposes the correlation ID.

Recoverable Error
message preserved

A friendly alert appears without clearing the textarea, letting the user retry after a temporary API or storage failure instead of re-entering the message.

Frontend Implementation

Typed API Boundary, Responsive Contract & Privacy

The browser knows the public contract, while Nginx owns production routing and the backend owns model and persistence logic.

frontend/src/api.ts
const predictionSchema = z.object({
  label: z.enum(["ham", "spam"]),
  spam_probability: z.number().min(0).max(1),
});

export async function createPrediction(text: string) {
  const response = await fetch("/api/predict", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text }),
  });
  const result = predictionSchema.parse(
    await response.json()
  );
  return {
    ...result,
    requestId: response.headers.get("x-request-id"),
  };
}
responsive behavior
Desktop
  icon rail + labeled sidebar
  main workspace + right inspector
  sidebar can be hidden and restored

Mobile
  desktop rail hidden
  compact primary navigation visible
  cards become one-column
  selected details open as a sheet
  touch targets remain at least 44px

All viewports
  App → Datasets → Training
  dark → light → high contrast
  K focuses global search
Frontend DataLifetimeStorage LocationPrivacy Decision
Typed SMS textCurrent interactionReact component memorySent to the API only when the user submits.
Prediction metadataCurrent page sessionIn-memory activity listLabel, probability, time, and request ID can be shown without retaining plaintext.
Original messageServer-side durable recordNeon as Fernet ciphertextNever written to browser storage or rendered in history.
ThemeDevice preferenceUI stateNon-sensitive presentation preference only.
Research metricsBuild-time staticFrontend bundlePublic notebook-derived facts; no database request needed.
Semantic navigation Named icon controls Visible focus WCAG A/AA scans Reduced-motion aware
Quality Infrastructure

Testing Tools & Libraries

The project tests Python behavior, database integration, React interactions, browser workflows, accessibility, and the composed container deployment.

Pytest, HTTPX & ONNXExercise preprocessing, predictor contracts, API routes, middleware, encryption, repositories, migrations, smoke behavior, and model artifact compatibility.
Vitest & JSDOMRun fast TypeScript unit and component tests in a browser-like environment as the frontend’s default test command.
React Testing Library & User EventTest the application through visible roles, labels, routes, typing, submission, theme controls, and navigation instead of component internals.
Mock Service WorkerIntercepts frontend API calls with realistic health and prediction responses, making UI tests deterministic without writing to Neon.
Playwright ChromiumChecks the production-style build at desktop and mobile viewports, including navigation, prediction flows, responsive layout, and browser-visible behavior.
Axe Core for PlaywrightScans dark, light, and high-contrast routes for detectable WCAG A and AA violations during browser testing.
Coverage Map

What Each Test Layer Proves

Every layer answers a different reliability question, from a pure function to the full Docker Compose stack.

LayerMain ScopeFailure It Catches
Backend unit testsPure behaviorText normalization drift, invalid artifact metadata, threshold mistakes, cipher errors, and configuration edge cases.
API testsHTTP contractWrong status codes or response shapes, missing request IDs, validation regressions, body-limit failures, and unsafe error leakage.
Database integrationPersistenceBroken migrations, invalid constraints, failed commits or rollbacks, duplicate request IDs, and missing encrypted prediction rows.
Frontend unit/componentInteractionBroken routing, inaccessible controls, stale loading states, malformed API handling, missing prediction results, and incorrect theme behavior.
Browser & accessibilityUser journeyDesktop/mobile layout regressions, failed real-browser navigation, focus and semantic issues, and detectable WCAG A/AA violations.
Deployment smokeContainersImages that build but cannot start together, migrations that do not complete, unhealthy services, broken prediction calls, or absent database persistence.
Release Confidence

Test Isolation, Real Dependencies & Deployment Gates

Fast tests remain isolated, integration tests use disposable infrastructure, and production cannot deploy until both paths pass.

01
Validate configurationCI first verifies Docker Compose parsing and prepares only non-sensitive defaults from .env.example.
02
Test Python with PostgreSQLA disposable PostgreSQL 16 service receives Alembic migrations before the backend suite and application smoke test run.
03
Test and build the UInpm ci, Vitest, the TypeScript/Vite production build, Playwright Chromium, and axe checks run as an independent job.
04
Smoke the deployable stackDocker Compose starts PostgreSQL, the one-shot migration container, and the API image; a real prediction is sent and its database row is verified.
05
Keep test data disposableCompose logs are printed on failure, then containers and the isolated CI volume are removed. Frontend tests use MSW and never contact production Neon.

Gate design: production deployment depends on the runtime tests, frontend tests, and composed deployment smoke test. A green isolated test suite alone is not enough.

Representative verified frontend scope: unit/component tests cover navigation and prediction behavior, while Playwright covers applicable desktop and mobile journeys with intentional viewport-specific skips where appropriate.

Test Inventory

Repository Coverage by Concern

The suite contains 91 declared Python and TypeScript test cases across API, integration, unit, smoke, component, browser, responsive, and accessibility concerns.

Declared Test Cases
91
Backend Test Groups
4
Frontend Test Layers
2
Axe Theme Scans
3
AreaRepresentative FilesImportant Assertions
APItest_predict.py, test_health.py, test_error_handling.pySuccess contract, schema limits, request IDs, 413 body cap, safe 503 shapes, and readiness.
ML runtimetest_predictor.py, test_onnx_model.py, test_artifacts.pyArtifact integrity, preprocessing agreement, valid probabilities, threshold logic, and portable runtime imports.
Persistencetest_database_persistence.py, test_prediction_repository.py, test_prediction_service.pyEncrypted writes, lookup paths, commits, rollbacks, constraints, and error translation.
Security/configtest_message_cipher.py, test_config.py, test_environment_template.pyFernet round trips, invalid tokens, required secrets, URL normalization, and safe example configuration.
React componentApp.navigation.test.tsx, App.prediction.test.tsxRoute order, search shortcut, themes, sidebar state, links, pending/result/error states, and malformed responses.
Browsere2e/app.spec.tsDesktop/mobile navigation, full prediction journey, mobile details sheet, and WCAG A/AA scans in three themes.
Executable Examples

Behavior Tests & Developer Shortcuts

Tests describe product behavior in readable code, while Makefile targets keep local execution aligned with CI.

frontend prediction test
it("renders a spam prediction", async () => {
  server.use(
    http.post("*/api/predict", () =>
      HttpResponse.json(
        { label: "spam", spam_probability: 0.9123 },
        { headers: { "x-request-id": requestId } },
      ),
    ),
  );

  await user.type(messageBox, "Claim your free prize");
  await user.click(analyzeButton);

  expect(await screen.findByText("Spam detected"))
    .toBeVisible();
  expect(screen.getByText("Encrypted record saved"))
    .toBeVisible();
});
quality commands
make test-unit
make test-api
make test-integration
make test-smoke
make test-local

make frontend-test
make frontend-test-watch
make frontend-test-e2e
make frontend-check

docker compose config --quiet
docker compose up --build --wait
Determinism

Mock only the network edge

MSW returns realistic JSON and headers while React Query, Zod, rendering, events, and application state remain real. This catches client bugs without depending on a sleeping host.

Integration

Use real PostgreSQL where it matters

Database tests and deployment smoke tests run against PostgreSQL rather than replacing SQLAlchemy behavior with a mock that could hide migration or dialect problems.

Accessibility

Combine automation with semantics

Testing Library queries by roles and names, Playwright checks real viewport behavior, and axe finds detectable violations across dark, light, and high-contrast themes.

Artifacts

Test the shipped model contract

The suite loads checked-in ONNX and preprocessing artifacts so CI can detect missing files, incompatible metadata, or runtime-only import regressions before image deployment.

Automation Infrastructure

CI/CD Tools & Services

One GitHub Actions workflow validates code and containers, migrates Neon, and triggers both Render services in a controlled order.

GitHub ActionsRuns the unified CI/CD workflow on pushes and pull requests targeting main, with workflow_dispatch retained for deliberate manual runs.
Docker & Docker ComposeValidate configuration, build the deployable image, run the API and migration services against PostgreSQL, wait for health, and verify end-to-end persistence.
PostgreSQL 16 & AlembicGive CI a real relational dependency and ensure schema upgrades are valid locally, in the composed stack, and against production Neon.
Python 3.13 & Node.js 22Provide pinned build environments for backend dependencies, Pytest, TypeScript compilation, Vitest, Vite, Playwright, and accessibility scans.
Render Deploy HooksDecouple Render’s build trigger from source pushes so deployments start only after migrations and all release gates succeed.
Pipeline Topology

From Commit to Production Release

Independent runtime and frontend checks converge on a deployable-stack smoke test before the production job receives control.

01
Trigger the workflowA push to main, pull request to main, or authorized manual dispatch starts the same declared pipeline.
02
Run parallel CI jobsruntime-tests validates Python, migrations, and API behavior while frontend-tests validates React, the production build, browser flows, and accessibility.
03
Exercise Docker Composedeployment-smoke depends on both CI jobs, builds the API image once, starts the stack, confirms the migration exit code, sends a prediction, and checks persistence.
04
Protect pull requestsPull requests stop after validation. Only non-PR events may enter the production environment and use deployment secrets.
05
Migrate production firstThe deploy job validates secret presence, installs runtime dependencies, runs alembic upgrade head, and reports the current Neon revision.
06
Trigger both Render servicesAfter the database is compatible, authenticated hook URLs start the API deployment and then the frontend deployment.
Secrets & Release Safety

Production Inputs, Concurrency & Ownership

Sensitive values remain in scoped secret stores, while committed files define only names, defaults, and reproducible release behavior.

Secret / ControlUsed ByRole
MESSAGE_ENCRYPTION_KEYTests & APIInitializes Fernet for encrypted persistence; the real value is never committed.
NEON_DIRECT_DATABASE_URLProduction migrationConnects Alembic directly to Neon before any Render release begins.
RENDER_API_DEPLOY_HOOK_URLCDStarts the API build only after successful production migration.
RENDER_FRONTEND_DEPLOY_HOOK_URLCDStarts the Nginx/React frontend build after all release checks pass.
production environmentGitHubScopes deployment credentials and provides a natural place for future reviewers or approval rules.
Concurrency groupRelease orderingcancel-in-progress: false prevents a newer run from interrupting an in-flight production migration or deployment.
Render auto-deploy offRenderPrevents Render from bypassing GitHub Actions and deploying code before tests or Alembic complete.

Why the workflow is unified: CI and CD live in one dependency graph, so the production job cannot accidentally run without the exact runtime, frontend, and container checks defined for the same commit.

Why migrations are not a permanent service: Alembic is a one-shot release action. It runs whenever a schema-changing version is deployed; if no code changes for months, there is no new schema to apply.

Job Dependency Graph

Parallel Validation, Converged Smoke Test, Serialized Release

The workflow is a dependency graph: two fast quality branches meet at a real container test, and only that combined result can unlock production.

CommitPush, pull request, or manual dispatch.
Parallel CIRuntime tests + frontend tests.
Compose SmokeMigrate, predict, query persisted row.
ProductionNeon migration, API hook, frontend hook.
Job 1
Runtime testsPython 3.13, PostgreSQL 16, Compose validation, Alembic upgrade/check, Pytest suite, application smoke.
Needs
DBPostgres
Outputgate
Job 2
Frontend testsNode 22, deterministic install, Vitest, TypeScript/Vite build, Playwright Chromium, and axe accessibility.
Needs
BrowserChromium
Outputgate
Job 3
Deployment smokeBuild once, start the stack, verify the one-shot migration exit code, exercise the API, and count prediction rows.
Needs1 + 2
StackCompose
Outputrelease
Job 4
Production deploySkip pull requests, validate four secrets, migrate Neon, and call the two Render deploy hooks without cancelling a release in progress.
Needs3
Envproduction
OrderDB → apps
Workflow Source

Triggers, Dependencies & Migration-First CD

The workflow file makes release policy executable instead of relying on a manual checklist.

.github/workflows/ci-cd.yml
name: CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

jobs:
  runtime-tests:
  frontend-tests:
  deployment-smoke:
    needs: [runtime-tests, frontend-tests]
  production-deploy:
    needs: deployment-smoke
    if: github.event_name != 'pull_request'
production release steps
- name: Apply production migrations
  env:
    DATABASE_URL: ${{ secrets.NEON_DIRECT_DATABASE_URL }}
  run: |
    alembic upgrade head
    alembic current

- name: Trigger Render API
  run: curl --fail --request POST \
    "$RENDER_API_DEPLOY_HOOK_URL"

- name: Trigger Render frontend
  run: curl --fail --request POST \
    "$RENDER_FRONTEND_DEPLOY_HOOK_URL"
Pull request behaviorEvery quality and deployment-smoke check runs, but production secrets and deploy hooks remain inaccessible because the release job is skipped.
Main-branch behaviorThe same checked commit proceeds automatically from tests to migration and both deployments—this is continuous delivery/deployment under one workflow.
Manual behaviorworkflow_dispatch provides an explicit rerun path for recovery or verification without changing the normal push-to-main release policy.
Hosting Infrastructure

Deployment Tools & Services

Two Render web services and one Neon database turn the repository into a public, independently scalable application.

Docker Multi-Stage ImagesThe API image installs only runtime Python dependencies and model artifacts; the frontend image builds with Node.js 22 Alpine and copies only compiled assets into Nginx 1.27 Alpine.
Render Web ServicesHosts sms-spam-api and sms-spam-frontend as separate Docker services with independent health checks, logs, URLs, environment variables, and deploy hooks.
Render Blueprintrender.yaml declares both service names, Docker contexts, health endpoints, free plans, Ohio region, non-secret defaults, and intentionally disabled automatic deploys.
Nginx 1.27 AlpineServes the React SPA, falls back to index.html for client routes, caches immutable assets for one year, and reverse-proxies API traffic over HTTPS.
Neon Managed PostgreSQLKeeps durable prediction data outside ephemeral web containers and allows the API and GitHub Actions migration job to connect from different hosts.
Production Architecture

Browser → Nginx → FastAPI → Neon

The frontend, API, and database are separate services connected through public TLS endpoints and narrowly scoped environment configuration.

01
Open the React applicationThe browser loads https://sms-spam-frontend-vdg0.onrender.com/app; Nginx serves the compiled SPA and its hashed assets.
02
Submit on the same originReact calls /api/predict on the frontend origin, so the browser does not need direct cross-origin access to the API.
03
Proxy to Render APINginx uses API_UPSTREAM=sms-spam-api-gou5.onrender.com, strips the /api/ prefix, preserves forwarding headers, and enables TLS server-name verification behavior.
04
Infer and persistFastAPI runs ONNX inference, encrypts the SMS, saves the prediction through SQLAlchemy to Neon, and returns only label and probability.
05
Render the verified responseThe frontend validates the JSON and displays the probability, classification, persistence status, and request identifier.
Independent hostsThe frontend, backend, and database do not need one central provider; HTTPS URLs and secret connection strings form their contract.
Durable stateRender containers can be rebuilt or replaced without losing predictions because Neon owns storage.
Free-tier behaviorIdle web services may spin down and the first request can be delayed while a container wakes; the health and UI states make that observable.
Service Blueprint

Images, Health Checks & Runtime Configuration

Each deployed unit has one responsibility and a health signal appropriate to its role.

UnitBuild / RuntimeHealth & Configuration
sms-spam-apiPython DockerBuilt from the root Dockerfile; Render checks /health; runtime secrets include DATABASE_URL and MESSAGE_ENCRYPTION_KEY.
sms-spam-frontendNode build + NginxBuilt from frontend/Dockerfile; Render checks /frontend-health; API_UPSTREAM selects the FastAPI hostname.
Neon production branchManaged PostgreSQLAccepts pooled application traffic and direct migration traffic over required TLS; stores the Alembic revision and encrypted prediction rows.
Alembic release taskGitHub ActionsRuns once per production release before deploy hooks; it is intentionally not a continuously running Render service.
Local parity stackDocker ComposeUses PostgreSQL, a one-shot migrate container, and the API so the same operational sequence can be tested before production.

Deployment outcome: model training remains an offline research concern, while the production image carries only the artifacts and libraries required for inference.

Routing outcome: the public API URL remains useful for documentation and health checks, but end users visit the separate frontend URL. Nginx connects that interface to FastAPI behind the scenes.

Release outcome: Render does not deploy immediately on every repository push. GitHub Actions owns the gate, applies Neon migrations first, and then triggers both Docker services through deploy hooks.

Container Build Lab

Lean API Image & Multi-Stage Frontend Image

The two images optimize for different workloads: Python inference with checked-in artifacts, and static React delivery with no Node process in production.

API Dockerfile
FROM python:3.13-slim
WORKDIR /app

RUN addgroup --system appgroup \
 && adduser --system --ingroup appgroup appuser

COPY requirements.txt .
RUN python -m pip install -r requirements.txt

COPY app ./app
COPY migrations ./migrations
COPY alembic.ini ./
COPY artifacts/sms-spam-model.onnx ./artifacts/
COPY artifacts/vocabs_config.json ./artifacts/
COPY artifacts/label_mapping.json ./artifacts/

USER appuser
CMD ["sh", "-c", "exec uvicorn app.main:app \
 --host 0.0.0.0 --port ${PORT:-8000}"]
Frontend Dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.27-alpine
COPY --from=build /app/dist \
  /usr/share/nginx/html
COPY nginx/default.conf.template \
  /etc/nginx/templates/default.conf.template

EXPOSE 10000
CMD ["nginx", "-g", "daemon off;"]
No TensorFlow Runtime
smaller API

The deployed service carries ONNX Runtime, NumPy, the ONNX graph, vocabulary configuration, and label mapping. Training-only TensorFlow/Keras stays outside the image.

Non-Root API User
appuser

Runtime files are owned by an unprivileged system user, reducing the impact of accidental process-level filesystem access.

No Node Server
static runtime

Node and the dependency tree exist only in the build stage. The final container contains Nginx plus the generated HTML, CSS, JavaScript, and hashed assets.

Layer Caching
repeatable builds

Dependency manifests are copied before source files, so unchanged Python requirements or npm lockfiles can reuse Docker build layers.

Edge Routing & Operations

Nginx Proxy Rules, SPA Fallback & Deployment Runbook

Nginx joins the independently hosted services for the browser, while health endpoints and logs provide clear operational checkpoints.

nginx/default.conf.template
location = /frontend-health {
    access_log off;
    return 200 "ok\n";
}

location /api/ {
    proxy_pass https://${API_UPSTREAM}/;
    proxy_ssl_server_name on;
    proxy_ssl_name ${API_UPSTREAM};
    proxy_set_header Host ${API_UPSTREAM};
    proxy_read_timeout 90s;
}

location /assets/ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

location / {
    try_files $uri $uri/ /index.html;
}
production checkpoints
1. GitHub checks are green
2. Compose smoke saved a prediction
3. Alembic upgraded Neon to head
4. Render API hook accepted the release
5. GET /health returns {"status":"ok"}
6. Render frontend hook accepted the release
7. GET /frontend-health returns "ok"
8. /app loads through SPA fallback
9. Browser prediction returns X-Request-ID
10. Neon row matches that request UUID
Observed SymptomLikely LayerFirst CheckExpected Recovery
Frontend URL initially slowRender free-tier sleep/frontend-health and service eventsWait for container wake-up; no redeploy needed.
UI says API sleepingAPI wake-up or readinessPublic /health and API logsAPI becomes ready after startup and Neon connection succeeds.
API health returns 503Database connectionRender DATABASE_URL and Neon statusRestore valid pooled URL/TLS credentials or wait for Neon recovery.
Prediction returns storage errorTransaction or schemaRequest ID in API logs, Alembic current revisionCorrect schema/configuration, then retry without exposing message content.
Direct React route returns 404Nginx SPA fallbacktry_files $uri $uri/ /index.htmlRestore template and redeploy frontend image.
Old JS persists after deployAsset cachingHashed Vite filenames and HTML responseHTML points to new hashes; immutable old assets remain harmless.
Why Render and Neon can be separate: service boundaries are ordinary network contracts. The frontend needs only the API hostname, the API needs only its database URL and encryption key, and GitHub Actions needs direct migration and deploy-hook secrets.
Amir Mohamad Askari · NLP Classification Lab · 2026