feat: implement unified deployment with Docker and Railway integration

This commit introduces a comprehensive deployment strategy that unifies the frontend and backend into a single Docker container served by the Rust backend, streamlining the deployment process and improving production architecture.

Key changes:
- Split CI/CD workflows: separated build.yaml (for CI/PR checks) and deploy.yaml (for production deployment)
- Implemented unified Docker deployment where the Axum server serves both API routes (under /api) and frontend static files
- Added GitHub Container Registry integration for Docker image distribution
- Updated Railway configuration to use the new healthcheck path (/api/health)
- Enhanced postgres.ts script with named volumes and constants for better container management
- Added API client utilities (web/lib/api.ts) and environment configuration (web/.env.example) for frontend-backend communication
- Configured Vite proxy for local development while supporting same-origin requests in production
- Updated Dockerfile to include frontend static files and proper environment variable handling

This architecture eliminates the need for separate deployments and CORS configuration, as the frontend and API are served from the same origin.
This commit is contained in:
Ryan Walters
2025-11-02 19:31:22 -06:00
parent 4002729ef7
commit 45e6131121
11 changed files with 276 additions and 116 deletions

View File

@@ -44,7 +44,7 @@ jsonwebtoken = { version = "9.3", default-features = false }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] }
tracing-futures = { version = "0.2.5", features = ["tokio"] }
tower-http = { version = "0.6", features = ["trace"] }
tower-http = { version = "0.6", features = ["trace", "fs"] }
time = { version = "0.3", features = ["macros", "formatting"] }
yansi = "1"
s3-tokio = { version = "0.39.6", default-features = false }

View File

@@ -1,4 +1,5 @@
ARG RUST_VERSION=1.89.0
ARG GIT_COMMIT_SHA
FROM lukemathwalker/cargo-chef:latest-rust-${RUST_VERSION} AS chef
WORKDIR /app
@@ -25,6 +26,10 @@ FROM debian:bookworm-slim AS runtime
WORKDIR /app
COPY --from=builder /app/target/release/pacman-server /usr/local/bin/pacman-server
# Copy frontend static files (built by GitHub Actions)
# These files should be in web/dist/client/ in the build context
COPY web/dist/client /app/static
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
@@ -35,12 +40,15 @@ ARG TZ=Etc/UTC
ENV TZ=${TZ}
# Optional build-time environment variable for embedding the Git commit SHA
ARG RAILWAY_GIT_COMMIT_SHA
ENV RAILWAY_GIT_COMMIT_SHA=${RAILWAY_GIT_COMMIT_SHA}
ARG GIT_COMMIT_SHA
ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA}
# Specify PORT at build-time or run-time, default to 3000
ARG PORT=3000
ENV PORT=${PORT}
EXPOSE ${PORT}
# Set static files directory for the server to serve
ENV STATIC_FILES_DIR=/app/static
CMD ["sh", "-c", "exec /usr/local/bin/pacman-server"]

View File

@@ -2,10 +2,12 @@ use axum::{routing::get, Router};
use axum_cookie::CookieLayer;
use dashmap::DashMap;
use jsonwebtoken::{DecodingKey, EncodingKey};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Notify, RwLock};
use tokio::task::JoinHandle;
use tower_http::services::{ServeDir, ServeFile};
use tracing::info_span;
use crate::data::pool::PgPool;
@@ -159,8 +161,25 @@ pub fn make_span<B>(request: &axum::http::Request<B>) -> tracing::Span {
/// Create the application router with all routes and middleware
pub fn create_router(app_state: AppState) -> Router {
Router::new()
.route("/", get(|| async { "Hello, World! Visit /auth/github to start OAuth flow." }))
// Get static files directory from environment variable
// Default to /app/static for production (Docker), or web/dist/client for local dev
let static_dir = std::env::var("STATIC_FILES_DIR").unwrap_or_else(|_| {
if std::path::Path::new("/app/static").exists() {
"/app/static".to_string()
} else {
"web/dist/client".to_string()
}
});
let static_path = PathBuf::from(&static_dir);
let index_path = static_path.join("index.html");
// Create API router with all backend routes
let api_router = Router::new()
.route(
"/",
get(|| async { "Pac-Man API Server. Visit /api/auth/github to start OAuth flow." }),
)
.route("/health", get(routes::health_handler))
.route("/auth/providers", get(routes::list_providers_handler))
.route("/auth/{provider}", get(routes::oauth_authorize_handler))
@@ -169,14 +188,28 @@ pub fn create_router(app_state: AppState) -> Router {
.route("/profile", get(routes::profile_handler))
.with_state(app_state)
.layer(CookieLayer::default())
.layer(axum::middleware::from_fn(inject_server_header))
.layer(
tower_http::trace::TraceLayer::new_for_http()
.make_span_with(make_span)
.on_request(|_request: &axum::http::Request<axum::body::Body>, _span: &tracing::Span| {
// Disable request logging by doing nothing
}),
)
.layer(axum::middleware::from_fn(inject_server_header));
// Create main router with API routes nested under /api
let router = Router::new().nest("/api", api_router);
// Add static file serving if the directory exists
let router = if static_path.exists() {
tracing::info!(path = %static_dir, "Serving static files from directory");
router.fallback_service(ServeDir::new(&static_path).not_found_service(ServeFile::new(&index_path)))
} else {
tracing::warn!(path = %static_dir, "Static files directory not found, serving API only");
router
};
// Add tracing layer to the entire router
router.layer(
tower_http::trace::TraceLayer::new_for_http()
.make_span_with(make_span)
.on_request(|_request: &axum::http::Request<axum::body::Body>, _span: &tracing::Span| {
// Disable request logging by doing nothing
}),
)
}
/// Inject the server header into responses