NextStep is hiring heavily over the next few months and decided to build its own job application portal instead of continuing to rely on external job boards, mainly to cut cost and keep direct control over the hiring pipeline and applicant data.
This is the first version. It's a deliberate MVP: things like email verification, password reset, and a full admin-role system were left out to ship fast and start hiring sooner, not because nobody thought of them. Candidates register, browse job postings, and apply with a resume upload. A single built-in admin account manages postings and moves applicants forward.
Candidates register an account, browse job postings, and apply to a job
with a resume upload. Each application has one of three statuses, in this
fixed order: submitted, interview, rejected. submitted is set
automatically when a candidate applies. interview and rejected change
only as a side effect of the admin clicking a "send email" action on an
applicant, which sends the email via SES and updates the status at the same
time. There's no separate status dropdown.
There is one built-in admin account, created from ADMIN_EMAIL /
ADMIN_PASSWORD (see the env var tables below). The admin logs in at the
same login page as candidates and is routed to /admin to manage job
postings and review applicants.
User Browser -> CloudFront -> S3 (static frontend)
|
API Requests (HTTPS)
|
Application Load Balancer -> ECS Fargate (backend)
|
+------+------+---------+---------+
RDS S3 Secrets SES KMS
(Postgres)(resumes) Manager (email) (encryption)
- CloudFront: CDN in front of the S3 frontend bucket, and the entry
point for the whole app. Routes
/api/*to the ALB, everything else to S3. - S3 (static frontend): hosts the built React app (
Frontend/static). - Application Load Balancer: routes HTTPS API traffic to the ECS service and terminates TLS.
- ECS Fargate (backend): runs the Express API as a container, one task definition, no autoscaling needed at current hiring volume.
- RDS (Postgres): stores users, jobs, and applications.
- S3 (resumes): stores uploaded resume files, accessed by the backend via IAM role, not public.
- Secrets Manager: holds the database credentials (
DB_HOST,DB_USER,DB_PASSWORD) and other secrets injected into the ECS task as environment variables. - SES: sends interview and rejection emails on behalf of the admin.
- KMS: encrypts data at rest for RDS and S3 via their default encryption settings.
Two steps:
- Start the database and backend:
cp .env.example .env
docker compose up --buildThis starts Postgres, the backend, and the frontend (nginx) as three
Compose services. The backend applies its own schema and seed data, and
creates the admin account from ADMIN_EMAIL / ADMIN_PASSWORD, on every
startup, against whatever PostgreSQL database it's pointed at -- local or
RDS -- so there's no separate manual schema or admin-seeding step, on a
fresh volume or a fresh RDS instance alike (see Backend/src/schema.js and
Backend/src/seedAdmin.js).
- The frontend container is built and started by the same command, via
the
frontendservice indocker-compose.yml. It's an nginx container that serves the built React app and reverse-proxies/api/*to thebackendservice over the internal Docker network (seeFrontend/nginx.conf), so the app works with zero extra configuration.
Open http://localhost. Register a candidate account, or log in as the
admin with ADMIN_EMAIL / ADMIN_PASSWORD from your .env.
The app connects to Postgres with discrete params rather than a single
DATABASE_URL, since in production the RDS-managed Secrets Manager secret
exposes host/user/password as separate fields, not a pre-built URL, and the
database name is a separate plain value outside that secret entirely (see
Backend/src/db.js).
| Variable | Purpose | Example |
|---|---|---|
DB_HOST |
Postgres host | db |
DB_PORT |
Postgres port | 5432 |
DB_USER |
Postgres user | postgres |
DB_PASSWORD |
Postgres password | postgres |
DB_NAME |
Postgres database name | job_portal |
DB_SSL |
Enables TLS on the database connection | not needed locally, leave unset (false) |
JWT_SECRET |
Signs login JWTs | a long random string |
S3_BUCKET_NAME |
S3 bucket for resume uploads | nextstep-resumes |
AWS_REGION |
Region for the S3 bucket and SES identity | us-east-1 |
SES_SENDER_EMAIL |
Verified SES "from" address for interview/rejection emails | you@example.com |
ADMIN_EMAIL |
Login identifier for the built-in admin account | admin@example.com |
ADMIN_PASSWORD |
Password for the built-in admin account | a strong password |
VITE_API_URL is not needed locally. The nginx proxy in the frontend
container handles routing /api/* to the backend, so the frontend calls
relative paths.
This follows the architecture diagram above. Steps assume you're already comfortable with the AWS console or CLI.
- RDS PostgreSQL: create an instance. No manual schema or admin-seeding
step is needed -- the backend applies
Backend/sql/schema.sql(and seed data) and creates the admin account fromADMIN_EMAIL/ADMIN_PASSWORDitself on startup, against whatever database it's pointed at, so a fresh RDS instance gets its tables and admin login created automatically on the very first boot (seeBackend/src/schema.jsandBackend/src/seedAdmin.js). Put the host, username, and password into Secrets Manager asDB_HOST,DB_USER, andDB_PASSWORD(the discrete fields RDS-managed secrets actually expose, rather than a pre-built connection string).DB_NAMEisn't part of that secret, set it as a plain task-definition env var. - S3 (resumes): create a private bucket for resume uploads. Attach an IAM policy to the ECS task role granting it read/write access to that bucket only.
- S3 + CloudFront (frontend): create a bucket for the static frontend
build and a CloudFront distribution in front of it. Configure CloudFront
to route
/api/*to the ALB and everything else to the S3 origin. - SES: verify a sending identity for
SES_SENDER_EMAIL. New AWS accounts start in SES sandbox mode, which also requires every recipient address to be verified. You'll likely need to request SES production access before candidates outside your verified list can receive email. - Secrets Manager: store
DB_HOST,DB_USER, andDB_PASSWORDat minimum. Other values (JWT_SECRET,ADMIN_PASSWORD, etc.) can live here too instead of as plain task-definition env vars if you want them encrypted at rest. - ECS Fargate: task definition needs these env vars:
DB_HOST,DB_USER,DB_PASSWORD(from Secrets Manager),DB_PORT,DB_NAME,DB_SSL=true,JWT_SECRET,S3_BUCKET_NAME,AWS_REGION,SES_SENDER_EMAIL,ADMIN_EMAIL,ADMIN_PASSWORD. Build the image fromBackend/Dockerfile. - ALB: routes HTTPS traffic to the ECS service's target group.
CloudFront points at this ALB for
/api/*. - Frontend build: build the frontend with
VITE_API_URLset to the ALB's or a custom domain's HTTPS address, for example:
cd Frontend
VITE_API_URL=https://api.yourdomain.com npm run buildUpload the Frontend/static output to the frontend S3 bucket.
The backend URL can also be changed after deployment without a rebuild:
edit config.js at the root of the uploaded static content (it sits
alongside index.html) and set API_URL to the new address. This
overrides VITE_API_URL at runtime, useful if the backend address
changes later. See Frontend/src/api/config.js.
- KMS: encryption at rest is applied via RDS's and S3's default encryption settings, both backed by a KMS key. No separate KMS setup is required beyond enabling default encryption on those resources.
This production path hasn't been walked end-to-end yet in this project. Validate it before relying on it for real hiring.