5.5 KiB
Deploy on Ubuntu (EC2)
The API listens on port 4040. Nginx serves the frontend build and proxies /api to that port. PM2 keeps the API running.
Replace example.com, bucket names, and account IDs with the real values before you apply anything.
1. Server packages
sudo apt update
sudo apt install -y curl ca-certificates nginx weasyprint
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
sudo npm install -g pm2
node -v
Confirm Node is 20 or newer. Agreement PDFs are printed by the weasyprint command, which exits after each file. WEASYPRINT_BIN can point at another binary.
2. Application
sudo mkdir -p /var/www/ppaimt-crest
sudo chown "$USER":"$USER" /var/www/ppaimt-crest
cd /var/www/ppaimt-crest
git clone <repository-url> .
cp backend/.env.example backend/.env
Edit backend/.env on the server. Set NODE_ENV=production, PORT=4040, CORS_ORIGIN to the public site origin, and the database, S3, mail, and token values. Do not commit that file.
npm install
npm run build
3. PM2
ecosystem.config.cjs starts backend/src/server.js on port 4040 and restarts it if it exits. PM2 sets PORT itself, so that value wins over backend/.env.
pm2 start ecosystem.config.cjs
pm2 save
pm2 startup
Run the command pm2 startup prints, then pm2 save again.
Check the process:
pm2 status
curl -s http://127.0.0.1:4040/api/health
4. Nginx
Copy the example config and point the site name at the real domain. The file serves frontend/dist, gzip-compresses text responses, sends basic security headers, and proxies /api/ to 127.0.0.1:4040.
sudo cp nginx.conf /etc/nginx/sites-available/ppaimt-crest
sudo ln -s /etc/nginx/sites-available/ppaimt-crest /etc/nginx/sites-enabled/ppaimt-crest
sudo nginx -t
sudo systemctl reload nginx
Open the EC2 security group for ports 80 and 443. Leave port 4040 closed to the public. The API is only reached through Nginx.
The in-app rate limit uses the address Hapi sees. Set TRUST_PROXY=true on the server so the API reads the first X-Forwarded-For hop from Nginx. Leave it false anywhere the process is reached directly, so a client cannot spoof the address.
5. HTTPS with Certbot
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com
Certbot edits the Nginx site and installs a renewal timer. Confirm renewal with:
sudo certbot renew --dry-run
6. RDS MySQL
- Use Amazon RDS for MySQL 8. Do not enable public access.
- Put the database in private subnets. Its security group should allow inbound TCP 3306 only from the EC2 instance security group.
- Create a database user with rights on the application database only.
- Set
DB_HOST,DB_PORT,DB_USER,DB_PASSWORD, andDB_NAMEinbackend/.env. - Set
DB_SSL=true. The Knex connection then uses TLS and checks the server certificate. - Turn on automated backups and keep them for 7 to 35 days. The restore drill is in RUNBOOK.md.
GET /api/health checks the database. When S3_BUCKET is set it also checks the bucket, and returns 503 if either check fails. With no bucket, local disk storage is reported as skipped.
7. S3
Create a private bucket in the same region as the app (for example ap-south-1).
- Block all public access.
- Disable ACLs (bucket owner enforced).
- Default encryption: SSE-S3 or SSE-KMS.
- Enable versioning before go-live:
aws s3api put-bucket-versioning --bucket YOUR_BUCKET_NAME --versioning-configuration Status=Enabled.
The application should use an IAM role on the EC2 instance, not long-lived keys in the image. If you must use keys for a temporary setup, put them in S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY on the server only.
Example bucket policy. It denies non-TLS requests and allows one application role to read and write objects. It does not grant public access.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::YOUR_BUCKET_NAME", "arn:aws:s3:::YOUR_BUCKET_NAME/*"],
"Condition": {
"Bool": { "aws:SecureTransport": "false" }
}
},
{
"Sid": "AllowAppRoleObjectAccess",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::YOUR_ACCOUNT_ID:role/YOUR_APP_ROLE"
},
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::YOUR_BUCKET_NAME/*"
}
]
}
Presigned URLs are the only way objects should be shared. Do not add a public s3:GetObject statement.
8. Environment
Copy backend/.env.example to backend/.env on the server. Set NODE_ENV=production, PORT=4040, TRUST_PROXY=true, and CORS_ORIGIN to the public site origin. DATA_ENCRYPTION_KEY is 32 bytes encoded as base64. Create it in Secrets Manager and copy the value into the server env. Do not commit it.
VITE_SUPPORT_EMAIL in frontend/.env is the address shown in the public footer. Set it before npm run build.
GitHub Actions runs lint, the backend tests against MySQL 8, npm audit --audit-level=high, and the frontend build. Deploy is a manual workflow that SSHs to the instance and runs scripts/deploy.sh. The host, user, and key are GitHub secrets DEPLOY_HOST, DEPLOY_USER, and DEPLOY_KEY.
Day-to-day operations, secret rotation, and the production checklist are in RUNBOOK.md. The CREST acceptance list is in UAT.md.