DEV Community

Cover image for Goravel deploy: Docker, Caddy and GitHub Actions
Krishan
Krishan

Posted on Edited on Originally published at devchalk.com

Goravel deploy: Docker, Caddy and GitHub Actions

Part 19 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: 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:

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"
Enter fullscreen mode Exit fullscreen mode

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:

$ 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…
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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

$ docker build -t taskflow .
$ docker run --rm --entrypoint ls taskflow -a
.
..
main
public
resources
storage
Enter fullscreen mode Exit fullscreen mode

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 lines not shown
                "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",
            },
        },
    })
 }
Enter fullscreen mode Exit fullscreen mode

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 lines not shown
        "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 lines not shown
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode
  • 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 lines not shown

   redis:
-    image: redis:alpine
+    image: redis:8.10-alpine
     restart: always
     ports:
       # ... 17 lines not shown
Enter fullscreen mode Exit fullscreen mode

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 lines not shown

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)
}
Enter fullscreen mode Exit fullscreen mode
  • 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 lines not shown
    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 lines not shown
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

{$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:

$ docker compose run --rm web artisan migrate
 SUCCESS  Migration success
Enter fullscreen mode Exit fullscreen mode

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:

$ ssh root@203.0.113.10
Enter fullscreen mode Exit fullscreen mode

On the server, as root:

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
Enter fullscreen mode Exit fullscreen mode

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:

$ 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

$ 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
Enter fullscreen mode Exit fullscreen mode

grep shows that all three are now filled in:

$ grep -E "^(APP_KEY|JWT_SECRET|DB_PASSWORD)=" .env
APP_KEY=767289…
JWT_SECRET=467d20…
DB_PASSWORD=bf5a2f…
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode
  • 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:

$ git remote set-url origin git@github.com:<you>/goravel-taskflow.git
Enter fullscreen mode Exit fullscreen mode

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.
$ 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
Enter fullscreen mode Exit fullscreen mode

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:

$ git push -u origin HEAD:main
Enter fullscreen mode Exit fullscreen mode

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:

$ gh run view
✓ main Test, build and deploy · 41200199938

JOBS
✓ test in 1m53s
✓ image in 2m0s
✓ deploy in 28s
Enter fullscreen mode Exit fullscreen mode

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

$ 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
Enter fullscreen mode Exit fullscreen mode

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

$ 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
Enter fullscreen mode Exit fullscreen mode

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

$ 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",…}
Enter fullscreen mode Exit fullscreen mode

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:

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

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:

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

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

$ 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
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

$ docker compose exec postgres psql -U taskflow_app -d taskflow -c "DROP TABLE tasks"
ERROR:  must be owner of table tasks
Enter fullscreen mode Exit fullscreen mode

Backups:

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:

$ docker compose exec -T postgres pg_dump -U taskflow -Fc taskflow > taskflow.dump
Enter fullscreen mode Exit fullscreen mode

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

$ 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

$ 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
Enter fullscreen mode Exit fullscreen mode

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

$ 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
Enter fullscreen mode Exit fullscreen mode

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

$ 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"}
Enter fullscreen mode Exit fullscreen mode

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:

$ sed -i "s/stop_grace_period: 60s/stop_grace_period: 2s/" compose.yml
$ docker compose up -d
Enter fullscreen mode Exit fullscreen mode

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:

$ 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
Enter fullscreen mode Exit fullscreen mode

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.


Previous: 18. Scheduling & Commands
Series: Goravel

Top comments (0)