Skip to content

Commit 819c644

Browse files
committed
docs: update deployment with self registry
1 parent 65958d0 commit 819c644

File tree

1 file changed

+179
-27
lines changed
  • adminforth/documentation/blog/2025-02-19-compose-ec2-deployment-ci-registry

1 file changed

+179
-27
lines changed

adminforth/documentation/blog/2025-02-19-compose-ec2-deployment-ci-registry/index.md

Lines changed: 179 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ image: "/ogs/ga-tf-aws.jpg"
1414
This guide shows how to deploy own Docker apps (with AdminForth as example) to Amazon EC2 instance with Docker and Terraform involving Docker self-hosted registry.
1515

1616
Needed resources:
17-
- GitHub actions Free plan which includes 2000 minutes per month (1000 of 2-minute builds per month - more then enough for many projects, if you are not running tests etc). Extra builds would cost `0.008$` per minute.
17+
- GitHub actions Free plan which includes 2000 minutes per month (1000 of 2-minute builds per month - more then enough for many projects, if you are not running tests). Extra builds would cost `0.008$` per minute.
1818
- AWS account where we will auto-spawn EC2 instance. We will use `t3a.small` instance (2 vCPUs, 2GB RAM) which costs `~14$` per month in `us-east-1` region (cheapest region). Also it will take `$2` per month for EBS gp2 storage (20GB) for EC2 instance
1919

20-
This is it, registry will be auto-spawned on EC2 instance, so no extra costs for it. Also GitHub storage is not used, so no extra costs for it.
20+
Registry will be auto-spawned on EC2 instance, so no extra costs for it. GitHub storage is not used as well, so no costs for it as well.
2121

22-
The setup has next features:
23-
- Build process is done using IaaC approach with HashiCorp Terraform, so almoast no manual actions are needed from you. Every resource including EC2 server instance is described in code which is commited to repo so no manual clicks are needed.
24-
- Docker build process is done on GitHub actions, so EC2 server is not overloaded
22+
The setup shape:
23+
- Build is done using IaaC approach with HashiCorp Terraform, so almoast no manual actions are needed from you. Every resource including EC2 server instance is described in code which is commited to repo.
24+
- Docker build process is done on GitHub actions server, so EC2 server is not overloaded with builds
2525
- Changes in infrastructure including changing server type, adding S3 Bucket, changing size of sever disk is also can be done by commiting code to repo.
2626
- Docker images and cache are stored on EC2 server, so no extra costs for Docker registry are needed.
2727
- Total build time for average commit to AdminForth app (with Vite rebuilds) is around 2 minutes.
@@ -43,8 +43,9 @@ Quick difference between approaches from previous post and current post:
4343
| How and where docker build happens | Source code is rsync-ed from CI to EC2 and docker build is done there | Docker build is done on CI and docker image is pushed to registry (in this post we run registry automatically on EC2) |
4444
| How Docker build layers are cached | Cache is stored on EC2 | GitHub actions has no own Docker cache out of the box, so it should be stored in dedicated place (we use self-hosted registry on the EC2 as it is free) |
4545
| Advantages | Simpler setup with less code (we don't need code to run and secure registry, and don't need extra cache setup as is naturally persisted on EC2). | Build is done on CI, so EC2 server is not overloaded. For most cases CI builds are faster than on EC2. Plus time is saved because we don't need to rsync source code to EC2 |
46-
| Disadvantages | Build on EC2 requires additional server RAM / overloads CPU | More terraform code is needed. registry cache might require small extra space on EC2 |
47-
46+
| Disadvantages | Build on EC2 requires additional server RAM / overloads CPU | More terraform code is needed. Registry cache might require small extra space on EC2. Complexities to make it run from both local machine and CI |
47+
| Initial build time *from local machine up to working state | 2m 48.412s | |
48+
| Rebuild time *from local machine, no docker cache changed `index.ts`| 0m 34.520s | |
4849

4950
## Chellenges when you build on CI
5051

@@ -110,6 +111,8 @@ Assume you have your AdminForth project in `myadmin`.
110111

111112
## Step 1 - Dockerfile
112113

114+
> TODO: Step 1 and 1.* will be accomplished automatically within the part of CLI and moved to manual non-CLI Hello world example
115+
113116
Create file `Dockerfile` in `myadmin`:
114117

115118
```Dockerfile title="./myadmin/Dockerfile"
@@ -120,7 +123,54 @@ ADD package.json package-lock.json /code/
120123
RUN npm ci
121124
ADD . /code/
122125
RUN --mount=type=cache,target=/tmp npx tsx bundleNow.ts
123-
CMD ["npm", "run", "startLive"]
126+
CMD ["npm", "run", "migrateLiveAndStart"]
127+
```
128+
129+
### Step 1.1 - Create bundleNow.ts
130+
131+
Create file `bundleNow.ts` in `myadmin`:
132+
133+
```typescript title="./myadmin/bundleNow.ts"
134+
import { admin } from './index.js';
135+
136+
await admin.bundleNow({ hotReload: false});
137+
console.log('Bundling AdminForth done.');
138+
```
139+
140+
Make sure you are not calling bundleNow in `index.ts` file for non-development mode:
141+
142+
```typescript
143+
//diff-remove
144+
await admin.bundleNow({ hotReload: process.env.NODE_ENV === 'development'});
145+
//diff-remove
146+
console.log('Bundling AdminForth done. For faster serving consider calling bundleNow() from a build script.');
147+
//diff-remove
148+
if (process.env.NODE_ENV === 'development') {
149+
//diff-add
150+
await admin.bundleNow({ hotReload: true });
151+
//diff-add
152+
console.log('Bundling AdminForth done');
153+
//diff-add
154+
}
155+
```
156+
157+
### Step 1.3 - Make sure you have `migrateLiveAndStart` script in `package.json`
158+
159+
```json title="./myadmin/package.json"
160+
...
161+
"scripts": {
162+
...
163+
"migrateLiveAndStart": "npx --yes prisma migrate deploy && tsx index.ts",
164+
...
165+
}
166+
...
167+
```
168+
169+
### Step 1.4 - Make sure you have `.dockerignore` file
170+
171+
```./myadmin/.dockerignore
172+
node_modules
173+
*.sqlite
124174
```
125175

126176
## Step 2 - compose.yml
@@ -129,6 +179,7 @@ create folder `deploy` and create file `compose.yml` inside:
129179

130180
```yml title="deploy/compose.yml"
131181

182+
132183
services:
133184
traefik:
134185
image: "traefik:v2.5"
@@ -146,7 +197,12 @@ services:
146197
pull_policy: always
147198
restart: always
148199
env_file:
149-
- .env.live
200+
- .env.secrets.live
201+
environment:
202+
- NODE_ENV=production
203+
- DATABASE_URL=sqlite://.db.sqlite
204+
- PRISMA_DATABASE_URL=file:.db.sqlite
205+
150206
volumes:
151207
- myadmin-db:/code/db
152208
labels:
@@ -192,7 +248,7 @@ Create `deploy/.gitignore` file with next content:
192248
*.tfstate.*
193249
*.tfvars
194250
tfplan
195-
.env.live
251+
.env.secrets.live
196252
```
197253

198254
## Step 6 - buildx bake file
@@ -205,7 +261,7 @@ variable "REGISTRY_BASE" {
205261
}
206262
207263
group "default" {
208-
target = "myadmin"
264+
targets = ["myadmin"]
209265
}
210266
211267
target "myadmin" {
@@ -364,13 +420,35 @@ resource "aws_instance" "app_instance" {
364420
systemctl start docker
365421
systemctl enable docker
366422
usermod -a -G docker ubuntu
423+
424+
echo "done" > /home/ubuntu/user_data_done
425+
367426
EOF
368427
369428
tags = {
370429
Name = "${local.app_name}-instance"
371430
}
372431
}
373432
433+
resource "null_resource" "wait_for_user_data" {
434+
provisioner "remote-exec" {
435+
inline = [
436+
"echo 'Waiting for EC2 software install to finish...'",
437+
"while [ ! -f /home/ubuntu/user_data_done ]; do echo '...'; sleep 2; done",
438+
"echo 'EC2 software install finished.'"
439+
]
440+
441+
connection {
442+
type = "ssh"
443+
user = "ubuntu"
444+
private_key = file("./.keys/id_rsa")
445+
host = aws_eip_association.eip_assoc.public_ip
446+
}
447+
}
448+
449+
depends_on = [aws_instance.app_instance]
450+
}
451+
374452
resource "null_resource" "setup_registry" {
375453
provisioner "local-exec" {
376454
command = <<-EOF
@@ -395,10 +473,6 @@ resource "null_resource" "setup_registry" {
395473
396474
provisioner "remote-exec" {
397475
inline = [<<-EOF
398-
# wait for docker to be installed and started
399-
bash -c 'while ! command -v docker &> /dev/null; do echo \"Waiting for Docker to be installed...\"; sleep 1; done'
400-
bash -c 'while ! docker info &> /dev/null; do echo \"Waiting for Docker to start...\"; sleep 1; done'
401-
402476
# remove old registry if exists
403477
docker rm -f registry
404478
# run new registry
@@ -428,6 +502,8 @@ resource "null_resource" "setup_registry" {
428502
triggers = {
429503
always_run = 1 # change number to redeploy registry (if for some reason it was removed)
430504
}
505+
506+
depends_on = [null_resource.wait_for_user_data]
431507
}
432508
433509
@@ -436,7 +512,15 @@ resource "null_resource" "sync_files_and_run" {
436512
provisioner "local-exec" {
437513
command = <<-EOF
438514
439-
# map appserver.local to the instance (in GA we don't know the IP, so have to use this mapping)
515+
# map appserver.local to the instance (in CI we don't know the IP, so have to use this mapping)
516+
# so then in GA pipeline we will use
517+
# - name: Set up Docker Buildx
518+
# uses: docker/setup-buildx-action@v3
519+
# with:
520+
# buildkitd-config-inline: |
521+
# [registry."appserver.local:5000"]
522+
# ca=["deploy/.keys/ca.pem"]
523+
440524
grep -q "appserver.local" /etc/hosts || echo "${aws_eip_association.eip_assoc.public_ip} appserver.local" | sudo tee -a /etc/hosts
441525
442526
# hosts modification may take some time to apply
@@ -447,7 +531,7 @@ resource "null_resource" "sync_files_and_run" {
447531
echo '{"auths":{"appserver.local:5000":{"auth":"'$(echo -n "ci-user:$(cat ./.keys/registry.pure)" | base64 -w 0)'"}}}' > ~/.docker/config.json
448532
449533
echo "Running build"
450-
docker buildx bake --progress=plain --push --allow=fs.read=..
534+
docker buildx bake --progress=plain --push --allow=fs.read=.. --allow network.host
451535
452536
# compose temporarily it is not working https://github.com/docker/compose/issues/11072#issuecomment-1848974315
453537
# docker compose --progress=plain -p app -f ./compose.yml build --push
@@ -468,9 +552,6 @@ resource "null_resource" "sync_files_and_run" {
468552
provisioner "remote-exec" {
469553
inline = [<<-EOF
470554
# wait for docker to be installed and started
471-
bash -c 'while ! command -v docker &> /dev/null; do echo \"Waiting for Docker to be installed...\"; sleep 1; done'
472-
bash -c 'while ! docker info &> /dev/null; do echo \"Waiting for Docker to start...\"; sleep 1; done'
473-
474555
cat /home/ubuntu/registry-auth/registry.pure | docker login localhost:5000 -u ci-user --password-stdin
475556
476557
cd /home/ubuntu/app/deploy
@@ -490,15 +571,16 @@ resource "null_resource" "sync_files_and_run" {
490571
private_key = file("./.keys/id_rsa")
491572
host = aws_eip_association.eip_assoc.public_ip
492573
}
493-
574+
575+
494576
}
495577
496578
# Ensure the resource is triggered every time based on timestamp or file hash
497579
triggers = {
498580
always_run = timestamp()
499581
}
500582
501-
depends_on = [aws_instance.app_instance, aws_eip_association.eip_assoc, null_resource.setup_registry]
583+
depends_on = [aws_eip_association.eip_assoc, null_resource.setup_registry]
502584
}
503585
504586
@@ -522,6 +604,10 @@ resource "aws_s3_bucket_lifecycle_configuration" "terraform_state" {
522604
status = "Enabled"
523605
id = "Keep only the latest version of the state file"
524606
607+
filter {
608+
prefix = ""
609+
}
610+
525611
noncurrent_version_expiration {
526612
noncurrent_days = 30
527613
}
@@ -547,6 +633,7 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state"
547633
}
548634
549635
636+
550637
```
551638

552639
> 👆 Replace `<your_app_name>` with your app name (no spaces, only underscores or letters)
@@ -563,9 +650,12 @@ aws_access_key_id = <your_access_key>
563650
aws_secret_access_key = <your_secret_key>
564651
```
565652

653+
654+
566655
### Step 7.2 - Run deployment
567656

568-
To run the deployment first time, you need to run:
657+
658+
We will run first deployment from local machine to create S3 bucket for storing Terraform state. In other words this deployment will create resources needed for storing Terraform state in the cloud and runnign deployment from GitHub actions.
569659

570660
```bash
571661
terraform init
@@ -577,7 +667,10 @@ Now run deployement:
577667
terraform apply -auto-approve
578668
```
579669

580-
> First time you might need to run deployment twice if you still see "Waiting for Docker to start..." message. This is because terraform runs `docker` command before docker is started.
670+
> 👆 Please note that this command might block ask you your `sudo` password to append `appserver.local` to `/etc/hosts` file.
671+
672+
> 👆 Please note that command might show errors about pushing images, this is fine because current deployment is done here only to setup S3 bucket for state migration before migrating to cloud.
673+
581674

582675
## Step 8 - Migrate state to the cloud
583676

@@ -690,7 +783,9 @@ jobs:
690783
691784
- name: Prepare env
692785
run: |
693-
echo "" > deploy/.env.live
786+
echo "ADMINFORTH_SECRET=$VAULT_ADMINFORTH_SECRET" > deploy/.env.secrets.live
787+
env:
788+
VAULT_ADMINFORTH_SECRET: ${{ secrets.VAULT_ADMINFORTH_SECRET }}
694789
695790
- name: Terraform build
696791
run: |
@@ -716,6 +811,7 @@ Go to your GitHub repository, then `Settings` -> `Secrets` -> `New repository se
716811
- `VAULT_SSH_PUBLIC_KEY` - execute `cat ~/.ssh/id_rsa.pub` and paste to GitHub secrets
717812
- `VAULT_REGISTRY_CA_PEM` - execute `cat deploy/.keys/ca.pem` and paste to GitHub secrets
718813
- `VAULT_REGISTRY_CA_KEY` - execute `cat deploy/.keys/ca.key` and paste to GitHub secrets
814+
- `VAULT_ADMINFORTH_SECRET` - generate some random string and paste to GitHub secrets, e.g. `openssl rand -base64 32 | tr -d '\n'`
719815

720816

721817
Now you can push your changes to GitHub and see how it will be deployed automatically.
@@ -736,11 +832,12 @@ Now open GitHub actions file and add it to the `env` section:
736832
```yml title=".github/workflows/deploy.yml"
737833
- name: Prepare env
738834
run: |
739-
echo "" > deploy/.env.live
835+
echo "ADMINFORTH_SECRET=$VAULT_ADMINFORTH_SECRET" > deploy/.env.secrets.live
740836
//diff-add
741-
echo "OPENAI_API_KEY=$VAULT_OPENAI_API_KEY" >> deploy/.env.live
837+
echo "OPENAI_API_KEY=$VAULT_OPENAI_API_KEY" >> deploy/.env.secrets.live
742838
//diff-add
743839
env:
840+
VAULT_ADMINFORTH_SECRET: ${{ secrets.VAULT_ADMINFORTH_SECRET }}
744841
//diff-add
745842
VAULT_OPENAI_API_KEY: ${{ secrets.VAULT_OPENAI_API_KEY }}
746843
```
@@ -829,4 +926,59 @@ Add this steps to the end of your GitHub actions file:
829926
"{\"text\": \"❌ *${{ github.actor }}* failed to build *${{ github.ref_name }}* with commit \\\"${{ github.event.head_commit.message }}\\\".\n:link: <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Build> | :link: <${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|View Commit>\"}" \
830927
${{ secrets.SLACK_WEBHOOK_URL }}
831928
929+
```
930+
931+
932+
### Want to run builds from your local machine?
933+
934+
This guide originally was created to run full builds from GitHub actions only, so out of the box it will fail to push images to registry from your local machine.
935+
936+
But for debug purporses you can run it from your local machine too with some addition steps.
937+
938+
#### 1. You need to make local Docker buildx builder to trust self-signed TLS certificate
939+
940+
Create folder `deploy/.local` and create next files:
941+
942+
```toml title=deploy/.local/buildkitd.toml
943+
[registry."appserver.local:5000"]
944+
insecure = false
945+
ca = ["../.keys/ca.pem"]
946+
```
947+
948+
```sh title=deploy/.local/create-builder.sh
949+
#!/bin/bash
950+
cd "$(dirname "$0")"
951+
docker buildx create --name mybuilder --driver docker-container --use --config ./buildkitd.toml
952+
```
953+
954+
Now create builder:
955+
956+
```bash
957+
bash .local/create-builder.sh
958+
```
959+
960+
#### 2. You need to deliver envs locally
961+
962+
Create file `deploy/.env.secrets.live` with next content:
963+
964+
```sh
965+
ADMINFORTH_SECRET=<your secret>
966+
```
967+
968+
Please note that if you are running builds both from GA and local, the `ADMINFORTH_SECRET` should much to GA secret. Otherwise all existing users will be logged out.
969+
970+
#### 2. You need to add app.server.local to your hosts file (Windows/WSL only)
971+
972+
> This step is not needed on Linux / Mac because
973+
974+
In power shell run
975+
976+
```
977+
Start-Process notepad "C:\Windows\System32\drivers\etc\hosts" -Verb runAs
978+
```
979+
980+
Check your public IP in Terraform output and add
981+
982+
```
983+
<your public ip> appserver.local
832984
```

0 commit comments

Comments
 (0)