A self-hosted Next.js application with a single source of truth for all layers — database, GraphQL API, and React front-end — built on top of the Next.js self-hosting guide.
Every entity in app/db/entities/ is decorated for both TypeORM (database) and TypeGraphQL (API), and the same TypeScript class is imported directly in React components and server actions.
app/db/entities/User.ts
│
├─ @Entity() ──▶ PostgreSQL table (TypeORM)
├─ @ObjectType() ──▶ GraphQL type (TypeGraphQL → Apollo)
└─ import { User } ──▶ React components & server libs
No separate DTO, schema file, or generated code — add a field once and it propagates everywhere.
// app/db/entities/User.ts
@ObjectType('User') // ← GraphQL type (TypeGraphQL → Apollo)
@Entity({ name: 'users' }) // ← PostgreSQL table (TypeORM)
export class User { // ← React components & server libs
@Field(() => ID)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field()
@Column({ type: 'varchar' })
login: string;
@Field(() => UserRole)
@Column({ type: 'enum', enum: UserRole })
role: UserRole;
}
@InputType('UsersFilter') // ← GraphQL input for filtering
export class UsersFilter {
@Field(() => String, { nullable: true }) id?: string;
@Field(() => String, { nullable: true }) login?: string;
@Field(() => UserRole, { nullable: true }) role?: UserRole;
}Important:
@ObjectTypeand@InputTypedecorators must always include an explicit string name (e.g.@ObjectType('User')). Next.js production builds mangle class names, so TypeGraphQL's default of usingconstructor.namewould produce broken schemas ("r"instead of"User"). The ESLint rulelocal/require-typegraphql-explicit-nameenforces this.
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router, standalone output) |
| Database | PostgreSQL + TypeORM 0.3.27 |
| API | GraphQL — Apollo Server 5 + TypeGraphQL 2 |
| Client | Apollo Client 4 + @apollo/client-integration-nextjs |
| Auth | NextAuth 4 (GitHub, Google OAuth + credentials) |
| Runtime | Node.js 20, React 19 |
| CI/CD | GitHub Actions → SSH deploy |
├── app/
│ ├── api/
│ │ ├── auth/ NextAuth route handler
│ │ └── graphql/
│ │ ├── route.ts Apollo handler (GET + POST)
│ │ ├── schema.ts buildGqlSchema()
│ │ └── resolvers/
│ │ ├── ... API crud handlers
│ ├── db/
│ │ ├── entities/ Single source of truth (TypeORM + TypeGraphQL)
│ │ │ ├── ... Entities models
│ │ ├── migrations/ Plain JS migrations (no ts-node in prod)
│ │ ├── db.ts TypeORM DataSource
│ │ └── runMigrations.js
│ ├── libs/ Server-side data fetchers (use Apollo client)
│ ├── providers.tsx Apollo + Auth providers
│ ├── layout.tsx
│ └── ... All pages folders
├── components/ Shared React components
├── server/
│ ├── apollo.ts ApolloServer singleton
│ └── context.ts GraphQL request context (userId from session)
├── utils/
├── docker/
│ ├── development/ compose.yaml (app + postgres)
│ ├── staging/ Dockerfile (multi-stage) + compose.yaml
│ └── production/ Dockerfile (multi-stage) + compose.yaml
├── eslint-rules/ Local ESLint rules
│ └── require-typegraphql-explicit-name.mjs
└── .github/workflows/
└── deploy.yml checks (lint + type-check) → deploy
- Docker + Docker Compose
- Node.js 20+
npm install
npm start # docker compose up (app + postgres)The app runs at http://localhost:3000.
In development mode Apollo Server exposes a GraphQL Sandbox at:
http://localhost:3000/api/graphql
Open it in a browser to explore the schema, run queries, and test mutations interactively.
Migrations live in app/db/migrations/ as plain .js files (no ts-node needed in production).
npm run migration:runThe CI/CD pipeline (GitHub Actions) does:
checksjob (runs on every push tomainorstage)deployjob (only onmain, only ifcheckspasses):- Builds and pushes two Docker images to
ghcr.io::latest— the app (runnerstage):migrate— the migration runner (migratorstage)
- Writes a
.envfile from GitHub Secrets/Variables and copies it withdocker/production/compose.yamlto/tmp/deployon the server - SSH:
- Pulls both images from
ghcr.iousingGHCR_TOKEN - Starts the database and waits for it to be healthy
- Runs migrations in an ephemeral
--rmcontainer - Starts the application
- Prunes old images and removes
/tmp/deploy
- Pulls both images from
- Builds and pushes two Docker images to
ssh root@your_server_ip
curl -o ~/deploy.sh https://raw.githubusercontent.com/4-life/hello-world/main/deploy.sh
chmod +x ~/deploy.sh && SSH_USER=myuser SSH_PORT=22 ./deploy.shSSH_USER is the name of the deploy system user the script creates (default: myuser). SSH_PORT is the SSH port (default: 22). Set them to match your SSH_USER and SSH_PORT secrets in GitHub Actions.
| Name | Kind |
|---|---|
SSH_HOST, SSH_USER, SSH_PORT |
Variables / Secrets |
SSH_PRIVATE_KEY |
Secret |
GHCR_TOKEN |
Secret |
POSTGRES_USER, POSTGRES_DB |
Variables |
POSTGRES_PASSWORD, NEXTAUTH_SECRET |
Secrets |
CLIENT_ID_GITHUB, CLIENT_SECRET_GITHUB |
Secrets |
CLIENT_ID_GOOGLE, CLIENT_SECRET_GOOGLE |
Secrets |
Creating GHCR_TOKEN
The server needs this token to pull the Docker image from ghcr.io.
- Go to GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic)
- Generate a new token with the
read:packagesscope - Add it to the repository: Settings → Secrets and variables → Actions → New repository secret
- Name:
GHCR_TOKEN - Value: the token you just created
- Name:
| Environment | Branch | Docker files | GitHub environment |
|---|---|---|---|
| Development | — (local) | docker/development/ |
— |
| Staging | stage |
docker/staging/ |
staging |
| Production | main |
docker/production/ |
production |
Each environment runs on its own server. SSH credentials (SSH_HOST, SSH_USER, SSH_PRIVATE_KEY, SSH_PORT) are stored per GitHub environment, so pushing to a branch only ever touches that environment's server.
Adding a new environment
1. Docker files — create docker/<env>/Dockerfile and docker/<env>/compose.yaml modelled on the staging equivalents.
2. GitHub environment — go to Settings → Environments → New environment, name it <env>, and add the same secrets and variables as staging (see Required secrets / vars) pointing to the new server.
3. Workflow job — add the trigger branch and a new job to .github/workflows/deploy.yml, following deploy-staging as the template:
# add the branch to the trigger
on:
push:
branches:
- main
- stage
- <branch>
# add the job
deploy-<env>:
if: github.ref == 'refs/heads/<branch>'
needs: checks
environment: <env>
...The environment: <env> binding is what scopes the job to that environment's secrets, ensuring the deploy hits the correct server.
Files are stored in AWS S3. The bucket is fully private — no object is ever publicly accessible. All reads and writes go through short-lived presigned URLs signed by the server.
Upload flow, security measures, bucket setup, and environment variables
1. Client → GraphQL mutation requestUploadUrl(contentType, ...)
← { uploadUrl (presigned PUT, 5 min), key }
2. Client → PUT file directly to S3 (no server proxy)
3. Client → GraphQL mutation confirmUpload(key, ...)
Server: fetches first bytes from S3, validates content
Server: deletes old file if one exists
Server: saves key to DB
← Entity with file field (presigned GET URL, 1 h TTL)
File fields on GraphQL types are @FieldResolver — they always return a fresh presigned GET URL, never the raw S3 key.
| Layer | What it does |
|---|---|
| Private bucket + Block Public Access ON | No anonymous reads or writes ever |
| Content-type allowlist | Server rejects disallowed MIME types before issuing an upload URL |
ContentType locked in presigned PUT |
S3 rejects the upload if the browser sends a mismatched Content-Type header |
| Key scoped to owner path | Users can only confirm keys that belong to their own prefix |
| Magic bytes check | Files whose content doesn't match the declared type are rejected and deleted from S3 |
ResponseContentType in presigned GET |
Browser always receives the correct MIME type regardless of what was stored |
| Old file deleted on replace | Orphaned objects are cleaned up immediately |
- Create an S3 bucket in your AWS region.
- Under Permissions, enable Block all public access.
- Create an IAM user with the policy below (least-privilege — scoped to the prefix used by the feature):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::YOUR_BUCKET_NAME/*"
}
]
}- Set the bucket CORS policy to allow direct PUT uploads from the browser:
[
{
"AllowedHeaders": ["Content-Type"],
"AllowedMethods": ["PUT"],
"AllowedOrigins": [
"http://localhost:3000",
"https://your-production-domain.com",
"https://your-staging-domain.com"
],
"MaxAgeSeconds": 3000
}
]Add to your .env (development) and to each GitHub environment (staging / production):
| Name | Kind | Description |
|---|---|---|
AWS_REGION |
Variable | e.g. eu-north-1 |
S3_BUCKET_NAME |
Variable | The bucket name |
AWS_ACCESS_KEY_ID |
Secret | IAM user access key |
AWS_SECRET_ACCESS_KEY |
Secret | IAM user secret key |
S3_BUCKET_NAME and AWS_REGION are also required as build arguments — next.config.ts reads them at build time to whitelist the bucket hostname for next/image optimization. Both Dockerfiles and the CI workflow are already wired to pass them.
- No secrets in the Docker image. The production
Dockerfilecontains no credentials. All environment variables are injected at runtime by the CI/CD pipeline and passed to containers viaenv_file. - No secrets in the repository.
.envfiles are git-ignored. GitHub Actions secrets/variables are the single source of truth — the pipeline writes a.envfile on the server at deploy time and never commits it. - No secrets in build arguments.
--build-argis not used for sensitive values. Only the image artifact is shipped; credentials are absent from all image layers anddocker inspectoutput.
- The production container runs as a non-root user (
nextjs, uid 1001). An attacker who achieves RCE inside the container gets a restricted user with no write access outside the app directory. - The migration runner is a separate image (
app-migrate). It runs ephemerally (--rm) before the app starts and has no access to the running application.
deploy.shcreates a dedicateddeployuser (no root, docker group only) for CI/CD SSH access. The root account is not used by the pipeline.- SSH access uses ed25519 key authentication only. The private key lives exclusively in GitHub Secrets and is never written to disk beyond the server's
authorized_keys. - UFW is enabled with only ports 22, 80, and 443 open.
- Nginx sits in front of Next.js and handles SSL termination, HTTP→HTTPS redirect, and rate limiting (10 req/s, burst 20).
- SSL certificates are issued by Let's Encrypt and auto-renewed every 12 hours via cron.
Protected pages and API endpoints use two complementary layers:
- Page-level — server-side layouts call
getServerSession(NextAuth). The/usersroute group'slayout.tsxredirects unauthenticated requests to/before any page content renders. - GraphQL-level — resolvers that require authentication are decorated with
@Authorized()(TypeGraphQL). TheauthCheckerinapp/api/graphql/schema.tsreadscontext.userId, which is populated from the NextAuth session byserver/context.ts. AnulluserId rejects the request. Role-based access (@Authorized('admin')) is also supported.
This template does not ship with web-layer hardening enabled out of the box. Apply the following before exposing the app to real users:
- Content Security Policy (CSP) — add a
Content-Security-Policyheader (or<meta>tag) restrictingscript-src,style-src,img-src,connect-src, etc. to known origins. In Next.js, set it innext.config.tsviaheaders()or in the Nginx config. - XSS protection — React escapes output by default, but review any
dangerouslySetInnerHTMLusage and sanitize any HTML coming from external sources (e.g. with DOMPurify). - CSRF protection — GraphQL mutations over POST are not automatically CSRF-safe. Use
SameSite=Lax(orStrict) on session cookies, or add a CSRF token layer (e.g.csrfnpm package) to the GraphQL route handler. - Security headers — add
X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin, andPermissions-Policyin Nginx ornext.config.ts. - Rate limiting — Nginx rate-limits HTTP at 10 req/s. Consider adding resolver-level rate limiting for expensive GraphQL operations (e.g. auth mutations) using a library such as graphql-rate-limit.
- Dependency audits — run
npm auditin CI and keepnpm audit --audit-level=highclean before every release. - Structured logging — the app currently has no production logger. Before going live, add structured logs (e.g. pino) with a
requestIdper GraphQL operation so errors and slow queries can be traced end-to-end. Key events to cover: every operation name + duration +userId, failed auth attempts, and mutations that touch other users' data.
- New environment variables → add to GitHub Secrets/Variables, not to the Dockerfile or source code
- New endpoints → check that
@Authorized()is applied where needed in resolvers - New file uploads or external calls → validate at the boundary, not inside business logic
