Hej, dzisiaj jako Innokrea rozwiniemy dla Was ostatni temat dotyczący używania GitHub Actions. Spróbujemy wdrożyć naszą prostą aplikację i pokazać jak działają zaawansowane opcje dostępne w naszych workflows. Koniecznie przeczytajcie nasze poprzednie artykuły w temacie Actions, Terraform oraz DevSecOps. Spróbujemy pokazać Wam także, jak połączyć wiedzę z Terraforma, aby stworzyć środowisko chmurowe na które następnie trafi nasza, przygotowana w poprzednim artykule aplikacja. Wszystkie pliki są dostępne w naszym repozytorium GitLab.
Dodanie testów do projektu
Zacznijmy od utworzenia prostego testu do naszej aplikacji. Ten test będzie sprawdzał czy wiadomość zwracana na głównym endpoint’cie jest poprawna. Test będzie utworzony w pliku index.test.js w folderze app.
const request = require('supertest');
const { app } = require('.');
let server;
beforeAll(() => {
server = app.listen(0);
});
afterAll(() => {
server.close();
});
describe('Main Endpoint', () => {
it('should return "Hello, World!" if RESPONSE_MESSAGE is not set', async () => {
const response = await request(server).get('/');
expect(response.status).toBe(200);
expect(response.text).toBe('Hello, World!'); // Check that the response body is "Hello, World!"
});
it('should return custom message if RESPONSE_MESSAGE is set', async () => {
process.env.RESPONSE_MESSAGE = 'Custom Message';
const response = await request(server).get('/');
expect(response.status).toBe(200);
expect(response.text).toBe('Custom Message');
});
});
Następnie zmodyfikujmy przygotowane ostatnio workflow i dodamy krok wywołujący testy w naszym kodzie, a także zmienimy nazwę workflow oraz naszego job’a.
name: Test & Deploy
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm install
- name: Test the code
run: npm test

Rysunek 1 – Workflow uruchomione z sukcesem
Testy powinny przejść bez problemu, a całe workflow pokazać w GitHubie na zielono.
Użycie Terraform do stworzenia serwera EC2
Spróbujmy zacząć od stworzenia pojemnika S3, w chmurze publicznej AWS, na przechowywanie stanu .tfstate do Terraforma. Podejście to sprawia, że możemy współdzielić stan między naszym GitHub Actions i lokalnym środowiskiem, a także wraz z innymi deweloperami. Stan nie powinien być przechowywany w ramach współdzielonego repozytorium, a raczej pojedynczy plik powinien być na współdzielonej instancji S3. Po utworzeniu S3 napiszemy skrypt w Terraformie, który w ramach workflow GitHub Actions utworzy serwer EC2, na który później wdrożymy naszą aplikację express.js.
Zacznijmy od utworzenia pojemnika S3 w AWS, które zostało przedstawione na poniższych zrzutach ekranu.

Rysunek 2 – Konsola AWS w której tworzymy S3

Rysunek 3 – Proces tworzenia pojemnika S3, nazywamy go express-app-bucket
Zostawiamy wszystko na domyślnych ustawieniach i klikamy utwórz na dole konsoli. Następnie, aby mieć dostęp do tego pojemnika S3 utworzymy także konto serwisowe w module IAM. Jest to moduł do zarządzania kontami, rolami oraz grupami w AWS.

Rysunek 4 – Proces tworzenia konta serwisowego, wybranie nazwy

Rysunek 5 – Wybór policy, które będzie bezpośrednio podłączone do tworzonego konta

Rysunek 6 – Stworzenie nowego policy z granulowanym dostępem tylko do zasobu S3. Aby zastosować, będziesz musiał podać nazwę własnego pojemnika S3. Kod tej policy znajduje się w pliku infrastructure/s3_policy.json

Rysunek 7 – powrót do tworzenia konta serwisowego. Powinniśmy załączyć stworzoną policy do konta, a także dodać AmazonEC2FullAccess do konta, aby móc utworzyć nową instancję serwera EC2, gdzie nasza aplikacja będzie wdrożona.

Rysunek 8 – Podsumowanie ustawień stworzonego konta. Teraz musimy stworzyć dane do logowania (security credentials), które będziemy wykorzystywać w GitHub Actions. Klikamy utwórz nowe dane.

Rysunek 9 – wybieramy other i klikamy next, a następnie pobieramy je na komputer

Rysunek 10 – utworzone dane do logowania widoczne w konsoli AWS

Rysunek 11 – Jeśli chcemy korzystać z terraforma lokalnie możemy skonfigurować konto w terminalu z użyciem polecenia aws configure i podając pobrane wcześniej dane. Warto to zrobić, aby przetestować naszą konfigurację.
Utworzenie pliku Terraform
Mamy już skonfigurowane konto serwisowe AWS, pojemnik S3 na stan Terraform. Musimy jeszcze stworzyć parę kluczy SSH, które przy pomocy Terraforma zostaną wgrane na przy tworzeniu zasobu EC2 w AWS. Skorzystamy w tym celu z programu ssh-keygen.
ssh-keygen -t rsa -b 4096 -f ./id_rsa

Rysunek 12 – Generowanie klucza SSH, widok z poziomu folderu infrastructure w naszym utworzonym repozytorium na GitHub
Skrypt Terraform i utworzenie zasobów chmurowych
Korzystając z dokumentacji providera Terraform od AWS tworzymy w folderze infrastructure plik main.tf w którym umieścimy całą naszą konfigurację. Utworzymy z poziomu kodu Terraform zasób EC2 z tzw. Security Group pozwalającym na ruch HTTP i SSH. Jako miejsce przechowywania pliku stanu Terraform .tfstate użyjemy utworzonego uprzednio S3.
# Define AWS provider
provider "aws" {
region = "eu-north-1"
}
terraform {
backend "s3" {
bucket = "express-app-bucket"
key = "terraform.tfstate" # The location within the bucket
region = "eu-north-1" # Your desired AWS region
encrypt = true
}
}
# EC2 Key Pair
resource "aws_key_pair" "deployer_key" {
key_name = "github-deploy-key"
public_key = file("./id_rsa.pub")
}
# Security Group
resource "aws_security_group" "express_sg" {
name = "express-app-sg"
description = "Allow HTTP and SSH"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# EC2 Instance
resource "aws_instance" "express_instance" {
ami = "ami-05edb7c94b324f73c"
instance_type = "t3.micro"
key_name = aws_key_pair.deployer_key.key_name
security_groups = [
aws_security_group.express_sg.name,
]
tags = {
Name = "express-app-instance"
}
}
output "instance_public_ip" {
value = aws_instance.express_instance.public_ip
}
Jeśli uprzednio skonfigurowaliśmy naszą chmurę przy użyciu aws configure, będziemy mogli wykonać poniższą komendę.

Rysunek 13 – wykonanie pliku Terraform z poziomu terminala komputera. Terraform informuje, że zostaną utworzone zasoby w chmurze. Powinniśmy być uwierzytelnieni, jeśli tylko wcześniej wykonaliśmy komendę AWS configure

Rysunek 14 – po utworzeniu zasobów powinniśmy otrzymać adres ip na ekranie naszego serwera EC2

Rysunek 15 – logowanie na serwer przy pomocy naszego klucza prywatnego SSH
Udało nam się stworzyć infrastrukturę w chmurze publicznej AWS za pomocą kodu Terraform. Teraz zniszczmy zasoby z użyciem polecenia terraform destroy, aby później skorzystać z workflow do stworzenia całej infrastruktury. W tym celu dodamy pobrane wcześniej sekrety AWS do GitHuba w ramach funkcjonalności GitHub Secrets. Jest to funkcjonalność służąca do przechowywania sekretów w ramach GitHuba. Raz wrzucony sekret nie może być oczytany z poziomu UI, ale za to może być użyty w ramach runnera GitHub Actions.

Rysunek 16 – Dodanie sekretów AWS do GitHub secrets
Musimy także dodać klucz prywatny do secrets, aby móc zalogować się do naszego serwera podczas wdrożenia aplikacji. Klucz publiczny jest przechowywany w ramach repozytorium i będzie instalowany przez Terraforma podczas tworzenia zasobów w chmurze AWS.

Rysunek 17 – Dodanie klucza prywatnego, który będzie używany do logowania do instancji EC2 i postawienia naszej aplikacji w ramach chmury AWS
Teraz przygotujmy workflow używający Terraforma oraz korzystający z sekretów zapisanych w naszym menedżerze sekretów. Workflow będzie wykonywane manualnie i będzie wykorzystywać obiekt sekret na poziomie pojedynczego joba. Wykorzystamy komendy terraform, aby pobrać Terraform provider, wygenerować plan a następnie wdrożyć go na chmurę AWS.
name: Terraform Deployment
on:
workflow_dispatch:
jobs:
terraform-deploy:
runs-on: ubuntu-24.04
env: # Declare environment variables at the job level
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2
with:
terraform_version: 1.5.0
- name: Terraform Init
run: terraform -chdir=infrastructure init
- name: Terraform Plan
run: terraform -chdir=infrastructure plan -out tfplan
- name: Terraform Apply
run: |
terraform -chdir=infrastructure apply -auto-approve tfplan | tee tf_output.txt
INSTANCE_PUBLIC_IP=$(grep 'instance_public_ip =' tf_output.txt | awk -F ' = ' '{print $2}' | tr -d '"' | head -n 1 | tr -d '\n')
echo $INSTANCE_PUBLIC_IP > tf_output.txt
echo "INSTANCE_PUBLIC_IP=$INSTANCE_PUBLIC_IP" >> $GITHUB_ENV
- name: Upload Terraform Output as Artifact
uses: actions/upload-artifact@v4
with:
name: tf_output
path: tf_output.txt
Dodatkowo, użyjemy komend Linuxa do poznania adresu IP, które AWS nam przyzna. Ten adres będzie można odczytać zarówno w logach runnera (dzięki obiektowi output Terraform) oraz w artefakcie który wygenerujemy. Artefakt to plik, generowany na poziomie pojedynczego workflow, który możemy pobrać po jego poprawnym wykonaniu.
Po dodaniu workflow w .github/workflows możemy zacommitować, wypchnąć zmiany do repozytorium i następnie uruchomić workflow.

Rysunek 18 – uruchomione workflow, które utworzyło naszą instancję EC2 i wgrało klucz publiczny do serwera. Dzięki temu możemy się zalogować na podany adres za pomocą komendy ssh -i klucz ec2-user@adres-ip
Umieszczenie stanu w pojemniku S3 sprawia, że możemy posługiwać się Terraformem zarówno z lokalnego systemu jak i z poziomu runnera. Ustawienie workflow z Terraformem pozwala na wdrożenie infrastruktury jednym kliknięciem, a także jeśli zastosowalibyśmy samą komendę ‘terraform plan’ na kontrolę konfiguracji. Dzięki temu wiemy, czy ktoś np. nie zmodyfikował ustawień naszego serwera z poziomu UI (configuration drift) i czy nasza konfiguracja jest zgodna ze stanem.
Wdrożenie naszej aplikacji z pomocą GitHub Actions
Aby wdrożyć automatycznie na nasz serwer naszą prostą aplikację do naszego workflow z testami dopiszemy kolejnego job’a, który będzie miał za zadanie wykonywać wdrożenie, jeśli testy się udały. Jako input od użytkownika będziemy brali adres ip serwera na który mamy wdrożyć (uzyskany z artefaktu pierwszego workflow) oraz port na którym ma działać aplikacja. Utworzymy w tym celu kolejny plik YML, który będzie wyglądać nastepująco:
name: Test & Deploy
on:
workflow_dispatch:
inputs:
instance_ip:
description: 'EC2 Instance Public IP'
required: true
type: string
port_number:
description: 'Port number'
required: true
type: int
jobs:
test:
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: Cache node_modules
uses: actions/cache@v3
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
- name: Test the code
run: npm test
deploy:
runs-on: ubuntu-24.04
needs: test
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup SSH private key for deployment
run: |
echo "${{ secrets.AWS_SSH_EXPRESS_PRIV_KEY }}" > private_key.pem
chmod 600 private_key.pem
ls -la
- name: Copy files to EC2 instance using SCP
run: |
# Export EC2 instance IP from user input
INSTANCE_IP=${{ github.event.inputs.instance_ip }}
# Copy the code to the EC2 instance using SCP
scp -o StrictHostKeyChecking=no -i private_key.pem -r . ec2-user@$INSTANCE_IP:/home/ec2-user/app
- name: Deploy on EC2 instance
run: |
INSTANCE_IP=${{ github.event.inputs.instance_ip }}
PORT=${{ github.event.inputs.port_number }} # Port passed as input
# SSH into EC2 with the environment variable
ssh -t -o StrictHostKeyChecking=no -i private_key.pem ec2-user@$INSTANCE_IP << EOF
set -e # Fail on any error
# Update the system and install Node.js (Amazon Linux 2023)
sudo yum update -y
curl -sL https://rpm.nodesource.com/setup_18.x | sudo bash -
sudo yum install -y nodejs
# Navigate to the app directory
cd /home/ec2-user/app
# Install dependencies
npm install
# Stop any existing application
sudo pkill -f "node app/index.js" || true
# Export the environment variable
export PORT=$PORT
# Start the application with sudo and fully detach
echo "Starting application on port \$PORT"
nohup sudo PORT=\$PORT npm start > app.log 2>&1 & disown
# Wait briefly to ensure the application starts
sleep 5
# Verify the application is running
if ! sudo lsof -i :\$PORT; then
echo "Application failed to start on port \$PORT"
exit 1
else
echo "Application is running successfully on port \$PORT"
fi
# Cleanly exit the SSH session
exit 0
EOF
- name: Clean up SSH key
run: rm -f private_key.pem
Do skryptu dodaliśmy komentarze, tak aby można było zrozumieć co robią poszczególne komendy. Wykorzystujemy tutaj komendy do pobrania klucza prywatnego z obiektu secrets, a następnie zalogowania się do serwera i postawienia działającej aplikacji z wykorzystaniem SSH, oraz kilku innych komend. Zwróćmy uwagę, że wykorzystujemy dyrektywę ‘ needs: test’, co oznacza, że aby wdrożenie mogło się rozpocząć nasz kod musi przejść najpierw wszystkie testy. Jest ona wymagana, ponieważ domyślnie osobne joby wykonują się współbieżnie tzn. w tym samym momencie. Po dodaniu pliku YML, zcommitowaniu oraz zpushowaniu uruchamiamy workflow. Dodatkowo korzystamy z mechanizmu cache’owania pozwalającego na przyśpieszenie wykonania workflow jeśli nasze zależności w package.json się nie zmieniły.
Jeśli wszystko zrobiliśmy poprawnie to wszystko powinno wyglądać w sposób widoczny na poniższym zrzucie ekranu.

Rysunek 19 – Uruchomione workflow, dwa joby są od siebie zależny co widać na rysunku. Pod naszym adresem ip, powinna być dostępna aplikacja.
Podsumowanie
Dzisiaj pokazaliśmy Wam jak wdrożyć aplikację z użyciem GitHub Actions i jak wykorzystać do tego technologie AWS oraz Terraform. Powyższy przykład jest czysto dydaktyczny. W środowisku produkcyjnym takie wdrożenie mogłoby zostać poszerzone o zastosowanie kontenerów, konsoli aws-cli, klastra, odpowiedniego branchingu w repozytorium, skanowania kodu (SAST, SCA) czy wreszcie odpowiednio ustawionych ruleset’ów ograniczających możliwości wdrożenia niezweryfikowanego kodu. Cały kod i konfigurację udostępniamy na naszym repozytorium. Dzięki i do usłyszenia za tydzień w kolejnym wpisie!