Containerise Gatus with Docker
Build a production-ready Docker image for Gatus using multi-stage builds.
Introduction
30 Second Summary
When you move an application from your laptop to a server, small differences in the operating system, installed tools, or configuration can break everything. Docker solves this by packaging your application into a container that runs the same way on any machine.
In this project, you will take a real open-source Go application called Gatus and containerise it from scratch. You will start with a simple Dockerfile that produces a bloated image, then rewrite it using multi-stage builds to cut the size by 95%.
What You'll Build
You will open your browser to a live health dashboard monitoring real websites, running inside a container built from an image you wrote and optimised by hand.
By the end of this project, you'll have:
- A live Gatus health dashboard monitoring real websites, built from a Docker image you wrote by hand.
- A multi-stage Dockerfile that compiles Go source code in one stage and produces a secure, minimal image under 30MB in the next.
- A documentation file explaining every containerisation decision you made and why each one matters for production.
- Secret Mission: Add a HEALTHCHECK instruction to your Dockerfile and verify Docker reports your container as healthy.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up and Run the Official Image
Before you can build your own Docker image for an application, you need to see it running first. Gatus is an open-source health monitoring dashboard written in Go that watches your websites and APIs.
Running the official Docker image first gives you a working baseline to compare against later when you build your own. In this step, you will create a project folder, write a simple monitoring config, and run the official Gatus container.
In this step, get ready to:
- Pull and run the official Gatus Docker image with a monitoring config.
- Try to explore the inside of a running container and discover why it fails.
- Stop and clean up the container.
Create your project folder
You need a dedicated folder to hold your project files and the config that tells Gatus which websites to monitor.
- In your PowerShell window, navigate to your home directory and create the project folder by running these commands:
cd ~
mkdir gatus-project
cd gatus-project
mkdir config
What do these commands do?
- cd ~ moves you to your home directory (e.g. C:\Users\your-username).
- mkdir gatus-project creates a new folder for this project.
- mkdir config creates a subfolder where your Gatus config file will live.
- Confirm the folders were created by running:
dir
You should see a config folder listed in the output.
Don't see the config folder?
- Make sure you ran cd gatus-project before running mkdir config. You need to be inside the project folder first.
- Run pwd to check your current location. It should end with gatus-project.
Still stuck? Help me find my gatus-project folder in PowerShell.
Create the monitoring config file
Gatus reads a YAML config file to know which endpoints to monitor. You will create a config that watches two public websites.
- Press the Windows key to open your search bar.
- Type the name of your text editor (such as VS Code or Notepad++) and press Enter to open it.
- Create a new file and add the following YAML content:
endpoints:
- name: example-website
group: external
url: "https://example.org/"
interval: 30s
conditions:
- "[STATUS] == 200"
- name: httpbin
group: external
url: "https://httpbin.org/status/200"
interval: 30s
conditions:
- "[STATUS] == 200"
- "[RESPONSE_TIME] < 5000"
What does this config do?
- Each entry under endpoints defines a website for Gatus to check every 30 seconds.
- conditions define what "healthy" means. [STATUS] == 200 means the site must return an HTTP 200 OK response.
- The httpbin endpoint also checks [RESPONSE_TIME] < 5000, which means the response must arrive within 5 seconds.
- Save the file as config.yaml inside the config folder, so the full path is gatus-project\config\config.yaml.
- Back in PowerShell, confirm the file was saved correctly by running:
type config\config.yaml
You should see the full YAML content you just saved, starting with endpoints:.
Seeing an error or empty output?
- Check that you saved the file with the exact name config.yaml (not config.yaml.txt). Some editors add a .txt extension by default.
- Make sure your PowerShell is still inside the gatus-project folder. Run pwd to check.
Need help? Help me verify my config.yaml file was saved to the right location.
✔️ Awesome, I've got everything!
Great. Double check you have saved config\config.yaml and you are ready to run the container.
ⓧ I'd like to double check the full code
Here is the complete config\config.yaml file. Make sure yours matches exactly.
endpoints:
- name: example-website
group: external
url: "https://example.org/"
interval: 30s
conditions:
- "[STATUS] == 200"
- name: httpbin
group: external
url: "https://httpbin.org/status/200"
interval: 30s
conditions:
- "[STATUS] == 200"
- "[RESPONSE_TIME] < 5000"
Run the official Gatus container
Now you will pull the official Gatus image from the GitHub Container Registry and run it with your config file mounted into the container.
- Start the Gatus container by running this command:
docker run -p 8080:8080 --name gatus-official --mount "type=bind,source=${PWD}/config,target=/config" ghcr.io/twin/gatus:stable
What does this command do?
- docker run pulls the image (if not already downloaded) and starts a container from it.
- -p 8080:8080 maps port 8080 on your machine to port 8080 inside the container, so you can access the dashboard in your browser.
- --mount takes your local config folder and makes it available inside the container at /config. This is called a bind mount.
- ghcr.io/twin/gatus:stable is the official Gatus image hosted on the GitHub Container Registry.
Why is my terminal full of log messages?
The container is running in the foreground, so Gatus prints its log output directly to your terminal. This is normal. You will need to open a second PowerShell window later in this step to run additional commands.
Docker may take a minute to download the image the first time. Once you see log lines mentioning your endpoints, the container is running.
Before you open your browser, what do you think you will see at http://localhost:8080?
- Open your browser and go to http://localhost:8080.
You should see the Gatus health dashboard with your two endpoints listed under the external group. After about 30 seconds, the first health check results appear as green (healthy) indicators.
Explore the running container
A running container is like a tiny, isolated computer. You can try to open a shell inside it to look around, just like you would connect to a remote server.
- Open a new PowerShell window. Press the Windows key, type PowerShell, and press Enter.
- Try to open a shell inside the running container by running:
docker exec -it gatus-official sh
This command fails. You see an error message mentioning that sh was not found.
Why can't I get a shell inside this container?
The official Gatus image is built on scratch, which is a completely empty base image with zero files. No shell, no ls, no whoami. The only things inside the container are the compiled Gatus binary, a config file, and SSL certificates.
This makes the image extremely small and secure, but impossible to debug. When you write your own Dockerfile later, you will use Alpine as the base instead. Alpine gives you a shell and basic tools while still being very small.
Even without a shell, you can confirm the container is running from the outside.
- Check that the container is running by running:
docker ps
You should see gatus-official listed with a status of Up and port 8080 mapped.
- Now stop and remove the container by running these two commands:
docker stop gatus-official
docker rm gatus-official
What do stop and rm do?
- docker stop gracefully shuts down the running container. Your first PowerShell window (the one showing logs) will return to a normal prompt.
- docker rm deletes the stopped container. The image is still on your machine. You are removing the running instance, not the template.
- Confirm the container is gone by running:
docker ps -a
You should see no container named gatus-official in the output.
Container still showing up?
- If docker rm gave an error about the container still running, run docker stop gatus-official first and then try docker rm gatus-official again.
- If you see a different container in the list, that is unrelated to this project. Only look for one named gatus-official.
Still stuck? Help me stop and remove my Docker container.
You have now seen Gatus running from the official image and discovered that its scratch base image makes it impossible to debug from inside. Next up, you will clone the Gatus source code and understand what makes it tick before writing your own Dockerfile.
Clone and Explore the Repository
In Step 1, you ran the official Gatus image and peeked inside a running container. You saw how minimal the filesystem was.
Before writing your own Dockerfile, you need to understand what the application is made of. Knowing the language, dependencies, entry point, and configuration structure drives every decision in your Dockerfile.
In this step, get ready to:
- Clone the Gatus repository and identify its key files.
- Read the existing Dockerfile to understand the multi-stage build pattern.
- Find the health endpoint that container orchestrators use for liveness checks.
Clone the Gatus repository and explore its structure
Gatus is an open-source Go application. Cloning the repository gives you the full source code, build configuration, and the existing Dockerfile that the maintainers use.
- Clone the repository and move into it by running these commands:
git clone https://github.com/TwiN/gatus.git
cd gatus
Clone failing or hanging?
Make sure you have an internet connection. If you see a certificate or SSL error, your network might be blocking Git traffic.
If git is not recognized, confirm Git is installed by running git --version. Help me fix my Git clone issue.
- List the top-level files by running:
dir
You should see a list of files and folders. Four of them are especially important for containerisation.
What are the key files?
- go.mod is the Go dependency manifest. It lists the Go version and every library the application depends on.
- config.yaml is the default configuration file. Gatus reads it to know which endpoints to monitor.
- Dockerfile contains the maintainers' existing build instructions for the Docker image.
- .dockerignore filters out files that Docker should not include when building the image.
- View the Go module file to check the application's language version by running:
type go.mod
Near the top of the output, you should see go 1.26.3. This tells you the exact Go version the application requires.
Why does the Go version matter for Docker?
When you write your Dockerfile, you need to pick a Go base image that matches this version. Using golang:1.26.3 ensures the compiler in your build stage matches what the project expects.
A mismatched version can cause build failures or subtle runtime bugs. Always check go.mod first.
- View the repository's default configuration file by running:
type config.yaml
You should see a YAML file with an endpoints array. Each endpoint has a name, URL, interval, and conditions. This is the structure Gatus expects when it starts up.
Seeing a 'cannot find path' error?
Make sure you are inside the gatus folder. Run pwd to check your current directory. If it does not end with \gatus, run cd gatus to move into it.
Help me find the right directory for the Gatus repository.
Read the existing Dockerfile
The Gatus maintainers already have a Dockerfile in the repository. Reading it before you write your own shows you the pattern that production Go images commonly follow.
- View the existing Dockerfile by running:
type Dockerfile
Scroll through the output. You should see two FROM instructions. That two-stage structure is the key pattern to notice.
What is this Dockerfile doing?
This is a multi-stage build. It uses two separate FROM instructions to create two stages.
- Stage 1 (builder): Uses golang:alpine as its base. This stage has the full Go compiler and builds the Gatus binary with CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus ..
- Stage 2 (runtime): Uses scratch as its base. This is a completely empty image. It copies only the compiled binary, the config file, and CA certificates from the builder stage.
The builder stage is thrown away after the build. Only the tiny runtime stage becomes the final image.
Do NOT use this Dockerfile
You will write your own Dockerfile from scratch in the next steps. The goal is to learn how each instruction works by building it yourself, not by copying someone else's work.
Check for a health endpoint
Container orchestrators like ECS need a way to check whether an application is alive and ready to serve traffic. Applications expose a dedicated health endpoint for this purpose.
Gatus has a /health endpoint. Let's confirm it exists in the source code.
- Search for health-related code in the API file by running:
Select-String "health" api\api.go
You should see lines referencing the /health path and the health handler. This confirms the endpoint exists in the application.
Why does the /health endpoint matter?
The /health endpoint returns {"status":"UP"} with HTTP 200 when the application is running normally.
Container orchestrators poll this endpoint on a schedule. If it stops responding, the orchestrator restarts the container or routes traffic away from it. You will use this endpoint for a Docker HEALTHCHECK in the Secret Mission.
No output from the search?
Confirm you are in the gatus repository root by running pwd. The api folder should be visible when you run dir.
Help me search for the health endpoint in the Gatus source code.
Before you move on, think about what you have learned from exploring the repository. What three things do you now know that will shape your Dockerfile?
The Go version (1.26.3) determines your base image. The config.yaml structure tells you what to mount at runtime. The /health endpoint gives you a built-in liveness check for orchestrators.
You now understand the application inside and out. Next up, you will write your first Dockerfile and see firsthand why a naive approach produces a bloated image.
Write a Naive Dockerfile and See the Problem
In the previous step, you cloned the Gatus repository and mapped out its structure. You know it's a Go application, you've read the existing Dockerfile, and you've spotted the /health endpoint.
Now it's time to write your own Dockerfile from scratch. You'll start with the simplest possible approach: a single-stage Dockerfile that builds and runs Gatus in one image. This approach works, but it hides a serious problem that only shows up when you check the final image size.
In this step, get ready to:
- Create a .dockerignore file to keep the build context lean.
- Write and build a single-stage Dockerfile.
- Discover why the naive approach produces a bloated image.
Create a .dockerignore file
When Docker builds an image, it sends the entire directory to the Docker daemon as the "build context." A .dockerignore file filters out files that don't belong in the image, like version control history and test data.
- The gatus repository already includes a .dockerignore file. Open it in your text editor.
- Replace its contents with the following:
.git
.github
.examples
.idea
*.db
testdata
Dockerfile
Dockerfile.*
web/app
What does this file do?
- Each line is a pattern that Docker excludes from the build context.
- .git and .github contain version control history and CI configs that aren't needed in the image.
- Dockerfile and Dockerfile.* prevent the Dockerfiles themselves from being copied into the image.
- web/app excludes the frontend source code because Go embeds the compiled assets at build time.
- Save .dockerignore.
- Confirm the file saved correctly by running this command in PowerShell:
type .dockerignore
You should see your nine exclusion patterns listed in the output.
Seeing an error or empty output?
- Make sure you are in the gatus folder. Run pwd to check your current directory.
- Make sure the file is named exactly .dockerignore (starting with a dot). Some text editors may add a hidden .txt extension.
- help me troubleshoot my .dockerignore file
Write and build a naive Dockerfile
A Dockerfile is a recipe that tells Docker how to build an image. The simplest approach uses a single stage: start from a base image with the compiler, copy your code in, compile it, and set it to run.
- Create a new file called Dockerfile.naive in the gatus folder using your text editor.
- Add the following content:
FROM golang:1.26.3
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o gatus .
EXPOSE 8080
ENTRYPOINT ["/app/gatus"]
What does this Dockerfile do?
- FROM golang:1.26.3 starts from a base image that includes the full Go compiler and toolchain.
- COPY go.mod go.sum and RUN go mod download download dependencies first, before copying the rest of the source code. This takes advantage of Docker's layer caching. If your dependencies haven't changed, Docker skips the download on rebuilds.
- RUN CGO_ENABLED=0 go build -o gatus . compiles the Go source code into a single binary called gatus.
- ENTRYPOINT ["/app/gatus"] tells Docker to run the gatus binary when the container starts.
- Save Dockerfile.naive.
✔️ Awesome, I've got everything!
Great. Make sure you've saved both .dockerignore and Dockerfile.naive before building.
ⓧ I'd like to double check the full code
Here is the complete .dockerignore file:
.git
.github
.examples
.idea
*.db
testdata
Dockerfile
Dockerfile.*
web/app
Here is the complete Dockerfile.naive file:
FROM golang:1.26.3
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o gatus .
EXPOSE 8080
ENTRYPOINT ["/app/gatus"]
- Build the Docker image by running this command:
docker build -f Dockerfile.naive -t gatus-naive .
What does this command do?
- -f Dockerfile.naive tells Docker to use your custom file instead of the default Dockerfile.
- -t gatus-naive tags (names) the resulting image as gatus-naive.
- . sets the build context to the current directory.
The build takes a few minutes as Docker downloads the Go base image and compiles the source code. You'll see each step execute in your terminal.
Build failed?
- Make sure Docker Desktop is running. Check for the Docker icon in your system tray.
- Make sure you are inside the gatus folder (the one with go.mod). Run pwd to check.
- If you see a network error during go mod download, check your internet connection. Docker needs to download Go dependencies during the build.
- help me troubleshoot my Docker build failure
- Run the container from your new image by running this command:
docker run -p 8080:8080 --name gatus-naive gatus-naive
What does this command do?
- -p 8080:8080 maps port 8080 on your machine to port 8080 inside the container.
- --name gatus-naive gives the running container a name so you can reference it later.
This command runs in the foreground and takes over your terminal. You'll see Gatus log output scrolling as it starts monitoring endpoints.
- Open http://localhost:8080 in your browser.
You should see the Gatus health dashboard with your endpoint checks running. The application works perfectly from your naive image.
- Switch back to your PowerShell window and press Ctrl+C to stop the container.
- Remove the stopped container by running this command:
docker rm gatus-naive
Container won't stop or remove?
- If Ctrl+C didn't stop the container, run docker stop gatus-naive first, then try docker rm gatus-naive again.
- If you see "port is already allocated" when running, a previous container is still using port 8080. Run docker ps to find it, then stop and remove it.
- help me troubleshoot my Docker container
Check the image size
The dashboard worked, so the image is functional. Before you move on, take a guess: how large do you think the image is? The Gatus application compiles down to a single Go binary.
- Check the image size by running this command:
docker images gatus-naive
The image is over 500MB.
Why is it so big?
The Gatus binary itself is only about 25MB. The remaining 500MB+ is the entire Go compiler, build tools, and operating system packages from the golang:1.26.3 base image.
None of those tools are needed at runtime. They were only needed to compile the code. Shipping them in the final image wastes disk space, takes longer to push and pull from a registry, and increases the attack surface for security threats.
This bloated image is the problem you'll solve next. In the next step, you'll write a multi-stage Dockerfile that throws away the compiler after building and produces an image under 30MB.
Write a Production-Ready Multi-Stage Dockerfile
Your naive Dockerfile proved that you can containerise Gatus and serve a working dashboard. But at over 500MB, that image ships the entire Go compiler toolchain into production. That is wasted disk space and unnecessary tools an attacker could exploit.
Multi-stage Docker builds solve this. You compile your application in one stage, then copy only the finished binary into a tiny base image. In this step, you will write a production-quality Dockerfile that cuts the image size by over 95%.
In this step, get ready to:
- Write a two-stage Dockerfile with a builder stage and a minimal runtime stage.
- Build the production image and run the container to verify the dashboard works.
- Compare the naive and production image sizes to see the difference multi-stage builds make.
Write the production Dockerfile
A multi-stage Dockerfile uses multiple FROM statements. The first stage compiles the code, and the second stage copies only the compiled binary into a minimal base image.
- In your text editor, create a new file called Dockerfile.production in the gatus folder.
- Add the builder stage by pasting this code:
# Stage 1: Build the Go binary
FROM golang:1.26.3-alpine AS builder
RUN apk add --no-cache ca-certificates
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .
What does this code do?
- FROM golang:1.26.3-alpine AS builder uses the Alpine variant of the Go image, which is smaller than the full Debian-based version. The AS builder label lets the second stage reference this stage's output.
- COPY go.mod go.sum is copied separately before the rest of the source code. This takes advantage of Docker layer caching. Dependencies only re-download when go.mod or go.sum change.
- CGO_ENABLED=0 disables C bindings, producing a statically-linked binary that runs anywhere without system libraries.
- Save Dockerfile.production. The builder stage compiles the binary, but the file is not complete yet.
Now you need the runtime stage that receives the compiled binary.
- Add the runtime stage below the builder stage in Dockerfile.production:
# Stage 2: Create the production image
FROM alpine:3.21
RUN addgroup -S gatus && adduser -S -G gatus gatus
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /build/gatus /usr/local/bin/gatus
RUN mkdir -p /config && chown gatus:gatus /config
USER gatus
EXPOSE 8080
ENTRYPOINT ["gatus"]
What does the runtime stage do?
- FROM alpine:3.21 starts a brand new image from Alpine Linux, which is only about 3.5MB compressed. Everything from the builder stage is discarded except what you explicitly COPY.
- addgroup and adduser create a dedicated gatus user. Running as non-root limits the damage if an attacker breaks into the container.
- COPY --from=builder pulls only the compiled binary from the builder stage. The Go toolchain stays behind and is not included in this image.
- USER gatus switches all subsequent commands and the running process to the non-root user.
Why Alpine instead of scratch?
The official Gatus image uses scratch (a completely empty base) for the runtime stage. Alpine is a better choice for learning because it includes a shell for debugging with docker exec, the adduser command for creating non-root users, and package management with apk.
At about 3.5MB, Alpine is still very lean. The tradeoff is a few extra megabytes in exchange for the ability to inspect and debug your container.
- Save Dockerfile.production.
✔️ Awesome, I've got everything!
Great. Double check you have saved Dockerfile.production in the gatus folder.
ⓧ I'd like to double check the full code
Here is the complete Dockerfile.production. Compare your file against this:
# Stage 1: Build the Go binary
FROM golang:1.26.3-alpine AS builder
RUN apk add --no-cache ca-certificates
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus .
# Stage 2: Create the production image
FROM alpine:3.21
RUN addgroup -S gatus && adduser -S -G gatus gatus
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /build/gatus /usr/local/bin/gatus
RUN mkdir -p /config && chown gatus:gatus /config
USER gatus
EXPOSE 8080
ENTRYPOINT ["gatus"]
Build and run the production image
- Build the production image by running this command:
docker build -f Dockerfile.production -t gatus-production .
What does this command do?
- -f Dockerfile.production tells Docker to use your new file instead of the default Dockerfile in the repository.
- -t gatus-production tags the resulting image with the name gatus-production so you can reference it later.
This build takes a few minutes. Docker downloads the Go Alpine base image, installs dependencies, and compiles the entire Gatus binary.
Build failing?
- Make sure you are in the gatus folder. The build needs go.mod and go.sum to be in the build context.
- Check that Docker Desktop is running. You can confirm by looking for the Docker icon in your system tray.
- If the build fails at go mod download, check your internet connection. Docker needs to download Go dependencies during the build.
Help me debug my Docker build error.
- Once the build completes, start the container in detached mode by running:
docker run -d -p 8080:8080 --name gatus-production gatus-production
What does the -d flag do?
The -d flag runs the container in detached mode. Instead of taking over your terminal with log output, the container runs in the background. You get your terminal prompt back immediately.
- Open http://localhost:8080 in your browser. You should see the Gatus health dashboard with endpoint checks running.
The dashboard works, but does the container run as a non-root user? Time to verify.
- Confirm the container runs as a non-root user by running:
docker exec gatus-production whoami
- You should see gatus in the output. Not root.
Why does this matter?
If the process inside your container runs as root and an attacker breaks in, they have root-level access to the container's filesystem. Running as a dedicated non-root user like gatus limits the blast radius of any container escape.
- Check the image size by running:
docker images gatus-production
- Look at the SIZE column. Your production image should be under 30MB.
Container exits immediately?
- Check the container logs by running docker logs gatus-production. A YAML syntax error in the built-in config would appear here.
- If port 8080 is already in use, stop whatever is using it first. You may still have a container from an earlier step running.
- If whoami returns root, make sure your Dockerfile.production includes the USER gatus instruction before the ENTRYPOINT. Rebuild the image after fixing it.
Help me figure out why my container is not starting.
Compare image sizes
You now have two images built from the same application source. One uses a single-stage build, and the other uses a multi-stage build.
Before you run the next command, take a guess. How much smaller do you think the production image is compared to the naive one?
- Compare both images side by side by running:
docker images
Look at the SIZE column. gatus-naive is over 500MB. gatus-production is around 25MB. That is a ~95% reduction.
Where did the 475MB+ go?
The builder stage is discarded after the build completes. Only the compiled binary, CA certificates, and timezone data are copied to the Alpine base.
The Go compiler, standard library, source code, and downloaded modules all stay in the builder stage. They never make it into the production image.
Your production image is lean, secure, and running. Next up, you will test the health endpoint, tag the image for a registry, and document every decision you made.
Test, Tag, and Document
Your production image is built, running as a non-root user, and clocking in at around 25MB. That is a solid foundation for any deployment.
A container image is only useful if you can verify it works, version it for a registry, and explain your decisions to your future self or teammates. In this step, you will test the health endpoint, tag the image with version numbers, and write a documentation file covering every containerisation decision you made.
In this step, get ready to:
- Test the health and API endpoints from outside the container.
- Tag the image with semantic version numbers for registry readiness.
- Write a documentation file explaining every containerisation decision.
Test the health endpoint and API
Container orchestrators like ECS and Kubernetes call a health endpoint to check whether your application is alive. If the endpoint fails, the orchestrator restarts the container or routes traffic away from it.
Before you run this, what do you think the /health endpoint will return? A status code? A JSON body? Something else?
- Test the health endpoint by running this command in your PowerShell window:
curl http://localhost:8080/health
You should see {"status":"UP"} returned with HTTP 200. This confirms your application is alive and responding inside the container.
Seeing HTML output instead of JSON?
In Windows PowerShell 5.1, curl is an alias for Invoke-WebRequest, which wraps the response in extra formatting. Use curl.exe instead to call the real curl binary that ships with Windows 10+.
Help me troubleshoot curl in PowerShell
- Test the API statuses endpoint by running:
curl http://localhost:8080/api/v1/endpoints/statuses
You should see a JSON response containing status information for the endpoints you configured in config.yaml. This is the same data that powers the Gatus dashboard in your browser.
Why do these endpoints matter?
The /health endpoint is a simple liveness check. Orchestrators call it repeatedly to decide whether to restart the container or route traffic elsewhere.
The /api/v1/endpoints/statuses endpoint exposes the monitoring data programmatically. You could integrate it with alerting tools or custom dashboards.
Tag the image for a registry
Tagging gives your image a version number. This is how you prepare images for pushing to registries like Docker Hub, ECR, or GHCR.
- Tag your production image with a semantic version by running:
docker tag gatus-production gatus:v1.0.0
- Tag the same image as latest by running:
docker tag gatus-production gatus:latest
What do you think you will see when you list all images with the name gatus? How many entries, and will they all be the same size?
- Verify all your tags by running:
docker images gatus
You should see three entries: gatus-production, gatus:v1.0.0, and gatus:latest. They all share the same image ID and size because tags are just labels pointing to the same underlying image.
What is image tagging?
A tag is a human-readable label attached to an image. The v1.0.0 tag pins a specific release, so anyone pulling that tag gets exactly this version.
The latest tag is a convention that points to the most recent build. In this project, you are preparing tags locally. You are not pushing to a registry yet.
Not seeing all three images?
Make sure you used docker images gatus (not docker images without a filter). The filter shows only images whose repository name contains gatus.
If you only see one or two entries, double-check that both docker tag commands completed without errors.
Help me troubleshoot my Docker image tags
Write a documentation file
Good documentation turns a one-off build into a repeatable process. You will create a CONTAINERISATION.md file that explains what Gatus is, how Docker works, and why you made each decision in your Dockerfile.
- Create a new file called CONTAINERISATION.md in the gatus folder (the repo root where your Dockerfiles live).
- Open CONTAINERISATION.md in your text editor and paste the following content:
# Gatus Containerisation Documentation
## What is Gatus?
Gatus is an open-source, developer-oriented health dashboard written in Go.
It monitors HTTP, TCP, ICMP, and DNS endpoints and displays their status on
a web dashboard. It is a single binary application that reads configuration
from a YAML file and serves a Vue.js frontend on port 8080.
Repository: https://github.com/TwiN/gatus
License: Apache 2.0
## How Docker Works (Key Concepts)
Docker packages an application and its dependencies into an image. A container
is a running instance of an image. Unlike virtual machines, containers share
the host kernel and do not run a separate operating system, making them
lightweight and fast to start.
Key terms:
- Image: a read-only template with the application and its dependencies
- Container: a running instance of an image
- Dockerfile: a text file with instructions to build an image
- Layer: each Dockerfile instruction creates a layer that is cached
What do these sections cover?
The first section introduces Gatus so anyone reading your docs knows what the application does before diving into how it is containerised.
The Docker key concepts section defines the four terms you will reference throughout the rest of the document. These are the building blocks everything else rests on.
- Save CONTAINERISATION.md.
- Confirm the file exists by running:
dir CONTAINERISATION.md
You should see CONTAINERISATION.md listed with a recent timestamp.
- Add the three "why" sections below your existing content in CONTAINERISATION.md:
## Why Multi-Stage Builds?
A single-stage build includes everything needed to compile the application
(compiler, build tools, source code) in the final image. For Go, this means
the entire Go toolchain (~500MB+) ships in production.
Multi-stage builds solve this by using one stage for compilation and a
separate, minimal stage for runtime. Only the compiled binary is copied
to the final image, reducing the size by ~95%.
Our comparison:
- Naive single-stage image: ~500MB+
- Production multi-stage image: ~25MB
## Why Non-Root Containers?
Running containers as root is a security risk. If an attacker escapes the
container, they have root access to the host. Running as a non-root user
limits the blast radius of a container escape.
Our Dockerfile creates a dedicated "gatus" user and switches to it with
the USER instruction before the ENTRYPOINT.
## Why .dockerignore Matters
The .dockerignore file prevents unnecessary files from being included in
the Docker build context. Without it, Docker sends the entire directory
(including .git, test data, and other large directories) to the daemon,
slowing down builds and potentially leaking sensitive data into images.
Why document these decisions?
Each section answers a question a teammate or your future self might ask. Why not a single stage? Why not run as root? Why bother with .dockerignore? Writing the answer now saves hours of rediscovery later.
- Save CONTAINERISATION.md and verify your content so far by running:
type CONTAINERISATION.md
You should see all five sections printed in your terminal: the title, What is Gatus, How Docker Works, Why Multi-Stage Builds, Why Non-Root Containers, and Why .dockerignore Matters.
- Add the final two sections below your existing content in CONTAINERISATION.md:
## The /health Endpoint
Gatus exposes a /health endpoint that returns {"status":"UP"} with HTTP 200.
Container orchestrators (ECS, Kubernetes) use this endpoint to determine
whether the application is alive and ready to serve traffic.
If the health check fails, the orchestrator can automatically restart the
container or route traffic away from it.
## Common Troubleshooting
- "port is already allocated": another container or process is using port
8080. Stop it with docker stop or use a different port mapping (-p 9090:8080).
- Config file not found: ensure the mount path matches exactly. On Windows,
use absolute paths like C:\Users\you\gatus-project\config.
- Container exits immediately: check logs with docker logs <container-name>.
Usually a YAML syntax error in config.yaml.
- Image build fails at go mod download: ensure you have internet connectivity.
Docker needs to download Go dependencies during the build.
What do these final sections cover?
The /health section explains the endpoint you just tested and why orchestrators depend on it. The troubleshooting section captures the most common issues you might hit when running Gatus in Docker.
- Save CONTAINERISATION.md.
- Confirm the complete file by running:
type CONTAINERISATION.md
You should see all seven sections printed: the title, What is Gatus, How Docker Works, Why Multi-Stage Builds, Why Non-Root Containers, Why .dockerignore Matters, The /health Endpoint, and Common Troubleshooting.
✔️ Awesome, I've got everything!
Great. Double check you have saved CONTAINERISATION.md and that all seven sections are present.
ⓧ I'd like to double check the full code
Scroll up and confirm each of the three chunks you pasted is present in order:
- Chunk 1: Title, What is Gatus, How Docker Works (Key Concepts).
- Chunk 2: Why Multi-Stage Builds, Why Non-Root Containers, Why .dockerignore Matters.
- Chunk 3: The /health Endpoint, Common Troubleshooting.
Make sure there are no duplicate headings or missing blank lines between sections.
Time for the final check. What do you expect to see when you run docker images now? How many Gatus-related images, and what sizes?
- Run the final verification by running:
docker images
You should see gatus-naive at 500MB+, and gatus-production, gatus:v1.0.0, and gatus:latest all under 30MB. The production images share the same image ID because they are the same image with different labels.
Your image is tested, tagged, and fully documented. You have a production-ready container image ready to push to any registry. If you are looking for an extra challenge, a Secret Mission awaits.
Secret mission
Add a Docker HEALTHCHECK
Your Gatus container has a /health endpoint, but Docker itself has no idea about it. Right now, Docker only knows whether the process is running. It cannot tell if the application is actually serving traffic. Can you teach Docker to monitor the container's health automatically?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Running container: gatus-production on port 8080.
- Docker images: gatus-naive, gatus-production, gatus:v1.0.0, gatus:latest, and the pulled official image ghcr.io/twin/gatus:stable.
- Local project files: the gatus-project folder containing your config and cloned repository.
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue with a future phase of this project series.
- Your gatus-production container continues running on port 8080.
- All Docker images remain on your machine, ready for tagging and pushing to a registry in a future phase.
- Your project files and documentation stay in the gatus-project folder.
Pause - I'll come back to this later
Stop the running container to free up port 8080 and system memory. Your images and project files stay in place so you can pick up where you left off.
- Stop the running container by running:
docker stop gatus-production
- When you are ready to resume, start the container again by running:
docker start gatus-production
- Your Docker images and local project files remain untouched.
Delete - I don't want to use this again
Remove all project containers, images, and files from your machine.
- Stop and remove the running container by running:
docker rm -f gatus-production
- Remove all project Docker images by running:
docker rmi gatus-naive gatus-production gatus:v1.0.0 gatus:latest ghcr.io/twin/gatus:stable
- Navigate out of the project folder and delete it by running:
cd ~
Remove-Item -Recurse -Force .\gatus-project
Remove base images too
The commands above remove your project images, but Docker also downloaded base images like golang:1.26.3-alpine and alpine:3.21 during builds. To remove all unused Docker images and build cache at once, run docker system prune -a instead. This removes everything not currently used by a running container, not just images from this project.
Nice Work!
Nice Work!
You just containerised an open-source Go application from scratch and built a production-ready Docker image that is 95% smaller than the naive version. That is a core skill for shipping applications to any cloud environment.
You've learned how to:
- Ran the official Gatus Docker image, explored a running container from the inside, and then wrote your own Dockerfile to replace it.
- Wrote a naive single-stage Dockerfile, saw the 500MB+ bloat firsthand, then built a multi-stage production Dockerfile that compiles Go in one stage and copies only the binary to a minimal Alpine runtime image under 30MB.
- Secured the container with a non-root user, optimised the build context with .dockerignore, tested the /health endpoint, tagged the image for registry readiness, and documented every decision in CONTAINERISATION.md.
- Secret Mission: Added a Docker HEALTHCHECK instruction and verified Docker reports the container as healthy.
Ready to quiz yourself?