# Docker & Deploy

> TaskFlow in Docker on a server, with HTTPS, CI and deploys that keep their jobs.

Goravel series, part 19 of 19. By Krishan Kumar. Published 2026-10-31.
Source: https://devchalk.com/goravel/19-docker-deploy
Code: https://github.com/krishankumar01/goravel-taskflow/tree/part-19

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.

Start from the Scheduling & Commands code:

```bash
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:

```bash
$ 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](https://docs.docker.com/reference/dockerfile/) with that in mind, as a [multi-stage build](https://docs.docker.com/build/building/multi-stage/) that compiles in one image and ships only the result in another:

```diff lang="docker" title="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`](https://docs.docker.com/build/concepts/context/) keeps the rest out of the build, the local `.env` first:

```text title=".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:

```bash
$ 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](https://www.goravel.dev/getting-started/configuration.html#environment-configuration) 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:

```diff lang="go" title="config/logging.go" collapse={1-29}
 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](https://devchalk.com/goravel/18-scheduling-commands#the-schedule). 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`](https://www.goravel.dev/getting-started/configuration.html#disabled-runners), and we read that list from the environment, so each container can turn off the other two:

```diff lang="go" title="config/app.go" collapse={1-29} collapse={40-80} t1:runners=/(disabled_runners)/
 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
 		//
 		// 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](https://caddyserver.com/docs/), a web server we use as a [reverse proxy](https://en.wikipedia.org/wiki/Reverse_proxy). Every request from the internet reaches Caddy first, and Caddy passes it on to web. It also [sets up HTTPS by itself](https://caddyserver.com/docs/automatic-https), getting a free certificate for our domain and renewing it before it expires.

A [Compose file](https://docs.docker.com/reference/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`:

```yaml title="deploy/compose.yml" t1:runners=/(APP_DISABLED_RUNNERS)/ t2:volume=/(storage:\/www\/storage\/app)/ t3:app=/(&app)/ t3:app=/(<<:[ ][*]app)/
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](https://docs.docker.com/reference/compose-file/fragments/). Compose [skips anything starting with `x-`](https://docs.docker.com/reference/compose-file/extension/), 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`](https://docs.docker.com/reference/compose-file/services/#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](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/), 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](https://devchalk.com/goravel/16-queues-jobs-task-export#the-job), 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:

```diff lang="yaml" title="docker-compose.yml" collapse={1-12} collapse={19-35}
 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:
       - "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:

```go title="app/http/controllers/health_controller.go" collapse={1-11}
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:

```diff lang="go" title="routes/api.go" collapse={1-16} collapse={25-53}
 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)
 		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](https://docs.docker.com/reference/compose-file/services/#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:

```text title="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`](https://docs.docker.com/reference/cli/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:

```bash
$ 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](https://en.wikipedia.org/wiki/Virtual_private_server) 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](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent).

Caddy needs the domain to point at the server before it can get a certificate, so we add an [A record](https://developers.cloudflare.com/dns/manage-dns-records/reference/dns-record-types/#a-and-aaaa) 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](https://docs.docker.com/engine/install/ubuntu/), a `deploy` user that runs the app from here on, and a [firewall](https://documentation.ubuntu.com/server/how-to/security/firewalls/) that only lets in SSH, HTTP and HTTPS:

```bash t1:ip=/(203[.]0[.]113[.]10)/
$ ssh root@203.0.113.10
```

On the server, as root:

```bash
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:

```bash t1:ip=/(203[.]0[.]113[.]10)/
$ 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
```

```ini title="deploy/.env.example" t2:domain=/(taskflow[.]example[.]com)/
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:

```bash
$ 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:

```bash
$ 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](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry), and let [GitHub Actions](https://docs.github.com/en/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:

```yaml title=".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](https://docs.github.com/en/actions/use-cases-and-examples/using-containerized-services/about-service-containers) 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:

```bash title="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`:

```bash
$ git remote set-url origin git@github.com:<you>/goravel-taskflow.git
```

The deploy job needs three [secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions). 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](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent).
- `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.

```bash
$ 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:

```bash
$ 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](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-with-a-personal-access-token-classic) 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:

```bash
$ 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:

```bash
$ 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:

```bash
$ 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:

```bash
$ 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:

```bash
$ docker compose exec postgres psql -U taskflow -d taskflow -c "UPDATE users SET role = 'manager' WHERE email = 'ana@example.com'"
UPDATE 1
```


A 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:

```bash
$ 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:

```bash
$ 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`](https://www.postgresql.org/docs/15/sql-alterdefaultprivileges.html) 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:

```diff lang="yaml" title="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:

```diff lang="bash" title="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:

```bash
$ docker compose exec postgres psql -U taskflow_app -d taskflow -c "DROP TABLE tasks"
ERROR:  must be owner of table tasks
```

Backups:

Postgres keeps all of TaskFlow's data in one volume, so losing the server loses the data. [`pg_dump`](https://www.postgresql.org/docs/15/app-pgdump.html) writes the whole database to one file while the app keeps running:

```bash
$ 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`](https://www.postgresql.org/docs/15/app-pgrestore.html) 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:

```bash
$ 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 `\%`:

```text
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`](https://docs.docker.com/reference/compose-file/services/#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:

```bash
$ 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:

```bash
$ 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:

```bash
$ 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:

```bash
$ 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:

```bash
$ 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](https://devchalk.com/goravel/18-scheduling-commands#cleaning-up-exports) 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.

---

Previous: [18. Scheduling & Commands](https://devchalk.com/goravel/18-scheduling-commands.md)
Series: [Goravel](https://devchalk.com/goravel.md)
