ARTICLE / docker · caddy
How I Use Docker, Docker Compose, Makefile, and Caddy to Deploy My Full-Stack Blog
Tech Article
- Published
- Reading time
- 7 min read
- Author
- Nicolas Leigh
1. Introduction
In modern web development, setting up a clean, consistent, and efficient development environment is just as important as writing the application code itself. Tools like Dockerfile, Docker Compose, Makefile, and Caddy can help you build, run, and deploy your applications with ease—but using them together effectively can be a challenge.
In this post, we’ll walk through how to combine these tools to develop and run a full-stack web app with a Vue frontend and a Go backend.
2. Project Overview
linze.pro is my personal blog application that allows me to publish and manage blog posts, while visitors can browse articles, view portfolio projects, and learn more about me. It’s designed as a full-stack application with a focus on clean architecture, multi-language support, and fast performance.
To make development and deployment more efficient, the project integrates the following tools:
- Dockerfile – to containerize the backend.
- Docker Compose – to orchestrate and manage multi-container services.
- Makefile – to define simple commands for building, running, and maintaining the app.
- Caddy – to serve static frontend files and reverse proxy API requests to the backend, with HTTPS support.
The project is a full-stack app:
- Frontend: Built with Vue and Tailwind CSS, providing a responsive and user-friendly interface.
- Backend: Built with Golang using the chi router, handling authentication, blog post CRUD operations, analytics, and more.
Here is the project folder structure:
linze.pro/
├── Caddyfile
├── Makefile
├── backend/
│ ├── Dockerfile
│ ├── .envrc
├── frontend/
├── compose.yaml
├── .gitignore
└── resources/
3. Dockerfile
Here’s the Dockerfile used to containerize the Go backend for linze.pro:
FROM golang:1.23.8-alpine3.20
WORKDIR /usr/src/app
COPY go.mod go.sum ./
# Uses Alibaba’s Go proxy for faster module downloads inside China
RUN go env -w GOPROXY=https://mirrors.aliyun.com/goproxy/,direct
RUN go mod download
# Install migration tool for database setup
RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
# links `migrate` to a common path
RUN ln -s /go/bin/linux_amd64/migrate /usr/local/bin/migrate
# Copy the source code
COPY . .
# Builds the application to `/usr/local/bin/app`
RUN go build -o /usr/local/bin/app ./cmd/api
# Expose the port used by the app
EXPOSE 8085
# Run the built binary file
CMD ["app"]
4. compose.yaml
To orchestrate all the services in the linze.pro blog app, I use Docker Compose. Here’s the compose.yaml file:
name: blog
services:
postgres:
restart: always
image: postgres:16.8-alpine3.20
container_name: blog-postgres
environment:
POSTGRES_DB: blog
POSTGRES_USER: nicolas
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
networks:
- backend_network
volumes:
- postgres_data:/var/lib/postgresql/data
expose:
- "5432"
backend:
restart: always
build:
context: ./backend
dockerfile: Dockerfile
container_name: blog-backend
env_file: "./backend/.envrc"
environment:
APP_ENV: production
networks:
- backend_network
depends_on:
- postgres
ports:
- "127.0.0.1:8085:8085"
redis:
image: redis:6.2-alpine
restart: always
container_name: blog-redis
networks:
- backend_network
command: redis-server --save 60 1 --loglevel warning
secrets:
db_password:
file: db_password.txt
volumes:
postgres_data:
networks:
backend_network:
driver: bridge
Services Explained
postgres
- Runs PostgreSQL in an Alpine-based lightweight container.
- Uses secrets to store the DB password securely.
- Stores persistent data via a named volume.
- Connected to the
backend_network.
backend
- Builds the Go backend from the
Dockerfile. - Loads environment variables from
.envrc. - Depends on
postgres(ensures Postgres starts first). - Binds to
127.0.0.1:8085to limit exposure to the host only. - Shares the same network as Postgres for inter-container communication.
redis
- Lightweight in-memory store used for caching or rate-limiting.
5. Caddy Setup
Caddy is a powerful web server that supports automatic HTTPS, reverse proxying, and static file serving with minimal configuration. Here's the Caddyfile used in the linze.pro project:
linze.pro {
handle /api/* {
reverse_proxy localhost:8085
}
handle {
root * /home/nicolas/linze.pro/vue-build/dist
try_files {path} /index.html
file_server
}
}
file.linze.pro {
handle {
root * /home/nicolas/linze.pro/resources
header {
Access-Control-Allow-Origin *
Access-Control-Allow-Methods "GET, OPTIONS"
Access-Control-Allow-Headers *
}
file_server browse
}
}
Section Breakdown
linze.pro
This block configures the main site that serves the frontend and handles API routing.
-
handle /api/*: Requests to paths like/api/v1/postsor/api/v1/authare reverse proxied to the backend Go server running onlocalhost:8085. -
handle:All other routes (e.g.,
/,/posts) serve static files from the Vue build output.-
root: Points to the Vue build files. -
try_files {path} /index.html: Points to the frontend code entry point. -
file_server: Enables static file serving.
-
file.linze.pro
This subdomain serves static resources (images and videos)
root: Points to/resourcesfolder on disk.header: Sets CORS headers to allow resources to be accessed from other origins. This is required because my project streams videos using HLS, which involves loading.m3u8and.tsfiles via JavaScript. If your videos are plain MP4 files served directly by the browser, this CORS configuration is usually not necessary.file_server browse: Enables directory listing.
6. Makefile
The Makefile is used to automate frequent tasks like Docker orchestration, database management, frontend deployment, and remote syncing. Here's how it's structured:
Variables and Environment
include ./backend/.envrc
MIGRATIONS_PATH=./cmd/migrate/migrations
include ./backend/.envrc: Loads environment variables, such as database DSN, used by thebackend/migrate/uptarget.MIGRATIONS_PATH: Defines the relative path to the database migration files. Since themigratecommand is executed inside the backend Docker container, the.refers to the root of the backend folder within the container context.
Resource Deployment
.PHONY: res/send
res/send:
@rsync -rP resources nicolas@106.14.126.186:~/linze.pro
Syncs the resources folder to the remote server using rsync.
-
I use
rsyncinstead ofscpbecausersyncis more powerful and reliable. In my experience,scpbehaves inconsistently—for example, when sending thedistfolder to the remote server, it sometimes skips creating the directory on the first attempt but then creates it automatically on the second. This unpredictability made me switch torsync, which handles file transfers more consistently and efficiently. -
-r: (recursive): Copies directories recursively. -
-P: (progress and partial): Shows progress during transfer.
.PHONY: res/send
- Declares
res/sendas a phony target, meaning it's not a real file or directory. Without this, if a file or folder namedres/sendexists,makewill skip running the command. Marking it as.PHONYforces the command to always execute.
@ before rsync
- Suppresses the default behavior of
makeprinting the command to the terminal. This keeps the output cleaner by hiding thersynccommand itself—only thersyncprogress and results will be shown.
Docker Compose Commands
.PHONY: compose/build
compose/build:
sudo docker compose up --build
.PHONY: compose/up
compose/up:
sudo docker compose up -d
compose/build: Builds images and runs containers interactively.compose/up: Runs containers in detached mode.
PostgreSQL DB Utilities
.PHONY: backend/createdb
backend/createdb:
sudo docker exec -it blog-postgres createdb -U nicolas blog
.PHONY: backend/psql
backend/psql:
sudo docker exec -it blog-postgres psql -U nicolas blog
- Runs common Postgres CLI commands inside the Docker container.
Database Migrations
.PHONY: backend/migrate/up
backend/migrate/up:
sudo docker exec blog-backend migrate -database ${CLOUD_DB_DSN} -path ${MIGRATIONS_PATH} up
- Runs database migrations using golang-migrate.
- The
CLOUD_DB_DSNvariable is defined inside the.envrcfile in the backend folder
Frontend Build & Deployment
.PHONY: frontend/build
frontend/build:
@cd frontend && npm run build && cd ..
.PHONY: frontend/send
frontend/send:
@cd frontend && rsync -rP dist nicolas@106.14.126.186:~/linze.pro/vue-build && cd ..
.PHONY: bs
bs: frontend/build frontend/send
@echo build and send finished!
frontend/build: Builds the Vue app.frontend/send: Deploys the built files viarsync.bs: A shortcut to build and send in one step.
7. How Docker, Compose, Makefile, and Caddy Work Together
In this project, each tool has a specific role, and together they create a smooth and automated development and deployment workflow:
1. Dockerfile – Build the Backend Application
The Dockerfile in the backend/ folder builds a statically linked Go binary and packages it into a lightweight container. This ensures that the backend runs consistently across environments—whether on your local machine or a remote server.
✅ Key benefit: Environment-independent Go app containerization.
2. docker-compose – Define and Run Multi-Container Setup
The compose.yaml file brings the whole application stack together:
- PostgreSQL for the database
- Redis for caching
- The Go backend container built from the Dockerfile
With one command (make compose/up), all these services are launched and networked properly.
✅ Key benefit: One file to rule them all—easy orchestration of your entire backend stack.
3. Makefile – Automate Everything
The Makefile wraps repetitive or error-prone commands—like syncing files, applying database migrations, or rebuilding the frontend—into friendly, memorable shortcuts.
Examples:
make bs: Build and deploy the frontendmake backend/migrate/up: Run database migrationsmake compose/build: Start all containers with a fresh build
✅ Key benefit: Simplifies daily dev and deployment tasks.
4. Caddy – Serve Everything Securely
Caddy serves both the frontend (static Vue build) and the API (reverse proxy to Go backend). It also handles HTTPS automatically with Let's Encrypt.
Caddy routes:
https://linze.pro→ Vue frontendhttps://linze.pro/api/*→ Go backendhttps://file.linze.pro→ Static file server (with CORS headers for HLS streaming)
✅ Key benefit: Zero-config HTTPS and clean domain routing.
Full Workflow Example
- Build containers:
make compose/build - Run DB migrations:
make backend/migrate/up - Build and deploy frontend:
make bs - Access the app at
https://linze.pro
8. Tips and Troubleshooting
Here are a few lessons learned and practical tips to save you time:
1. Example .envrc File
Make sure your .envrc is correctly set up for database migrations and other Makefile tasks:
export CLOUD_DB_DSN=postgres://<username>:<password>@postgres:5432/<dbname>
Note: Use the Docker Compose service name (
postgres) as the host, notlocalhost.
2. Avoid Quoting ENV Values
If you wrap environment variables in double quotes like this:
export CLOUD_DB_DSN="postgres://..."
…it may cause unexpected errors when passed into Makefile commands. Instead, write it without quotes unless absolutely necessary.
3. Use ssh-add Before rsync
If you’re deploying files with rsync over SSH, be sure your private key is loaded:
ssh-add ~/.ssh/id_rsa
Otherwise,
rsyncmay fail with a permission error, even ifsshworks fine.
9. Summary
In this post, I walked you through how I combined Docker, Docker Compose, Makefile, and Caddy to build and deploy my full-stack blog project. Each tool plays a distinct role:
- Docker packages the Go backend into a portable container.
- Docker Compose coordinates backend services like Postgres and Redis.
- Makefile automates common dev and deployment tasks.
- Caddy serves both static and dynamic content with automatic HTTPS.
The result is a clean, maintainable, and production-ready setup.
🖥️ Live Site: linze.pro 📦 Source Code: GitHub – nicolasleigh/linze.pro