Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.git
.github
.next
.swc
node_modules
test-results
playwright-report
coverage
.env
.env.*
!.env.example
*.log

250 changes: 212 additions & 38 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,60 +4,234 @@ on:
push:
branches:
- main
- App/aws-replica
workflow_dispatch:

permissions:
contents: read
id-token: write

concurrency:
group: encodex-production
cancel-in-progress: false

env:
AWS_REGION: us-east-2
AWS_PAGER: ""
ECR_REPOSITORY: encodex
ENV_PARAMETER_NAME: /encodex/env

jobs:
build-and-deploy:
name: Build, Push, Deploy
runs-on: ubuntu-latest
timeout-minutes: 20

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
- name: Configure AWS credentials with GitHub OIDC
uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-2
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
role-session-name: encodex-${{ github.run_id }}-${{ github.run_attempt }}
aws-region: ${{ env.AWS_REGION }}
unset-current-credentials: true

- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2

- name: Build and push image to ECR
- name: Build, migrate, and push image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
shell: bash
run: |
docker build -f docker/Dockerfile \
--build-arg JWT_SECRET="${{ secrets.JWT_SECRET }}" \
--build-arg DATABASE_URL="${{ secrets.DATABASE_URL }}" \
-t $ECR_REGISTRY/encodex:$IMAGE_TAG \
-t $ECR_REGISTRY/encodex:latest .
docker push $ECR_REGISTRY/encodex:$IMAGE_TAG
docker push $ECR_REGISTRY/encodex:latest

- name: Deploy to EC2 via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.EC2_HOST }}
username: ubuntu
key: ${{ secrets.EC2_SSH_KEY }}
request_pty: true
script: |
echo '${{ secrets.ENV_FILE }}' > /tmp/.env
sudo kubectl create secret generic encodex-secrets \
--from-env-file=/tmp/.env \
--dry-run=client -o yaml | sudo kubectl apply -f -
rm /tmp/.env
ECR_TOKEN=$(aws ecr get-login-password --region us-east-2)
sudo kubectl create secret docker-registry ecr-secret \
--docker-server=509194952795.dkr.ecr.us-east-2.amazonaws.com \
--docker-username=AWS \
--docker-password=$ECR_TOKEN \
--dry-run=client -o yaml | sudo kubectl apply -f -
sudo kubectl apply -f /home/ubuntu/k8s/deployment.yml
sudo kubectl apply -f /home/ubuntu/k8s/service.yml
sudo kubectl set image deployment/encodex-app encodex=${{ steps.login-ecr.outputs.registry }}/encodex:${{ github.sha }}
sudo kubectl rollout status deployment/encodex-app --timeout=120s
set -Eeuo pipefail

docker build \
-f docker/Dockerfile \
--build-arg DATABASE_URL \
--build-arg JWT_SECRET \
--tag "$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" \
Comment on lines +56 to +60
--tag "$ECR_REGISTRY/$ECR_REPOSITORY:latest" \
.
Comment on lines +60 to +62

docker build \
-f docker/Dockerfile \
--target deps \
--tag "encodex-migrations:$IMAGE_TAG" \
.

docker run --rm \
--env DATABASE_URL \
"encodex-migrations:$IMAGE_TAG" \
npx prisma migrate deploy

docker push "$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
docker push "$ECR_REGISTRY/$ECR_REPOSITORY:latest"

- name: Update runtime environment parameter
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
shell: bash
run: |
set -Eeuo pipefail

env_path="$(mktemp "$RUNNER_TEMP/encodex-env.XXXXXX")"
trap 'rm -f -- "$env_path"' EXIT
chmod 600 "$env_path"

if [[ -z "$DATABASE_URL" || -z "$JWT_SECRET" ]]; then
echo "::error::DATABASE_URL and JWT_SECRET must both be configured."
exit 1
fi

if [[ "$DATABASE_URL" == *$'\n'* || "$DATABASE_URL" == *$'\r'* ||
"$JWT_SECRET" == *$'\n'* || "$JWT_SECRET" == *$'\r'* ]]; then
echo "::error::Runtime secret values must each be a single line."
exit 1
fi

printf 'DATABASE_URL=%s\nJWT_SECRET=%s\n' \
"$DATABASE_URL" "$JWT_SECRET" > "$env_path"

env_bytes="$(wc -c < "$env_path" | tr -d '[:space:]')"
if (( env_bytes == 0 || env_bytes > 4096 )); then
echo "::error::Runtime configuration must contain 1-4096 bytes."
exit 1
fi

aws ssm put-parameter \
--name "$ENV_PARAMETER_NAME" \
--type SecureString \
--tier Standard \
--value "file://$env_path" \
--overwrite \
>/dev/null

- name: Deploy through SSM Run Command
env:
INSTANCE_ID: ${{ vars.EC2_INSTANCE_ID }}
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
shell: bash
run: |
set -Eeuo pipefail

if [[ ! "$INSTANCE_ID" =~ ^i-[0-9a-f]{8,17}$ ]]; then
echo "::error::EC2_INSTANCE_ID is missing or invalid."
exit 1
fi

if [[ ! "$IMAGE_TAG" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Unexpected Git commit SHA."
exit 1
fi

image_uri="$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
deployment_b64="$(base64 -w 0 < k8s/deployment.yml)"
service_b64="$(base64 -w 0 < k8s/service.yml)"
parameters_file="$(mktemp "$RUNNER_TEMP/ssm-parameters.XXXXXX.json")"
trap 'rm -f -- "$parameters_file"' EXIT

jq -n \
--arg region "$AWS_REGION" \
--arg registry "$ECR_REGISTRY" \
--arg image "$image_uri" \
--arg parameter "$ENV_PARAMETER_NAME" \
--arg deployment "$deployment_b64" \
--arg service "$service_b64" \
'{
commands: [
"exec /usr/bin/env bash <<\u0027ENCODEX_DEPLOY_SCRIPT\u0027",
"set -Eeuo pipefail",
("AWS_REGION=" + ($region | @sh)),
("ECR_REGISTRY=" + ($registry | @sh)),
("IMAGE_URI=" + ($image | @sh)),
("ENV_PARAMETER_NAME=" + ($parameter | @sh)),
("DEPLOYMENT_B64=" + ($deployment | @sh)),
("SERVICE_B64=" + ($service | @sh)),
"umask 077",
"work_dir=$(mktemp -d /tmp/encodex-deploy.XXXXXX)",
"cleanup() { rm -rf -- \"$work_dir\"; }",
"trap cleanup EXIT",
"printf \"%s\" \"$DEPLOYMENT_B64\" | base64 --decode > \"$work_dir/deployment.yml\"",
"printf \"%s\" \"$SERVICE_B64\" | base64 --decode > \"$work_dir/service.yml\"",
"sed -i -E \"0,/^[[:space:]]*image:[[:space:]]*/s#^([[:space:]]*image:[[:space:]]*).*\\$#\\1$IMAGE_URI#\" \"$work_dir/deployment.yml\"",
"aws ssm get-parameter --region \"$AWS_REGION\" --name \"$ENV_PARAMETER_NAME\" --with-decryption --output json | jq -er \".Parameter.Value\" > \"$work_dir/app.env\"",
"sed -i \"s/\\r$//\" \"$work_dir/app.env\"",
"grep -qE \"^DATABASE_URL=.+$\" \"$work_dir/app.env\"",
"grep -qE \"^JWT_SECRET=.+$\" \"$work_dir/app.env\"",
"/usr/local/bin/k3s kubectl create secret generic encodex-secrets --from-env-file=\"$work_dir/app.env\" --dry-run=client -o yaml | /usr/local/bin/k3s kubectl apply -f -",
"ECR_TOKEN=$(aws ecr get-login-password --region \"$AWS_REGION\")",
"/usr/local/bin/k3s kubectl create secret docker-registry ecr-secret --docker-server=\"$ECR_REGISTRY\" --docker-username=AWS --docker-password=\"$ECR_TOKEN\" --dry-run=client -o yaml | /usr/local/bin/k3s kubectl apply -f -",
"unset ECR_TOKEN",
"/usr/local/bin/k3s kubectl apply -f \"$work_dir/service.yml\"",
"/usr/local/bin/k3s kubectl apply -f \"$work_dir/deployment.yml\"",
"/usr/local/bin/k3s kubectl rollout restart deployment/encodex-app",
"/usr/local/bin/k3s kubectl rollout status deployment/encodex-app --timeout=180s",
"for attempt in {1..15}; do if curl -fsS http://127.0.0.1:30180/login >/dev/null; then break; fi; if (( attempt == 15 )); then exit 1; fi; sleep 2; done",
"/usr/local/bin/k3s kubectl get deployment encodex-app -o jsonpath=\"{.spec.template.spec.containers[?(@.name==\\\"encodex\\\")].image}\"",
"printf \"\\n\"",
"ENCODEX_DEPLOY_SCRIPT"
],
executionTimeout: ["300"]
}' > "$parameters_file"

command_id="$(
aws ssm send-command \
--region "$AWS_REGION" \
--document-name AWS-RunShellScript \
--instance-ids "$INSTANCE_ID" \
--comment "Encodex deployment $IMAGE_TAG" \
--timeout-seconds 60 \
--parameters "file://$parameters_file" \
--query Command.CommandId \
--output text
)"

echo "SSM command: $command_id"

status=""
deadline=$((SECONDS + 360))
while (( SECONDS < deadline )); do
status="$(
aws ssm get-command-invocation \
--region "$AWS_REGION" \
--command-id "$command_id" \
--instance-id "$INSTANCE_ID" \
--query Status \
--output text \
2>/dev/null || true
)"

case "$status" in
Success)
break
;;
Pending|InProgress|Delayed|"")
sleep 5
;;
*)
break
;;
esac
done

aws ssm get-command-invocation \
--region "$AWS_REGION" \
--command-id "$command_id" \
--instance-id "$INSTANCE_ID" \
--query '{Status:Status,Output:StandardOutputContent,Error:StandardErrorContent}' \
--output json || true

if [[ "$status" != "Success" ]]; then
echo "::error::SSM deployment failed with status: ${status:-unknown}"
exit 1
fi
92 changes: 92 additions & 0 deletions infra/aws/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Encodex AWS replica

This stack reproduces the current Encodex runtime at essentially the same cost:

- one `t3.small` Ubuntu 22.04 EC2 instance in `us-east-2`
- one 8 GiB root volume
- single-node k3s with the existing Kubernetes manifests
- Caddy in front of the k3s NodePort
- one private ECR repository
- one public IPv4 address

The replica deliberately replaces long-lived AWS/SSH keys with GitHub OIDC and
AWS Systems Manager. It also closes public SSH and NodePort access, encrypts the
disk, uses `gp3`, and gives the server a stable Elastic IP. These changes do not
materially increase cost or change application behavior.

## Deploy the infrastructure

The target account must have a default VPC and public subnet in `us-east-2`.

```powershell
aws cloudformation deploy `
--profile encodex-new `
--region us-east-2 `
--stack-name encodex `
--template-file infra/aws/encodex-replica.yml `
--capabilities CAPABILITY_NAMED_IAM `
--parameter-overrides `
VpcId=vpc-06c498b1a5b641dfb `
PublicSubnetId=subnet-0d53064c3df4bdd1c
```

CloudFormation installs k3s, Caddy, AWS CLI, and the SSM agent. The initial
Caddy configuration serves plain HTTP through the Elastic IP so the replica can
be tested before DNS changes.

## Configure GitHub

Set these repository variables from the CloudFormation outputs:

- `AWS_DEPLOY_ROLE_ARN`
- `EC2_INSTANCE_ID`

Configure these repository secrets:

- `DATABASE_URL`
- `JWT_SECRET`

The workflow combines those two secrets into the encrypted runtime parameter;
the legacy `ENV_FILE` secret is no longer used. `DATABASE_URL` must be Railway's
public PostgreSQL TCP-proxy URL, for example:

```dotenv
DATABASE_URL=<Railway public PostgreSQL TCP proxy URL>
```

A `postgres.railway.internal` hostname cannot be reached from AWS. If the
current GitHub secrets still deploy the working demo, reuse them without reading
or replacing their values.

## Deploy and validate before DNS

The workflow builds the image, applies the checked-in Prisma migrations, pushes
to the new ECR repository, stores the two runtime secrets as the Standard
SecureString `/encodex/env`, and deploys through SSM.

Before changing DNS, validate:

```text
http://<ElasticIp>/login
```

## Namecheap cutover

After the HTTP replica is healthy:

1. Change the Namecheap root `A` record (`@`) to the stack's `ElasticIp` output.
2. Wait until `encodexdrive.com` resolves to the new address.
3. Through SSM, switch Caddy to its production configuration:

```bash
sudo ln -sfn /etc/caddy/Caddyfile.production /etc/caddy/Caddyfile
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

4. Verify `https://encodexdrive.com/login` and an authenticated database-backed
API request.
5. Keep the old EC2 instance running for a rollback window.

The old AWS credentials and SSH GitHub secrets should only be removed after the
new deployment and DNS cutover have been stable.
Loading