Vapor-based Swift backend that exposes student authentication APIs over REST and additional student APIs over GraphQL. This guide covers native local development on Linux and macOS, with optional Docker-based workflows.
- Framework: Vapor 4
- Language: Swift 6
- Database: MySQL
- API styles: REST and GraphQL
- Container registry: GitHub Container Registry (GHCR)
- Student signup, login, and logout over REST
- GraphQL endpoint for student queries and mutations
- JWT-based authentication with token revocation on logout
- Centralized validation across REST and GraphQL
- Docker-based local development (optional)
- Optional HTTPS support for native local runs
- Optional reverse-proxy TLS termination with Caddy
PlantUML source: server-runtime.puml
This diagram shows the runtime flow between clients, the Vapor server, REST routes, GraphQL routes, authentication logic, and MySQL.
Sources/StudentAppBackend: application sourceTests/StudentAppBackendTests: test suitedocker-compose.yml: local source-based developmentdocker-compose.package.yml: packaged backend + MySQLdocker-compose.caddy.yml: backend behind Caddy with HTTPS termination
PlantUML source: code-architecture.puml
This diagram shows how configure.swift, routes, controllers, GraphQL, services, models, migrations, middleware, and tests fit together in the codebase.
- macOS 13 or later (or a modern Linux distribution)
- Swift 6 toolchain / Xcode compatible with the package
- MySQL 8 if running outside Docker
- Docker and Docker Compose for container-based setup (optional)
swift buildswift runswift testCurrent tests cover the REST auth flow, logout authorization behavior, logout token reuse rejection, GraphQL signup, and validation edge cases.
The backend container is published to GitHub Container Registry through the consolidated CI/CD workflow at .github/workflows/swift.yml.
- Default image:
ghcr.io/rajeshm20/studentappbackend:latest - Additional tags:
main, release tags such asv1.0.0, and commit SHA tags
To publish from CI:
git push origin mainTo publish a versioned image:
git tag v1.0.0
git push origin v1.0.0The Docker publish job runs only after the test job passes.
The published image contains only the backend application. To run the backend together with MySQL, use docker-compose.package.yml:
docker compose -f docker-compose.package.yml up -dThis package starts:
ghcr.io/rajeshm20/studentappbackend:latestmysql:8
Default database-related values:
DATABASE_USER=rootDATABASE_PASSWORD=newpasswordMYSQL_ROOT_PASSWORD=newpasswordMYSQL_ROOT_HOST=%
For local development from source, use docker-compose.yml. It provides the same runtime shape but builds the backend image from this repository.
If MySQL was previously started with older credentials or host permissions, recreate the volume once:
docker compose -f docker-compose.package.yml down -v
docker compose -f docker-compose.package.yml up -dBase URL for the packaged Docker setup:
http://localhost:8080
curl -X POST http://localhost:8080/auth/signup \
-H "Content-Type: application/json" \
-d '{
"name": "SasvathRN",
"email": "sasvathrn@rnss.com",
"password": "password123"
}'curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "rajesh@example.com",
"password": "password123"
}'The login response includes a JWT token that you pass to protected flows such as logout.
curl -X POST http://localhost:8080/auth/logout \
-H "Authorization: Bearer YOUR_JWT_TOKEN"Expected success response:
{
"message": "Logout successful"
}- Requires a bearer token in the
Authorizationheader - Verifies the JWT before processing the request
- Stores the token
jtiin the revoked-token table - Rejects repeated logout attempts with the same token
- Sign up a user.
- Log in and capture the returned token.
- Call logout with that token.
Example logout after login:
curl -X POST http://localhost:8080/auth/logout \
-H "Authorization: Bearer eyJhbGciOi..."forgot password (sends OTP)
curl -X POST http://localhost:8080/auth/forgot-password \
-H "Content-Type: application/json" \
-d '{
"email": "your-real-email@gmail.com"
}'Expected response (success — email is registered):
{
"success": true,
"message": "A verification code has been sent to your email."
}Expected response (email not registered):
{
"success": false,
"message": "Email not registered, please enter a registered email id."
}Where to find the code:
If SENDGRID_API_KEY is set → check the actual inbox for your-real-email@gmail.com. If it's not set (falls back to ConsoleEmailService) → check your terminal/server logs where swift run is running — you'll see something like: 📧 [DEV EMAIL] To: your-real-email@gmail.com | Subject: Your password reset code Your verification code is: 482913
curl -X POST http://localhost:8080/auth/verify-reset-code \
-H "Content-Type: application/json" \
-d '{
"email": "your-real-email@gmail.com",
"code": "482913"
}'Expected response (success):
{
"success": true,
"message": "Code verified.",
"sessionToken": "aB3xY9k2mZ..."
}Copy the sessionToken from this response — you need it for step 3.
Expected response (wrong/expired code):
{
"success": false,
"message": "Invalid code.",
"sessionToken": null
}curl -X POST http://localhost:8080/auth/reset-password \
-H "Content-Type: application/json" \
-d '{
"email": "your-real-email@gmail.com",
"sessionToken": "aB3xY9k2mZ...",
"newPassword": "NewPass123",
"confirmPassword": "NewPass123"
}'Expected response (success):
{
"success": true,
"message": "Password reset successfully"
}Expected error responses:
// mismatched passwords
{"error": true, "reason": "Passwords do not match"}// too short
{"error": true, "reason": "Password must be at least 8 characters"}// expired/invalid/reused session token
{"error": true, "reason": "Invalid or expired reset session"}curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "your-real-email@gmail.com",
"password": "NewPass123"
}'Should return a valid JWT if the reset actually took effect.
Quick edge-case tests worth running too
curl -X POST http://localhost:8080/auth/forgot-password \
-H "Content-Type: application/json" \
-d '{"email": "not-a-real-user@nowhere.com"}'# Run step 2 again with the same code after step 3 already succeeded
curl -X POST http://localhost:8080/auth/verify-reset-code \
-H "Content-Type: application/json" \
-d '{"email": "your-real-email@gmail.com", "code": "482913"}'curl -X POST http://localhost:8080/auth/verify-reset-code \
-H "Content-Type: application/json" \
-d '{"email": "your-real-email@gmail.com", "code": "482913"}'Expect: "Code has expired. Please request a new one."
students: fetch all studentsstudent(id: UUID!): fetch a single studentsignup(input: StudentGraphQLCreateInput!): create a studentlogin(input: StudentGraphQLLoginInput!): authenticate and return a JWTupdateStudent(input: StudentGraphQLUpdateInput!): updatedob,name, andphoneNumber
curl -X POST http://localhost:8080/graphql \
-H "Content-Type: application/json" \
-d '{
"query": "mutation Signup($input: StudentGraphQLCreateInput!) { signup(input: $input) { id name email } }",
"variables": {
"input": {
"name": "Graph User",
"email": "graphql@example.com",
"password": "password123"
}
}
}'curl -X POST http://localhost:8080/graphql \
-H "Content-Type: application/json" \
-d '{
"query": "mutation Login($input: StudentGraphQLLoginInput!) { login(input: $input) { token user { id name email } } }",
"variables": {
"input": {
"email": "graphql@example.com",
"password": "password123"
}
}
}'curl -X POST http://localhost:8080/graphql \
-H "Content-Type: application/json" \
-d '{
"query": "mutation UpdateStudent($input: StudentGraphQLUpdateInput!) { updateStudent(input: $input) { id name phoneNumber dob } }",
"variables": {
"input": {
"id": "PUT-STUDENT-UUID-HERE",
"name": "Updated Name",
"phoneNumber": "+1 234 567 8900"
}
}
}'curl -X POST http://localhost:8080/graphql \
-H "Content-Type: application/json" \
-d '{
"query": "{ students { id name email phoneNumber dob } }"
}'Minimal students query:
curl -X POST http://localhost:8080/graphql \
-H "Content-Type: application/json" \
-d '{
"query": "{ students { id name email } }"
}'The current backend applies validation in multiple layers so invalid data is rejected before it can silently drift into persistence.
- REST signup uses Vapor validation plus shared
ValidationUtilities.swiftchecks - GraphQL signup reuses the same create-request validation rules
- GraphQL
updateStudentvalidatesdob,name, andphoneNumber - MySQL schema constraints backstop key field lengths at the database level
- Required for signup
- Must not be empty or whitespace-only
- Maximum length: 100 characters
- Required for signup
- Must match email format rules
- Maximum length: 254 characters
- Still enforced as unique at the database level
- Required for signup
- Minimum length: 8 characters
- Must contain at least one letter and one number
- Optional
- Must not be in the future
- Must represent a plausible age between 5 and 120 years
- Optional
- Must be 10 to 20 characters
- May contain only digits,
+,-, and spaces
The students schema adds CHECK constraints for:
namelength up to 100 charactersemaillength up to 254 charactersphoneNumberlength between 10 and 20 characters when present
This means malformed or oversized values are not only blocked at the API layer, but also guarded at the database layer.
curl -X POST http://localhost:8080/auth/signup \
-H "Content-Type: application/json" \
-d '{
"name": "",
"email": "not-an-email",
"password": "short"
}'Expected behavior:
- HTTP
400 Bad Request - Validation failure reason returned by the API
- No student row created
The current test suite includes cases for:
- empty and oversized names
- malformed and oversized emails
- weak passwords
- future and implausible dates of birth
- malformed, too-short, and too-long phone numbers
- valid payloads with optional fields present or absent
If running the Vapor app directly and you want local HTTPS, generate a self-signed certificate with CN=localhost.
- Generate the certificate and key.
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout key.pem -out cert.pem -days 365 \
-subj "/CN=localhost"- Export a
.p12bundle if needed.
openssl pkcs12 -export -out localhost.p12 \
-inkey key.pem -in cert.pem \
-name "Vapor Localhost Cert"- Import
cert.peminto macOS Keychain and set it to trust for local use. - Restart the Vapor application so it reloads the certificates.
- Test the endpoint again.
curl https://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"rajesh@example.com","password":"password123"}'To run the backend behind Caddy with TLS termination, use docker-compose.caddy.yml:
docker compose -f docker-compose.caddy.yml up -dThis keeps the backend container on HTTP and lets Caddy manage HTTPS on port 443.
For local Caddy testing, use:
https://localhost
If you are not using Docker, install and configure MySQL locally.
brew update
brew install mysqlStart MySQL as a background service:
brew services start mysqlOr start it manually when needed:
mysql.server startmysql_secure_installationRecommended actions during setup:
- Set a root password
- Remove anonymous users
- Disallow remote root login unless explicitly required
- Remove the test database
- Reload privilege tables
mysql -u root -p -h 127.0.0.1 -P 3306- In Docker or production environments, keep the app container on HTTP.
- Terminate TLS in Caddy, Nginx, or another reverse proxy.
- Do not package local self-signed certificates into production images.
Side-by-side comparison of the WSL + Docker setup vs the Linux / macOS (native) setup.
| WSL + Docker Setup Guide | Linux / macOS Setup Guide | |
|---|---|---|
| Target environment | Windows 11 with WSL2 (Ubuntu), everything runs via Docker | Native Linux or macOS, with Docker optional |
| Prerequisites | Windows 11 + WSL2 (Ubuntu) Docker installed and running Git Ports: MySQL 3306, App 8081 if 8080 is taken |
macOS 13+ or modern Linux Swift 6 toolchain / Xcode MySQL 8 (if not using Docker) Docker + Compose (optional) |
| Clone repo | git clone https://github.com/rajeshm20/StudentAppBackend.gitcd StudentAppBackendls (expect Dockerfile, Package.swift, Sources/) |
Same clone step, but typically followed by native build rather than Docker build |
| Build the app | docker buildx create --use --name mybuilderdocker buildx inspect --bootstrapdocker buildx build --platform linux/amd64 -t studentappbackend:local --load . |
swift build |
| Run the app | docker run -p 8081:8080 studentappbackend:local |
swift run |
| Run tests | Handled inside CI container (swift test -v); current coverage includes signup, login, logout, and logout token-reuse cases |
swift test; current coverage includes signup, login, logout, and logout token-reuse cases |
| Database setup | docker compose up -d db (MySQL runs in a container)Verify: docker ps |
Docker Compose or native Homebrew MySQL:brew install mysqlbrew services start mysqlmysql_secure_installation |
| Common issues | ARM64 image on AMD64 → exec format error, fix with --platform linux/amd64 rebuildPort in use → sudo ss -tulpn | grep :8080, remap with -p 8081:8080MySQL refused → check docker ps, or docker compose up -d |
Not covered — native builds don't hit the ARM64/AMD64 container mismatch; port conflicts are OS-level (lsof -i :8080 on macOS/Linux) |
| Git branch workflow | git checkout -b wsl_studentappbackendgit add .git commit -m "..."git push -u origin wsl_studentappbackend |
Not specific to platform — standard feature-branch workflow applies |
| Publish to GHCR (manual) | docker tag studentappbackend:local ghcr.io/rajeshm20/studentappbackend:wsl-v1docker login ghcr.io -u rajeshm20docker push ghcr.io/rajeshm20/studentappbackend:wsl-v1 |
Same manual tag/login/push steps apply if building locally on macOS/Linux |
| Publish via CI | git push origin main (latest)git tag v1.0.0 && git push origin v1.0.0 (versioned) |
Identical — CI publishing is platform-agnostic |
| Packaged setup (app + DB) | docker compose -f docker-compose.package.yml up -d |
Same command, same defaults (DATABASE_USER=root, DATABASE_PASSWORD=newpassword, etc.) |
| HTTPS (native, no proxy) | Not covered — WSL guide assumes Docker/HTTP only | openssl req -x509 -newkey rsa:2048 ... to generate self-signed certOptional .p12 exportImport into macOS Keychain |
| HTTPS via Caddy | docker compose -f docker-compose.caddy.yml up -d |
Same command — identical across both guides |
| API endpoints (REST/GraphQL) | Same base URL (http://localhost:8080), same REST auth routes /auth/signup, /auth/login, /auth/logout, plus /graphql and /graphiql |
Identical API surface across platforms; logout requires Authorization: Bearer <jwt> and revokes the token by JWT ID |
| Deployment notes | Keep app container on HTTP; terminate TLS at Caddy/Nginx; never ship self-signed certs in prod images | Same guidance |
| Unique to this guide | ARM64/AMD64 troubleshooting, WSL-specific branch naming convention, buildx multi-platform build steps | Native swift build/run/test workflow, macOS Keychain cert trust steps, Homebrew MySQL install/secure/connect steps |
- Choose WSL + Docker if you're on Windows and want a fully containerized workflow with no native Swift toolchain installed.
- Choose Linux/macOS native if you're developing directly with Xcode/Swift tooling and want faster iteration (
swift run/swift test) without rebuilding Docker images on every change. - Both guides converge on the same auth/API surface, including logout token revocation, same GHCR publishing workflow, and same Caddy/HTTPS reverse-proxy setup — the only real divergence is how the binary gets built and run locally.