In my previous article I described my simple demo-application, StellarGallery. A simple frontend for an art gallery where users can buy art using stellar cryptocurrency. The backend keeps track of orders and let customers download a high resolution image when payment is received.
This article is about what my production architecture looks like and how I build my docker containers. Even though the domain is a little bit uncommon, the production architecture should be common. A React SPA and a Golang Server.
Keywords: Docker, Docker Compose, Traefik( https, Letsencrypt, redirect port 80->443, gzip compression), substitution of api url in client image at container startup.
In earlier projects I would have Dockerhub do the builds automatically after github updates, but this is now a paid option at Dockerhub so here I use a more manual approach.
I build docker images on my laptop and push them to DockerHub. I'm not planning on frequent updates and it's just a demo so I think its okay for now.
My production server is a Linode machine running Ubuntu. I will describe the setup details in the Details section below.
Ubuntu 20.04 LTS
The Dockerfile for the React client is pretty common, I think.
Multi-stage build to make the build as small as possible.
#Build Stage Start #Specify a base image FROM node:alpine as builder #Specify a working directory WORKDIR '/app' #Copy the dependencies file COPY package.json . #Install dependencies RUN npm install #Copy remaining files COPY . . #Build the project for production RUN npm run build #Run Stage Start FROM nginx #Copy production build files from builder phase to nginx COPY --from=builder /app/build /usr/share/nginx/html EXPOSE 80
This is how I build my Dockerfile and push it to DockerHub:
docker build -t client_stellar_gallery . docker tag client_stellar_gallery:latest gunstein/client_stellar_art_gallery:latest docker push gunstein/client_stellar_art_gallery:latest
Pretty plain Dockerfile for Go, I guess, but I want to mention some hassle I had.
Originally I wrote the server to use SQLite as database. It worked well when run on my laptop, but gave me plenty of trouble when run in a Docker container on my Linode server. To build a container with SQLite I had to make a static build. My understanding is that this is because SQLite needs glibc. I used this build command in my Dockerfile:
RUN go build -a -ldflags "-linkmode external -extldflags '-static' -s -w" -o server_stellar_gallery main.go
The build command worked ok, but my server crashed at the strangest places when run in the container. I think the crashes were caused by some glibc conflicts.
In the end I gave up and converted to PostgreSQL. The ORM I'm using is Gorm which is using the pgx, a pure Go driver for PostgreSQL. This worked like a charm. To me, it seems like a good rule to avoid static builds if possible.
I also want to explain the last line in my Dockerfile, CMD ["--account_public_key"]. The string is just a placeholder and is supposed to be overridden in the Docker-compose.yml file.
FROM golang:latest as builder WORKDIR /app COPY . . RUN go get -d -v ./... RUN go install -v ./... RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server_stellar_gallery . #FROM scratch FROM centos:latest COPY --from=builder /app/server_stellar_gallery / # Copy CA certificates to prevent x509: certificate signed by unknown authority errors COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt EXPOSE 5000 ENTRYPOINT ["/server_stellar_gallery"] CMD ["--account_public_key"]
This is how I build my Dockerfile and push it to DockerHub:
docker build -t server_stellar_gallery . docker tag server_stellar_gallery:latest gunstein/server_stellar_art_gallery:latest docker push gunstein/server_stellar_art_gallery:latest
I would have thought my docker-compose.yml file to be an ordinary one, but I must admit that I had some trouble creating it and especially the stuff involving Traefik was a challenge. It seems to me that configuring Traefik has evolved a bit and this must be considered when looking at examples.
I chose to set up redirection from port 80->443 and connecting middleware for compression to the frontend. This seems to be considered best practice for frontends. At least Lighthouse gives you credit for implementing these things.
I really like Traefik. They seem to have achieved their vision of simplifying networking. From a programmers point of view it's pleasant to be released of all the burden setting up for example https, letsencrypt, port redirection and response-compression.
One last thing to mention:
After docker-compose up -d is run I have to substitute the placeholder API_URL in the client build. (The placeholder exists in the .env file in the client project.)
I use this command:
docker-compose exec -w /usr/share/nginx/html/static/js client_stellar_art_gallery bash -c "sed -i 's,__API_URL__,https://galleryapi.vatnar.no,g' *"
I never found a way to run this command in the docker.compose.yml file so instead I made a script first running docker-compose up -d and then the command above. It's working, but it feels like the last command could be set up in the docker-compose.yml file.
Here's my docker-compose.yml file:
version: "3.8" services: traefik: image: "traefik:v2.4" container_name: "traefik" command: # - "--log.level=DEBUG" # - "--api.insecure=true" - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entrypoints.websecure.address=:443" - "--certificatesresolvers.myresolver.acme.tlschallenge=true" - "--firstname.lastname@example.org" - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json" # redirect port 80 -> 443 - "--entrypoints.web.address=:80" - "--entrypoints.web.http.redirections.entryPoint.to=websecure" - "--entrypoints.web.http.redirections.entryPoint.scheme=https" - "--entrypoints.web.http.redirections.entrypoint.permanent=true" ports: - "443:443" - "80:80" # - "8080:8080" volumes: - "./letsencrypt:/letsencrypt" - "/var/run/docker.sock:/var/run/docker.sock:ro" client_stellar_art_gallery: image: gunstein/client_stellar_art_gallery:latest container_name: "client_stellar_art_gallery" restart: always labels: - "traefik.enable=true" - "traefik.http.routers.client_stellar_art_gallery.rule=Host(`gallery.vatnar.no`)" - "traefik.http.routers.client_stellar_art_gallery.entrypoints=websecure" - "traefik.http.routers.client_stellar_art_gallery.tls.certresolver=myresolver" # use compression - "traefik.http.routers.client_stellar_art_gallery.middlewares=test-compress" - "traefik.http.middlewares.test-compress.compress=true" postgres: image: postgres container_name: "postgres" restart: always environment: POSTGRES_PASSWORD: postgres volumes: - ./dbdata:/var/lib/postgresql/data server_stellar_art_gallery: image: gunstein/server_stellar_art_gallery:latest container_name: "server_stellar_art_gallery" depends_on: - "postgres" restart: always labels: - "traefik.enable=true" - "traefik.http.routers.server_stellar_art_gallery.rule=Host(`galleryapi.vatnar.no`)" - "traefik.http.routers.server_stellar_art_gallery.entrypoints=websecure" - "traefik.http.routers.server_stellar_art_gallery.tls.certresolver=myresolver" command: "-account=GBGJFGCDZHQ3LXJOUK7EOZB77OR2GMES3FVQRK4M724THUDLZLP7J6A7"