NextKey is a project starter that streamlines deploying password-protected Next.js applications to AWS. The NextKey portal is a flexible password protection system which allows you to host different versions of a website on a single domain, with each version accessible via a unique password.
- Why NextKey?
- Features
- Getting Started
- Password Variations
- Preparing the AWS Prerequisites
- Testing
- Optional Features
- Appendix: Full Stack
- Acknowledgments
Protecting a new site with NextKey can be useful in scenarios such as:
- Prototype development. Easily get feedback on different versions of a website by sharing the corresponding password.
- Personal/private site. Protect your personal domain and limit access to you and people you know. You can host different subsites on your personal domain, and unlock each one with a specific password.
Beyond password protection, this starter takes care of key aspects of deploying a secure Next.js application to AWS such as provisioning a secure EC2 instance, requesting a TLS certificate, configuring a CI/CD pipeline, and serving your site at your owned domain.
All resources provisioned by the CDK stack run for free under the AWS Free Tier (for 12 months). Your costs are the domain name and associated Route 53 Hosted Zone, which are required to secure your infrastructure behind HTTPS (domain names run around $10/year; a Hosted Zone is $0.50/month).
- Password Protection: Host different subsites, or different versions of a website, on a single domain, with each accessible via a unique password. Secured with a signed JSON Web Token (JWT).
- Requesting TLS (HTTPS): Automates requesting a TLS certificate for your domain and securely uploading to S3. Your EC2 instance will download the TLS certificate from S3 and use it to securely launch a HTTPS server.
- Route53 Hosting: NextKey automates connecting your EC2 instance to your Route53 Hosted Zone to serve your website at your owned domain.
- CI/CD Pipeline: Connects your GitHub repository to a CodePipeline which will continuously deploy new changes to your app.
- Modern Styling with Stitches: Stitches.dev is a modern CSS-in-JS framework with near-zero runtime and first-class support for tokens.
- Unit and End-to-End Testing: NextKey includes Jest for unit testing and Testcafe for end-to-end testing.
To try it locally, you will need:
zsh
orbash
wget
orcurl
git
(v2.28+)rename
node
(v18.x.x)pnpm
(v1.x)
Optionally, to set up with an AWS database, you will need:
- Docker Engine installed and running
Later, to deploy to AWS, you will need:
- An AWS account created
- AWS CLI configured
- A domain name purchased in or imported into Amazon Route 53
- A Route 53 Hosted Zone created for domain name
- Connection to GitHub account/repo created in AWS Dev Tools
- (Optional) HTTPS certificate created or imported in AWS Certificate Manager (Not recommended: only use when load balancer is needed. Requires uncommenting code in cdk/my-app-cdk/my-app-cdk-stack.ts. Adds additional cost after AWS Free Tier.)
(See Preparing the AWS Prerequisites for a detailed walkthrough)
-
cd
to a project directory -
Run the install script via
wget
orcurl
:# (optionally replace zsh with bash) # wget $ zsh <(wget -qO- https://raw.githubusercontent.com/tw-studio/nextkey-aws-starter/main/scripts/create-nextkey-app.zsh) # curl $ zsh <(curl -fsSo- https://raw.githubusercontent.com/tw-studio/nextkey-aws-starter/main/scripts/create-nextkey-app.zsh)
The starter is configured with two variations to start, initially called main and one. These correspond to the pages stored under src/pages/_main/ and src/pages/_one/ respectively. The correct password entered into the textbox will serve either the main or one variation.
To try this locally:
-
First, run the setup script and follow any instructions that appear:
$ pnpm dev:setup
-
Once all setup steps are complete, build the project and serve it locally in development mode by running:
$ pnpm serve:dev
-
Visit http://localhost:3000 in a browser
-
Try a few inputs
-
Enter
main password
to access the main app variation -
In the same tab, navigate to http://localhost:3000/welcome via the address bar
-
Enter
password for one
to access variation one -
To stop the custom Express server, it is not enough to stop the foreground process as it is run with
pm2
as a background process. Instead, run:$ pnpm stop
For faster development cycles, the development server can bypass the NextKey portal page and serve the desired app variation directly. Additionally, doing so will enable hot reloading, so that app code changes reflect in the browser immediately.
To run the main variation directly:
-
Run:
$ pnpm dev # same as pnpm dev:main
-
Visit http://localhost:4000
To run the variation one:
-
Run:
$ pnpm dev:one
-
Visit http://localhost:4000
To stop the hot reloading server, simply stop the foreground process with Ctrl-C
.
Disabling the NextKey portal is easy to do by changing a setting. When disabled, the main website variation will be served directly, without the Nextkey password portal.
To disable the NextKey portal:
-
Open
.env/.secrets.js
-
Set the value for
useNextKey
to0
(defaults to1
) -
(Optional) Serve the development server locally to validate the behavior:
$ pnpm serve:dev
-
Visit http://localhost:3000 in a browser
By default, both development servers will run in http, not https. The starter simplifies the setup for local https development, however, this is true only for the custom server (started with pnpm serve:dev
or pnpm start:dev
, not the hot reloading server started with pnpm dev
).
Steps to use https locally:
- Run
pnpm https:local:setup
and follow any instructions - Run
mkcert -install
if directed to do so in the output from step 1. Enter the Sudo password when prompted. - Run
pnpm serve:dev
(orpnpm start:dev
if pages are already built) to serve locally with https - Visit the app in the browser at https://localhost:3000
This starter streamlines your deployment to a fully functional and secure stack hosted on AWS and set up for Continuous Deployment. Your cdk/my-app-cdk/ directory contains Infrastructure as Code (IaC), a complete infrastructure stack written with the AWS Cloud Development Kit (CDK), which defines and configures AWS elements in TypeScript.
Once your AWS prerequisites are set up, the entire first-time stack and code deployment takes less than 10 minutes. All resources created by the stack run for free under the AWS Free Tier (which lasts 12 months from account creation) when no other resources are also running (in particular other Load Balancers, EC2, or RDS instances).
Here are the steps:
-
Change directory to cdk/my-app-cdk/
-
Run
pnpm cdk:precheck
or manually rename RENAME_TO.secrets.js to .secrets.js in cdk/my-app-cdk/.env/ -
Commit & push your new full project directory to a GitHub repo
-
Ensure the following are prepared for AWS (see Preparing the AWS Prerequisites for a detailed walkthrough):
- An AWS account created
- AWS CLI configured
- A domain name purchased in or imported into Amazon Route 53
- A Route 53 Hosted Zone created for domain name
- Connection to GitHub account/repo created in AWS Dev Tools
- (Optional) HTTPS certificate created or imported in AWS Certificate Manager (Not recommended: only use when load balancer is needed. Requires uncommenting code in cdk/my-app-cdk/my-app-cdk-stack.ts. Adds additional cost after AWS Free Tier.)
-
Set the proper values for these in cdk/my-app-cdk/.env/.secrets.js
-
To serve your web app using free, properly signed HTTPS encryption:
- Navigate to cdk/create-certs-cdk/
- Set the required
CDK_CERT_
values in .env/.secrets.js - Decide to store values in SSM Parameter Store or to hardcode them in a file, then follow the instructions accordingly in user-data/run-certbot.sh
- Run
pnpm cdk:full
and follow any instructions - (optional) After the stack is successfully created, wait two minutes then check the S3 console to confirm the creation and upload of TLS certificate files.
- When successfully complete:
- Destroy the stack
- Set
useHttpsFromS3
to'1'
in cdk/my-app-cdk/.env/.secrets.js and in .env/.secrets.js
- (future) To refresh the TLS certificate files before their 3 month expiry period, follow the instructions in cdk/create-certs-cdk/README.md
-
Optionally change any project secret defaults in .env/.secrets.js
-
Put all project secrets prefixed with
jwt
,secret
, ordbProd
in your AWS SSM Parameter Store per the steps in .env/.secrets.js. For example:$ aws ssm put-parameter \ --name '/my-app/prod/jwtSubMain' \ --value 'jwtSubMain secret' \ --type 'SecureString'
-
From cdk/my-app-cdk/, synthesize and deploy your app infrastructure stack with:
$ pnpm cdk:full
-
When that's complete, go to CodePipeline console and wait until the deployment is fully complete
-
When complete, visit your domain name (using https) in a browser
-
Push future changes to master to continuously deploy your app to production
While all resources run for free under the Free Tier, it's a good practice to keep usage minimal by regularly destroying the CDK stack when not actively needing the production deployment:
-
From cdk/my-app-cdk/, run:
$ pnpm cdk:destroy
Your src/pages/ directory looks like this:
├── pages/
│ ├── _app.page.tsx
│ ├── _document.page.jsx
│ ├── _one/
│ │ ├── index.page.tsx
│ │ └── index.spec.ts
│ └── _main/
│ ├── index.page.tsx
│ └── index.spec.ts
Notice the two directories _main/
and _one/
. Those are the two default app variations that are served for different passwords.
- To visit the pages under
_main/
, type in at the launcher page the default password:main password
- To visit the pages under
_one/
, type in at the launcher page the default password:password for one
Once authenticated, the pages you have access to will only be those in your permitted directory. For example, after authenticating with main secret
, you will only be served the pages under _main/
— though they will appear to be served at the root — and you will not be able to access any pages under _one/
.
You may freely add an app variation in the following way:
Let's add an app variation called cinematic
.
-
First, create a new directory _cinematic/ inside src/pages/
-
Next, open src/middleware.page.ts and change the following:
-
Add the path
_cinematic/
to the arraypathBases
:const pathBases = [ '/_main', '/_one', '/_cinematic', ]
-
Extend the first
pathname
check withpathBases[2]
:if ( pathname.startsWith(`${pathBases[0]}`) || pathname.startsWith(`${pathBases[1]}`) || pathname.startsWith(`${pathBases[2]}`) ) { return res.rewrite('/404') }
-
Add a case to the switch statement like the following:
// ... case process.env.JWT_SUB_CINEMATIC: return res.rewrite(`${pathBases[2]}${pathname}`) // ...
-
-
Now let's add that mystery environment variable and one other. Open .env/.secrets.js, and add two environment variables:
const jwtSubCinematic = 'cinematic' // a unique permissions identifier that no one will see const secretKeyCinematic = '<PASSWORD>' // the password to access the cinematic variation module.exports = { // add these: jwtSubCinematic, secretKeyCinematic, }
-
Open .env/common.env.js, and add two entries:
const envCommon = { JWT_SUB_CINEMATIC: '', SECRET_KEY_CINEMATIC: '', }
-
Open .env/production.env.js, and add:
const { jwtSubCinematic, secretKeyCinematic, } = require('./.production.secrets.js') const envProduction = { JWT_SUB_CINEMATIC: jwtSubCinematic ?? '', SECRET_KEY_CINEMATIC: secretKeyCinematic ?? '', }
-
Make those same additions to .env/development.env.js and .env/testing.env.js
-
With that complete, open server/index.ts, initialize the new variables and modify the switch statement in
unlockWithKey()
::// initializing variables: const jwtSubCinematic = process.env.JWT_SUB_CINEMATIC ?? '' const secretKeyCinematic = process.env.SECRET_KEY_CINEMATIC ?? '' // in unlockWithKey: switch (req.body[keyName]) { case secretKeyMain: jwtSub = jwtSubMain break case secretKeyOne: jwtSub = jwtSubOne break case secretKeyCinematic: jwtSub = jwtSubCinematic break default: console.error('Key not recognized') // eslint-disable-line no-console res.status(500).send(Strings.msg500ServerError) }
-
Finally, put the secrets into SSM Parameter Store by running:
$ aws ssm put-parameter --name "/my-app/prod/jwtSubCinematic" --value "<value>" --type "SecureString" $ aws ssm put-parameter --name "/my-app/prod/secretKeyCinematic" --value "<value>" --type "SecureString"
-
That's it! Run the server locally with
pnpm serve:dev
to try it. Then to test in production, create pages under _cinematic/, commit & push your changes to GitHub, wait a few minutes for your changes to deploy, then visit your domain to check the result.
To configure your app for deployment to AWS, you will need to provide these six values in cdk/my-app-cdk/.env/.secrets.js:
const cdkGitHubConnectionArn = ''
const cdkGitHubOwner = ''
const cdkGitHubRepo = ''
const cdkGitHubRepoBranch = ''
const cdkHostedZoneId = ''
const cdkHostname = ''
You will know these values after these six steps:
Here's an overview of the first steps: Prerequisites to use the AWS CLI version 2 | AWS
- Go to AWS sign up and create an account for free.
- Note: Billing details are required, but all resources created by the included CDK stack will run for free under the AWS Free Tier (which lasts for 12 months from account creation)
- (Optional, but recommended) Create a non-root IAM user account following these steps: Step 2: Create an IAM user account
- Step 3: Create an access key ID and secret access key
- Install or update the latest version of the AWS CLI
- Run
aws configure
to input your access key ID and secret access key: Quick setup
Your AWS CLI should now be good to go.
- First, push your new project to a GitHub repository
- Your username will be
cdkGitHubOwner
- The repository name will be
cdkGitHubRepo
- Your username will be
- Sign in to the AWS Console
- Use the search box at the top to go to the CodePipeline console
- In the left nav, expand Settings, then choose Connections
- Click Create connection
- Choose GitHub, then choose Connect to GitHub, then follow the instructions
- Choosing to authorize a selected repository or all repositories (past and future) is up to you
- Back on the Connections page, copy the ARN to the GitHub Connection just created (this will be
cdkGitHubConnectionARN
)
Note: This step will require an annual fee (~$10/year).
- Go to the Amazon Route 53 console
- In the Dashboard, scroll to Register domains, then either search availability for a new domain name or choose transfer your existing domains
- Follow the instructions until you own or have transferred a domain name
- Record the domain name in
cdkHostname
(e.g.const cdkHostname = 'yourdomain.com'
).
Note: This step will cost $0.50/month with low traffic.
- Go to the Amazon Route 53 console
- In the left nav, choose Hosted zones
- Choose Create hosted zone
- Type in the domain name, then choose Create hosted zone
- Once created, enter the new hosted zone entry details
- Expand the Hosted zone details
- Copy the Hosted zone ID into
cdkHostedZoneId
Note: To stop charges, you must delete the hosted zone.
(Optional / Not Recommended) |6|
Create (or import) an HTTPS Certificate in AWS Certificate Manager (ACM)
Not recommended: Only use when load balancer is needed. Requires uncommenting code in cdk/my-app-cdk/my-app-cdk-stack.ts
. Adds additional cost after AWS Free Tier.
- Go to the AWS Certificate Manager (ACM) console
- Choose either Request a certificate or Import a certificate (either service is free)
- Follow the instructions until you have an HTTPS Certificate in AWS Certificate Manager
- Copy its ARN into
cdkHttpsCertificateArn
The starter includes Jest unit tests and Testcafe integration tests.
Run the included Jest unit tests with:
$ pnpm jest # runs all tests (same as pnpm jest:all)
$ pnpm jest:app # only runs tests under src/
Check package.json for scripts starting with jest:
for all available Jest tests.
Testcafe can run integration tests in an actual (headless) browser. It supports several browsers, including Chrome, Firefox, and Safari.
-
First, start the server. You can do it one of two ways:
$ pnpm dev # starts hot reloading src/pages/_main/ at http://localhost:4000 $ pnpm start:test # requires 'pnpm build:all:test' first; runs at https://localhost:3000 $ pnpm serve:test # runs 'pnpm build:all:test' and 'pnpm start:test'; runs at https://localhost:3000
-
Depending on how you started the server, run
testcafe
:$ pnpm testcafe # test against http://localhost:4000 $ pnpm testcafe:main # test against https://localhost:3000
GitHub will automatically run PR tests via the included GitHub Actions workflow when creating a new Pull Request into the main branch.
This starter is configured to run and interface with PostgreSQL in local development and in production on an AWS RDS instance.
-
This starter is preconfigured for migration-based database development. This means all changes to a database are reflected in a sequence of migration files. Migration files are checked into a repository along with feature code, which makes the database effectively "versioned" alongside commits and releases.
-
The system for running and tracking migrations is enabled by Flyway whose Community version is free to use. As Flyway is Java based, this starter provides scripts to run Flyway commands in a Docker container spun up on demand.
-
Interacting with the database in client code is achieved through Knex.js database connections and Objection.js Model objects (which are built on Knex.js).
Run pnpm db:dev:setup
to be guided through unmet steps for running a Postgres database container locally. This will include:
- Set
useDatabase
to'1'
in .env/.secrets.js. - Have Docker Engine installed and running.
- (Optional) Change
dbDevPassword
in `.env/.secrets.js to a different password
The setup script will list several next steps that can be explored:
- Control database migrations with
pnpm db:dev:flyway
commands, ex:pnpm db:dev:flyway migrate
- Modify database content and data models in db/migrations/ and models/
- Connect to postgres when needed with
pnpm db:dev:connect:psql
- Run unit tests on the demo database with
pnpm jest:db
- Run unit tests on the demo data models with
pnpm jest:models
A quick way to understand how all the pieces fit together is with an illustration of a typical workflow of making a change to the database:
-
You're working on version 0.0.0 of your app. You create a new sql file in db/migrations with a title following a very specific format, such as
V0.0.0_0__My_awesome_database.sql
(see Flyway Migrations for their naming conventions and requirements). -
You write the changes you want to make to your database in sql, for example:
/* V0.0.0_0__My_awesome_database.sql */ CREATE TABLE IF NOT EXISTS awesome_things ( awesome_thing_id int GENERATED ALWAYS AS IDENTITY primary key, display_name text NOT NULL );
-
You add an additional sql migration file to insert test content into your database called
V0.0.0_1__TEST_Insert_awesome_things.sql
. Flyway will run migration files in order by their version number (in this case,0.0.0_1
after0.0.0_0
). For ideas on how to organize release and test migration files separately, see Organising your migrations. -
You're ready to create your database. You run
pnpm db:dev:setup
(or simplypnpm dev
) to run Flyway'smigrate
command on a new local Postgres database, all with Docker Compose. The script completes successfully and your local database is initialized and ready to go. -
(Optional) You want to interact with the local database directly. You make sure
postgresql
is installed, and you runpnpm db:dev:connect:psql
to connect to the local database withpsql
. -
In your Next.js pages and components, you interface with your database primarily through Objection.js Models which you create in the models/ directory. You create models/AwesomeThings.js, which defines a model for your database table per the guidelines at Objection.js. You sometimes may interface with the database more directly by importing and using the db/knex object.
-
You write your pages' database queries as much as possible inside
getServerSideProps()
to achieve server-side rendering. (See Data Fetching: getServerSideProps) -
You run
pnpm dev
to interact with your app in the browser.pnpm dev
will also run all migrations found in db/migrations/ on the locally running Postgres container. -
You write tests for your database in db/test/ and tests for your models in models/. These will automatically be discovered when running
pnpm jest
and thepnpm jest:all:
variants. You can also run them specifically withpnpm jest:db
andpnpm jest:models
. -
(Optional) Your database and migration files look good and you're ready to check in your changes. You push your changes to a development branch then create a Pull Request into main. This triggers a GitHub Actions workflow which sets up all dependencies, including Flyway and Postgres, and ultimately runs
pnpm jest:all:PR
to run all discoverable Jest tests. -
You commit your changes to main. Your app is already deployed to AWS via the included CDK stack and it is running a Free Tier RDS PostgreSQL instance. CodePipeline detects your changes in the repository and starts a CodeDeploy deployment. As part of the deployment scripts, a Flyway container is spun up to run
migrate
on your production database. Your database and app code update successfully and your changes are live.
NextKey will take care of initializing a RDS instance qualified for the AWS Free Tier with minimal configuration. The minimal steps are:
- Set
useDatabase
to'1'
in .env/.secrets.js - Set
cdkUseDatabase
to'1'
in cdk/my-app-cdk/.env/.secrets.js - Set values for
cdkDb*
variables in cdk/my-app-cdk/.env/.secrets.js - Upload those same values to AWS SSM Parameter Store as explained in .env/.secrets.js
After these requirements are met, deploying your app's CDK stack will automatically initialize a RDS instance which your deployed app will use.
- Securely connect to the RDS instance with psql from your development machine, run
pnpm db:prod:fwd:rds
in one terminal window, then in another runpnpm db:prod:fwd:psql
. The first script will set up a secure port forwarding session between your development machine and your ec2 instance. The second script will use this port forwarding to tunnel into your RDS instance with psql.
This starter includes basic support for feature flags to support trunk-based development.
To illustrate, when beginning a new feature, you can:
-
Add a new flag in .env/development.flags.js with name
FLAG_NEW_FEATURE
and set its value to'on'
:// .env/development.flags.js const flagsDevelopment = { FLAG_NEW_FEATURE: 'on', }
Note: Flags' environment variable names must be in snake uppercase format and begin with
'FLAG_'
. -
In app code, write new implementation logic that runs in development mode but not in production by surrounding it like in this example:
import * as flag from '../utils/code-flags' if (flag.isEnabled('newFeature')) { /* Only runs if process.env.FLAG_NEW_FEATURE is set to 'on' */ } else { /*...*/ }
-
Alternatively when testing variants, use
getVariant()
to check a flag's value directly:import * as flag from '../utils/code-flags' if (flag.getVariant('featureWithVariant') === 'blue') { /* Only runs if process.env.FLAG_FEATURE_WITH_VARIANT has value 'blue' */ } else if (flag.getVariant('featureWithVariant') === 'green') { /* Only runs if process.env.FLAG_FEATURE_WITH_VARIANT has value 'green' */ } else { /*...*/ }
-
Commit this code into the trunk knowing it won't affect production code.
-
When ready to enable in production, add the appropriate flag in .env/production.flags.js and push to remote master:
// .env/production.flags.js const flagsProduction = { FLAG_NEW_FEATURE: 'on', }
-
Finally, when the feature is demonstrated to work in production and all is well, remember to remove this flag's code to keep things tidy.
- Front End
- Server
- Testing
- Database & Data Model
- Infrastructure as Code (IaC)
- Deployment
- Continuous Delivery
- Compute
- Network
- Artifact Storage
- Styles
- Linting & Formatting
- Code Repository
- Miscellaneous
The font used for the NextKey portal page is PixL by Keith Bates at K-Type Foundry.