DevChalk

Docker & Deploy

Krishan Kumar · · 16 min See the changes ↗

So far in this series, we have run everything on our own machine. But a real application has to be deployed somewhere, so that others can reach it from the internet. So we are going to deploy the full setup on a production server. That means the API, the queue worker and the scheduler, with Postgres and Redis behind them and HTTPS in front. It also means a way to ship new versions without losing the jobs in flight.

CodeStart from the Scheduling & Commands code
Terminal window
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-18
cp .env.example .env
go run . artisan key:generate
go run . artisan jwt:secret
docker compose up -d
go run . artisan migrate:fresh --seed
docker compose exec postgres psql -U goravel -d taskflow -c "CREATE DATABASE taskflow_test"

A production Docker image

TaskFlow has had a Dockerfile since the first part. Before we ship it, let’s build it and look at what ends up inside. It copies our .env into the image, so every secret from the laptop ships with it:

Terminal window
docker build -t taskflow .
docker run --rm --entrypoint cat taskflow /www/.env | grep -E "APP_KEY|JWT_SECRET|DB_PASSWORD"
APP_KEY=d2RP1W…
JWT_SECRET=kdKMdI…
DB_PASSWORD=secret…

If we ship this image, our .env ships with it, and anyone who can pull the image can read these secrets. So the image should hold only the code, and the settings come from the server when the container starts. Let’s rewrite the Dockerfile with that in mind, as a multi-stage build that compiles in one image and ships only the result in another:

Dockerfile
FROM golang:alpine AS builder
ENV GO111MODULE=on \
CGO_ENABLED=0
FROM golang:1.25-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go mod tidy
RUN go build --ldflags "-s -w -extldflags -static" -o main .
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o main .
FROM alpine:latest
FROM alpine:3.22
WORKDIR /www
COPY --from=builder /build/main /www/
COPY --from=builder /build/.env /www/.env
COPY --from=builder /build/public/ /www/public/
COPY --from=builder /build/resources/ /www/resources/
LABEL org.opencontainers.image.source=https://github.com/krishankumar01/goravel-taskflow
RUN adduser -D -u 10001 app
COPY --from=builder /build/main ./main
COPY --from=builder /build/public ./public
COPY --from=builder /build/resources ./resources
RUN mkdir -p storage/app storage/logs && chown -R app:app storage
USER app
ENTRYPOINT ["/www/main"]
ENV APP_ENV=production APP_HOST=0.0.0.0 APP_PORT=3000
EXPOSE 3000
ENTRYPOINT ["./main"]
  • The build stage copies go.mod and go.sum first and downloads the modules, so Docker reuses that layer until the dependencies change.
  • golang:1.25-alpine and alpine:3.22 are pinned, so a build next month starts from the same Go and Alpine versions.
  • The final image has the binary, public/ and resources/. The binary reads both from its working directory at runtime. resources/ holds the views and the email templates, so without it the home page answers 500 and the first email panics.
  • The app runs as the user app, not root, and owns only storage/.
  • LABEL org.opencontainers.image.source names the repository the image comes from, so GHCR links the package to it and shows the source on the package page. Use your own repository here.
  • APP_HOST=0.0.0.0 makes the server listen on every address in the container. The default, 127.0.0.1, only accepts connections from inside the container itself.

.dockerignore keeps the rest out of the build, the local .env first:

.dockerignore
.git
.env
.env.*
storage
tests
main

Now let’s build the image again and check that it holds only what the app needs to run:

Terminal window
docker build -t taskflow .
docker run --rm --entrypoint ls taskflow -a
.
..
main
public
resources
storage

There is no Go toolchain in the image, so go run . artisan doesn’t work there. The compiled binary is the app, and it runs Artisan commands itself: ./main artisan migrate runs the migrations and exits without starting the server. ./main is the image’s entrypoint, so with Docker the same command is docker run taskflow artisan migrate.

Configuration from environment variables

Goravel reads each setting from the environment first, and from .env only when the file is there. So on the server we keep the settings in their own .env file, and Docker passes them to the containers when they start. The image itself never has them.

Two settings in TaskFlow’s config are written in code, so we can’t change them from the environment yet. The first one is the logs. Right now they only go to a file in storage/logs, and in a container nobody reads that file. With print on, every log line also goes to stdout, so docker compose logs shows it:

config/logging.go
29 collapsed lines
package config
import (
"taskflow/app/facades"
)
func init() {
config := facades.Config()
config.Add("logging", map[string]any{
// Default Log Channel
//
// This option defines the default log channel that gets used when writing
// messages to the logs. The name specified in this option should match
// one of the channels defined in the "channels" configuration array.
"default": config.Env("LOG_CHANNEL", "stack"),
// Log Channels
//
// Here you may configure the log channels for your application.
// Available Drivers: "single", "daily", "custom", "stack"
// Available Level: "debug", "info", "warning", "error", "fatal", "panic"
// Available Formatter: "text", "json"
"channels": map[string]any{
"stack": map[string]any{
"driver": "stack",
"channels": []string{"daily"},
},
"single": map[string]any{
"driver": "single",
"path": "storage/logs/goravel.log",
"level": config.Env("LOG_LEVEL", "debug"),
"print": false,
"print": config.Env("LOG_PRINT", false),
"formatter": "text",
},
"daily": map[string]any{
"driver": "daily",
"path": "storage/logs/goravel.log",
"level": config.Env("LOG_LEVEL", "debug"),
"days": 7,
"print": false,
"print": config.Env("LOG_PRINT", false),
"formatter": "text",
},
},
})
}

The second is which runners a process starts, so one image can run as the API, the worker or the scheduler.

Web, worker and scheduler containers

go run . starts three runners in one process: goravel:http for the API, goravel:queue for the worker and goravel:schedule for the scheduler from Scheduling & Commands. That’s fine on a laptop, but on a server it causes two problems. A panic in one queue job stops the whole process, so the API goes down with it. And each runner needs a different number of containers: the scheduler must run in exactly one, while the worker is what we add more of when jobs pile up.

So we are going to run the same image as three containers, each with one runner on. Goravel skips every runner listed in app.disabled_runners, and we read that list from the environment, so each container can turn off the other two:

config/app.go
29 collapsed lines
package config
import (
"github.com/goravel/framework/support/carbon"
"taskflow/app/facades"
)
// Boot Start all init methods of the current folder to bootstrap all config.
func Boot() {}
func init() {
config := facades.Config()
config.Add("app", map[string]any{
// Application Name
//
// This value is the name of your application. This value is used when the
// framework needs to place the application's name in a notification or
// any other location as required by the application or its packages.
"name": config.Env("APP_NAME", "Goravel"),
// Application Environment
//
// This value determines the "environment" your application is currently
// running in. This may determine how you prefer to configure various
// services the application utilizes. Set this in your ".env" file.
"env": config.Env("APP_ENV", "production"),
// Application Debug Mode
"debug": config.Env("APP_DEBUG", false),
// Disabled Runners
//
// The runners this process should not start, as a space-separated list
// of signatures, for example "goravel:queue goravel:schedule".
"disabled_runners": config.Env("APP_DISABLED_RUNNERS", ""),
// Application Timezone
//
41 collapsed lines
// Here you may specify the default timezone for your application.
// Example: UTC, Asia/Shanghai
// More: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
"timezone": carbon.UTC,
// Application Locale Configuration
//
// The application locale determines the default locale that will be used
// by the translation service provider. You are free to set this value
// to any of the locales which will be supported by the application.
"locale": "en",
// Application Fallback Locale
//
// The fallback locale determines the locale to use when the current one
// is not available. You may change the value to correspond to any of
// the language folders that are provided through your application.
"fallback_locale": "en",
// Encryption Key
//
// 32 character string, otherwise these encrypted strings
// will not be safe. Please do this before deploying an application!
"key": config.Env("APP_KEY", ""),
"maintenance": map[string]any{
// Maintenance Mode Driver
//
// These configuration options determine the driver used to determine and
// manage Goravel's "maintenance mode" status. The "cache" driver will
// allow maintenance mode to be controlled across multiple machines.
//
// Supported drivers: "file", "cache"
"driver": config.Env("APP_MAINTENANCE_DRIVER", "file"),
// Maintenance Mode Store
//
// This option controls the cache store used by the "cache" maintenance
// driver. Leave it empty to use the default cache store.
"store": config.Env("APP_MAINTENANCE_STORE", ""),
},
})
}

Now we need Docker to start those three containers, along with Postgres and Redis. In front of them goes Caddy, a web server we use as a reverse proxy. Every request from the internet reaches Caddy first, and Caddy passes it on to web. It also sets up HTTPS by itself, getting a free certificate for our domain and renewing it before it expires.

A Compose file describes all six containers. The server needs only this file and a couple of others, not TaskFlow’s code, so we keep them in a deploy/ folder and copy just that folder over. Here’s deploy/compose.yml:

deploy/compose.yml
x-app: &app
image: ${TASKFLOW_IMAGE:-taskflow}
env_file: .env
restart: unless-stopped
stop_grace_period: 60s
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
services:
web:
<<: *app
environment:
APP_DISABLED_RUNNERS: goravel:queue goravel:schedule
volumes:
- storage:/www/storage/app
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
interval: 10s
timeout: 3s
retries: 3
worker:
<<: *app
environment:
APP_DISABLED_RUNNERS: goravel:http goravel:schedule
volumes:
- storage:/www/storage/app
scheduler:
<<: *app
environment:
APP_DISABLED_RUNNERS: goravel:http goravel:queue
postgres:
image: postgres:15-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_DATABASE}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
retries: 10
redis:
image: redis:8.10-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
retries: 10
caddy:
image: caddy:2.10-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
DOMAIN: ${DOMAIN}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy:/data
depends_on:
web:
condition: service_healthy
volumes:
storage:
postgres:
redis:
caddy:
  • x-app is the part the three app services share: the image, the settings from .env, the restart policy and the wait for Postgres and Redis. &app names the block, and <<: *app copies it into each service. Compose’s docs call these fragments. Compose skips anything starting with x-, so it doesn’t run as a container of its own.
  • Each service turns off the two runners it doesn’t need with APP_DISABLED_RUNNERS.
  • stop_grace_period: 60s gives a process a minute to finish its work when it’s stopped, so a deploy doesn’t cut a job off halfway.
  • Only Caddy publishes ports. Web is reachable only through it.
  • Redis runs with --appendonly yes, its append-only file, so the queued jobs survive a restart, and its image is pinned like the others.

Web and worker also share one volume, storage:/www/storage/app, because the worker writes each export’s file, as in Queues & Jobs, and web is the one that serves the download. Without the shared volume, the export shows done but its download fails. Attachments live in that volume too, so they survive when a new version replaces the web container.

deploy/compose.yml is a second Compose file. The one at the project root is only for development, where it starts Postgres, Redis and Mailpit for the app we run with go run .. We pin Redis there too, so both use the same version:

docker-compose.yml
12 collapsed lines
services:
postgres:
image: postgres:15-alpine
restart: always
environment:
POSTGRES_DB: taskflow
POSTGRES_USER: goravel
POSTGRES_PASSWORD: secret
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:alpine
image: redis:8.10-alpine
restart: always
ports:
17 collapsed lines
- "6380:6379"
volumes:
- redis_data:/data
mailpit:
image: axllent/mailpit:v1.27
restart: always
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
ports:
- "1025:1025"
- "8025:8025"
volumes:
postgres_data:
redis_data:

Health checks

Caddy should only start sending traffic once web is up and healthy, but TaskFlow has no route that says whether it is. So let’s add one. It checks the two things the app can’t run without, Postgres and Redis:

app/http/controllers/health_controller.go
11 collapsed lines
package controllers
import (
"context"
"time"
"github.com/goravel/framework/contracts/http"
redisfacades "github.com/goravel/redis/facades"
"taskflow/app/facades"
)
type HealthController struct {
// Dependent services
}
func NewHealthController() *HealthController {
return &HealthController{
// Inject services
}
}
func (r *HealthController) Show(ctx http.Context) http.Response {
c, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
checks := map[string]string{"database": "ok", "redis": "ok"}
status := http.StatusOK
db, err := facades.Orm().DB()
if err == nil {
err = db.PingContext(c)
}
if err != nil {
checks["database"] = err.Error()
status = http.StatusServiceUnavailable
}
client, err := redisfacades.Instance("default")
if err == nil {
err = client.Ping(c).Err()
}
if err != nil {
checks["redis"] = err.Error()
status = http.StatusServiceUnavailable
}
return ctx.Response().Json(status, checks)
}
  • Both checks share a two-second timeout, so a database that hangs can’t hang the health check too.
  • Each check writes its error into the response, and either failure turns the status into 503.

The route sits outside api/v1, because it isn’t part of the API:

routes/api.go
16 collapsed lines
package routes
import (
"taskflow/app/facades"
"taskflow/app/http/controllers"
"taskflow/app/http/middleware"
"github.com/goravel/framework/contracts/route"
httpmiddleware "github.com/goravel/framework/http/middleware"
)
func Api() {
authController := controllers.NewAuthController()
taskController := controllers.NewTaskController()
userController := controllers.NewUserController()
attachmentController := controllers.NewAttachmentController()
notificationController := controllers.NewNotificationController()
exportController := controllers.NewExportController()
healthController := controllers.NewHealthController()
facades.Route().Get("healthz", healthController.Show)
facades.Route().Prefix("api/v1").Group(func(router route.Router) {
router.Post("register", authController.Register)
29 collapsed lines
router.Middleware(httpmiddleware.Throttle("login")).Post("login", authController.Login)
router.Post("refresh", authController.Refresh)
router.Middleware(&middleware.Auth{}).Group(func(router route.Router) {
router.Get("me", authController.Me)
router.Post("logout", authController.Logout)
router.Post("tasks", taskController.Store).Name("tasks.create")
router.Get("tasks/{id}", taskController.Show)
router.Get("tasks", taskController.Index)
router.Patch("tasks/{id}", taskController.Update)
router.Delete("tasks/{id}", taskController.Destroy)
router.Post("tasks/{id}/attachments", attachmentController.Store)
router.Get("tasks/{id}/attachments/{attachment}", attachmentController.Show)
router.Delete("tasks/{id}/attachments/{attachment}", attachmentController.Destroy)
router.Get("notifications", notificationController.Index)
router.Post("notifications/{id}/read", notificationController.Read)
router.Post("exports", exportController.Store)
router.Get("exports/{id}", exportController.Show)
router.Get("exports/{id}/download", exportController.Download)
router.Get("users", userController.Index)
router.Get("users/{id}", userController.Show)
})
})
}

When Postgres or Redis is down, web stays up, but the check answers 503 with the error, and Docker marks the container unhealthy through its healthcheck. Docker doesn’t restart it for that. The check is what docker compose up --wait and Caddy’s depends_on wait for before they go on.

Only Docker needs this route, so we keep it off the internet. A failed check names our database user and internal hostnames, and every request runs two pings, so it shouldn’t be open to anyone. Caddy answers 404 for it, while Docker still reaches it from inside the network:

deploy/Caddyfile
{$DOMAIN} {
respond /healthz 404
reverse_proxy web:3000
}

{$DOMAIN} is read from the DOMAIN setting, so the same Caddyfile works for any domain. Caddy gets the certificate for whatever name it holds.

Migrations in production

Migrations have to run once per release, and before the new version starts, because the new code expects the new tables. If each app container ran them when it started, all three would try at once, and again on every restart. We run them in a one-off container instead. docker compose run starts a fresh container from the web service, with the same image and settings, and runs one command. --rm removes it when it’s done:

Terminal window
docker compose run --rm web artisan migrate
SUCCESS Migration success

A VPS with HTTPS

Caddy and the containers need a server with a public IP. Any VPS provider works, with:

  • Ubuntu 24.04 on x86 (amd64), which is what the image is built for;
  • the smallest plan, which is plenty for TaskFlow’s six containers;
  • your SSH key added when you create it. GitHub’s guide shows how to generate one.

Caddy needs the domain to point at the server before it can get a certificate, so we add an A record with the server’s IP. Below, the server is 203.0.113.10 and the domain is taskflow.example.com.

First we log in as root once and prepare the server. It gets Docker, a deploy user that runs the app from here on, and a firewall that only lets in SSH, HTTP and HTTPS:

Terminal window
ssh root@203.0.113.10

On the server, as root:

Terminal window
curl -fsSL https://get.docker.com | sh
adduser --disabled-password --gecos "" deploy
usermod -aG docker deploy
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
install -m 600 -o deploy -g deploy /root/.ssh/authorized_keys /home/deploy/.ssh/
install -d -o deploy -g deploy /opt/taskflow
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable

ufw guards the server itself, but ports that Docker publishes skip it. That’s one more reason only Caddy publishes any.

Then we copy the deploy/ files to the server, where .env starts as a copy of the example:

Terminal window
scp deploy/compose.yml deploy/Caddyfile deploy/deploy.sh deploy/.env.example deploy@203.0.113.10:/opt/taskflow/
ssh deploy@203.0.113.10
cd /opt/taskflow
cp .env.example .env && chmod 600 .env
deploy/.env.example
DOMAIN=taskflow.example.com
TASKFLOW_IMAGE=ghcr.io/krishankumar01/goravel-taskflow:latest
APP_NAME=TaskFlow
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=https://taskflow.example.com
LOG_LEVEL=info
LOG_PRINT=true
JWT_SECRET=
DB_CONNECTION=postgres
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=taskflow
DB_USERNAME=taskflow
DB_PASSWORD=
QUEUE_CONNECTION=redis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=
FILESYSTEM_DISK=local
MAIL_HOST=
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM_ADDRESS=taskflow@example.com
MAIL_FROM_NAME=TaskFlow

We fill in the secrets on the server, so they never leave it. Each line replaces one empty setting in .env with a random value from openssl. APP_KEY must be exactly 32 characters, and rand -hex 16 gives that:

Terminal window
sed -i "s#^APP_KEY=.*#APP_KEY=$(openssl rand -hex 16)#" .env
sed -i "s#^JWT_SECRET=.*#JWT_SECRET=$(openssl rand -hex 32)#" .env
sed -i "s#^DB_PASSWORD=.*#DB_PASSWORD=$(openssl rand -hex 16)#" .env

grep shows that all three are now filled in:

Terminal window
grep -E "^(APP_KEY|JWT_SECRET|DB_PASSWORD)=" .env
APP_KEY=767289…
JWT_SECRET=467d20…
DB_PASSWORD=bf5a2f…

The rest is ours to set: DOMAIN and APP_URL for the domain, TASKFLOW_IMAGE for the image, and the MAIL_* lines for an SMTP provider.

CI and deploys with GitHub Actions

The server runs the app as an image it pulls from a registry. We use GitHub’s container registry, GHCR, and let GitHub Actions build the image, push it there and deploy it on every push to main. That way every release passes the tests first, and nothing is built or deployed by hand:

.github/workflows/deploy.yml
name: Test, build and deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-24.04
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_DB: taskflow_test
POSTGRES_USER: goravel
POSTGRES_PASSWORD: secret
ports: ["5433:5432"]
options: --health-cmd pg_isready --health-interval 5s --health-retries 10
redis:
image: redis:8.10-alpine
ports: ["6380:6379"]
mailpit:
image: axllent/mailpit:v1.27
env:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
ports: ["1025:1025", "8025:8025"]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
- run: go test ./tests/... --env=.env.testing
image:
needs: test
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
outputs:
name: ${{ steps.name.outputs.image }}
steps:
- id: name
run: echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v7
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
push: true
tags: |
${{ steps.name.outputs.image }}:${{ github.sha }}
${{ steps.name.outputs.image }}:latest
deploy:
needs: image
if: vars.DEPLOY_ENABLED == 'true'
runs-on: ubuntu-24.04
concurrency: deploy
environment: production
steps:
- name: Deploy over SSH
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
HOST: ${{ secrets.DEPLOY_HOST }}
IMAGE: ${{ needs.image.outputs.name }}:${{ github.sha }}
run: |
install -m 600 /dev/null key
echo "$SSH_KEY" > key
echo "$KNOWN_HOSTS" > known_hosts
ssh -i key -o UserKnownHostsFile=known_hosts -o StrictHostKeyChecking=yes deploy@"$HOST" \
"cd /opt/taskflow && ./deploy.sh $IMAGE"
  • test runs the tests against real Postgres, Redis and Mailpit, started as services on the ports .env.testing expects. Mailpit gets the same two settings as in docker-compose.yml, so it accepts the mailer’s login.
  • image runs only when the tests pass. It lowercases the repository name first, because image names must be lowercase and GitHub keeps the owner’s capitals. Then it builds the image and pushes it with two tags: the commit, so every release can be found again, and latest. GITHUB_TOKEN can push because the job asks for packages: write.
  • deploy runs only when the DEPLOY_ENABLED variable is true. It logs in to the server as deploy, checking the server’s host key first, and runs deploy.sh with the commit’s image. environment: production records each run as a deployment on the repository page, and concurrency: deploy makes a second push wait until the first deploy is done.

deploy.sh is what the deploy job runs on the server. It pulls the image and runs the migrations, then checks what migrate printed, so a failed migration stops the release before the new containers start:

deploy/deploy.sh
#!/bin/sh
set -eu
cd "$(dirname "$0")"
image="${1:?usage: ./deploy.sh <image>}"
export TASKFLOW_IMAGE="$image"
docker compose pull web
out=$(docker compose run --rm web artisan migrate 2>&1) || true
echo "$out"
if ! echo "$out" | grep -q "Migration success"; then
echo "migrate failed, keeping the running version"
exit 1
fi
docker compose up -d --wait
sed -i "s#^TASKFLOW_IMAGE=.*#TASKFLOW_IMAGE=$image#" .env
docker image prune -af
  • TASKFLOW_IMAGE is set for this run only. .env gets the new image after the containers are healthy, so if the new web fails its check, .env still names the last good image and docker compose up -d brings it back. Migrations the release already ran stay applied.
  • || true keeps set -e from ending the script when the migrate container can’t even start, so the script still prints why before it stops.
  • docker image prune -af removes images no container uses, so each release doesn’t leave its image on the disk.

The workflow runs in your own repository, so the code has to live there. Create an empty repository on GitHub and point origin at it, but don’t push yet. First put your own username in place of krishankumar01, in the Dockerfile’s LABEL and in deploy/.env.example:

Terminal window
git remote set-url origin git@github.com:<you>/goravel-taskflow.git

The deploy job needs three secrets. We add them to the production environment under Settings → Environments, and limit its deployment branches to main. Then only a deploy from main can read them, and that matters: deploy is in the docker group, so the deploy key works as root on the server.

  • DEPLOY_HOST is the server’s IP.
  • DEPLOY_SSH_KEY is a private key made just for CI, so it can be removed without touching our own. It’s made the same way as in GitHub’s SSH key guide.
  • DEPLOY_KNOWN_HOSTS is the server’s host key. Every run starts on a fresh machine, so without it the job would trust whatever answers on that IP.
Terminal window
ssh-keygen -t ed25519 -f ci_deploy -N "" -C "github actions"
ssh-copy-id -i ci_deploy.pub deploy@203.0.113.10
ssh-keyscan -t ed25519 203.0.113.10 > known_hosts
ssh-keygen -lf known_hosts

ssh-keygen -lf prints the key’s fingerprint, which must match the one SSH showed on our first login. The private half, ci_deploy, goes into DEPLOY_SSH_KEY and known_hosts into DEPLOY_KNOWN_HOSTS, and we delete both from our machine afterwards.

Last, the deploy job only runs when the repository variable DEPLOY_ENABLED is true, set under Settings → Secrets and variables → Actions. Turning it off stops deploys, while tests and images keep running on every push.

The first deploy

With the secrets and the variable in place, we push:

Terminal window
git push -u origin HEAD:main

That starts the first run, and its image job creates the package on GHCR. The workflow publishes it from a public repository, so the package is public too, and the server pulls it without logging in. If your repository is private, keep the package private. The server then logs in once with docker login ghcr.io, using a classic token with only the read:packages scope, as GitHub’s guide on authenticating to the registry shows.

The three jobs run one after another. gh run view shows them in the terminal, and the repository’s Actions tab shows the same:

Terminal window
gh run view
✓ main Test, build and deploy · 41200199938
JOBS
✓ test in 1m53s
✓ image in 2m0s
✓ deploy in 28s

On the server, web, worker and scheduler now run the commit’s image, while Postgres, Redis and Caddy carry on untouched:

Terminal window
docker compose ps --format "table {{.Service}}\t{{.Image}}\t{{.Status}}"
SERVICE IMAGE STATUS
caddy caddy:2.10-alpine Up 12 hours
postgres postgres:15-alpine Up 12 hours (healthy)
redis redis:8.10-alpine Up 12 hours (healthy)
scheduler ghcr.io/krishankumar01/goravel-taskflow:64af961d3cd87192f6f5d97d9f43c4072caceeb0 Up About a minute
web ghcr.io/krishankumar01/goravel-taskflow:64af961d3cd87192f6f5d97d9f43c4072caceeb0 Up About a minute (healthy)
worker ghcr.io/krishankumar01/goravel-taskflow:64af961d3cd87192f6f5d97d9f43c4072caceeb0 Up About a minute

Caddy already has a certificate from Let’s Encrypt, and /healthz stays hidden from outside:

Terminal window
curl -sv https://taskflow.example.com/ -o /dev/null 2>&1 | grep -E "subject:|issuer:"
* subject: CN=taskflow.example.com
* issuer: C=US; O=Let's Encrypt; CN=YE2
curl -sI https://taskflow.example.com/healthz
HTTP/2 404

Everyone who registers through the API is a member, so Ana registers first:

Terminal window
curl -X POST https://taskflow.example.com/api/v1/register -H "Content-Type: application/json" -d '{"name":"Ana","email":"ana@example.com","password":"secret123"}'
{…,"id":1,"name":"Ana","email":"ana@example.com","role":"member",…}

One SQL update then makes her the first manager. After that she logs in, and we keep her token in ANA as in earlier parts:

Terminal window
docker compose exec postgres psql -U taskflow -d taskflow -c "UPDATE users SET role = 'manager' WHERE email = 'ana@example.com'"
UPDATE 1
MoreA separate database user for migrations

Right now the app and the migrations log in to Postgres as the same user. That user owns every table, so if someone ever gets the API to run a query of their choosing, they could drop a table as easily as read one. So in production we usually split it into two users. The app gets one that can read and write rows but can’t change tables, and migrations keep running as the owner, only when we deploy.

On the server, the owner keeps its name and password, in a file of its own that only Postgres and migrate will read:

Terminal window
printf 'POSTGRES_USER=taskflow\nPOSTGRES_PASSWORD=%s\n' "$(sed -n 's/^DB_PASSWORD=//p' .env)" > .env.owner
chmod 600 .env.owner

Then the owner creates the app user with a new password, and .env switches to it:

Terminal window
pw=$(openssl rand -hex 16)
docker compose exec postgres psql -U taskflow -d taskflow -c "
CREATE ROLE taskflow_app LOGIN PASSWORD '$pw';
GRANT USAGE ON SCHEMA public TO taskflow_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO taskflow_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO taskflow_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO taskflow_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO taskflow_app;"
sed -i "s#^DB_USERNAME=.*#DB_USERNAME=taskflow_app#; s#^DB_PASSWORD=.*#DB_PASSWORD=$pw#" .env

The first two grants cover the tables there now. The ALTER DEFAULT PRIVILEGES lines cover the ones later migrations create, so a new table doesn’t need a new grant. Postgres 15 also stops ordinary users from creating tables in public, so the app user can’t add tables of its own.

In compose.yml, Postgres gets the owner from .env.owner, and a migrate service logs in as the owner, setting DB_USERNAME and DB_PASSWORD from the owner’s values just before it runs. profiles: [tools] keeps it out of docker compose up, so it only runs when we ask:

deploy/compose.yml
migrate:
<<: *app
env_file: [.env, .env.owner]
profiles: [tools]
restart: "no"
entrypoint: ["sh", "-c", "DB_USERNAME=$$POSTGRES_USER DB_PASSWORD=$$POSTGRES_PASSWORD exec ./main artisan migrate"]
postgres:
image: postgres:15-alpine
restart: unless-stopped
env_file: .env.owner
environment:
POSTGRES_DB: ${DB_DATABASE}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:

deploy.sh then runs the migrations through it:

deploy/deploy.sh
out=$(docker compose run --rm web artisan migrate 2>&1) || true
out=$(docker compose run --rm migrate 2>&1) || true

The next deploy migrates as the owner, and the app user gets turned away the moment it tries to change a table:

Terminal window
docker compose exec postgres psql -U taskflow_app -d taskflow -c "DROP TABLE tasks"
ERROR: must be owner of table tasks
MoreBackups

Postgres keeps all of TaskFlow’s data in one volume, so losing the server loses the data. pg_dump writes the whole database to one file while the app keeps running:

Terminal window
docker compose exec -T postgres pg_dump -U taskflow -Fc taskflow > taskflow.dump

-T runs it without a terminal, so the dump reaches the file unchanged. -Fc writes Postgres’s compressed custom format, which pg_restore reads.

A dump is only useful if it restores, so we restore it into a scratch database, check the data is there, and drop the scratch database again:

Terminal window
docker compose exec postgres createdb -U taskflow taskflow_restore
docker compose exec -T postgres pg_restore -U taskflow -d taskflow_restore < taskflow.dump
docker compose exec postgres psql -U taskflow -d taskflow_restore -c "SELECT count(*) FROM users"
count
-------
1
(1 row)
docker compose exec postgres dropdb -U taskflow taskflow_restore

To take one every night, we make a backups/ folder in /opt/taskflow and add one line to the deploy user’s crontab with crontab -e. It runs at 03:00 server time, names each file by its date and deletes files older than a week. In a crontab, % has to be written \%:

0 3 * * * cd /opt/taskflow && docker compose exec -T postgres pg_dump -U taskflow -Fc taskflow > backups/taskflow-$(date +\%F).dump && find backups -name "*.dump" -mtime +7 -delete

These files sit on the same server, so they’re lost with it. Copy them somewhere else too, such as another machine or object storage. Attachments and exports live in the storage volume, which a database dump doesn’t cover.

Graceful shutdown during deploys

A deploy replaces the worker, and the worker may be in the middle of a job when it happens. Docker stops a container by sending it SIGTERM first, and Goravel’s worker answers by finishing the job it’s running before it exits. Docker waits for that up to stop_grace_period, and only after that does it kill the process.

Let’s watch it with an export. A handful of tasks export in an instant, so first we add 100,000 test tasks, which makes the export take a while:

Terminal window
docker compose exec postgres psql -U taskflow -d taskflow -c "INSERT INTO tasks (created_at, updated_at, created_by, title, description, status) SELECT now(), now(), 1, 'Task ' || g, 'A task to export.', 'pending' FROM generate_series(1, 100000) g"
INSERT 0 100000

Then we ask for an export, and while it runs, we replace the worker the same way a deploy does:

Terminal window
curl -X POST https://taskflow.example.com/api/v1/exports -H "Authorization: Bearer $ANA"
{…,"id":1,"user_id":1,"status":"pending"}
docker compose up -d --force-recreate worker
Container taskflow-worker-1 Recreate
Container taskflow-worker-1 Recreated
Container taskflow-worker-1 Starting
Container taskflow-worker-1 Started

The old worker finished the export before it exited, so the export is done:

Terminal window
curl https://taskflow.example.com/api/v1/exports/1 -H "Authorization: Bearer $ANA"
{"created_at":"2026-10-31 12:06:39","updated_at":"2026-10-31 12:06:53","id":1,"user_id":1,"status":"done"}

Now let’s set the grace period to 2 seconds and try again. Docker reads the grace period from the container it stops, so docker compose up -d first gives every container the new value:

Terminal window
sed -i "s/stop_grace_period: 60s/stop_grace_period: 2s/" compose.yml
docker compose up -d

This time Docker kills the worker in the middle of the export. The export never finishes: it stays pending, and its job sits in Redis’s reserved list, where nothing picks it up again:

Terminal window
curl -X POST https://taskflow.example.com/api/v1/exports -H "Authorization: Bearer $ANA"
{…,"id":2,"user_id":1,"status":"pending"}
docker compose up -d --force-recreate worker
Container taskflow-worker-1 Recreate
Container taskflow-worker-1 Recreated
Container taskflow-worker-1 Starting
Container taskflow-worker-1 Started
curl https://taskflow.example.com/api/v1/exports/2 -H "Authorization: Bearer $ANA"
{"created_at":"2026-10-31 12:07:07","updated_at":"2026-10-31 12:07:07","id":2,"user_id":1,"status":"pending"}
docker compose exec redis redis-cli zcard TaskFlow_queues:redis_default:reserved
1

The exports:prune command from Scheduling & Commands deletes an export stuck like this after an hour, but the user never gets the file. So the grace period has to be longer than the slowest job. Put it back to 60 seconds, with docker compose up -d again, before moving on.

A release can also lose a job that’s new in it. The worker only runs jobs it knows, and while a deploy is running, the new web can send a job the old worker has never heard of. So a new job type ships one release before anything sends it.

TaskFlow now runs on a server with HTTPS, and every push to main is tested, built and deployed.

The code for this partgit checkout part-19 See the changesBrowse the code ↗Download zip
Get new articles by email One email when a new part or article comes out.