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" 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… 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.modandgo.sumfirst and downloads the modules, so Docker reuses that layer until the dependencies change. -
golang:1.25-alpineandalpine:3.22are pinned, so a build next month starts from the same Go and Alpine versions. - The final image has the binary,
public/andresources/. 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 onlystorage/. -
LABEL org.opencontainers.image.sourcenames 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.0makes 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:
$ 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 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", }, }, }) } 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 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-appis the part the three app services share: the image, the settings from.env, the restart policy and the wait for Postgres and Redis.&appnames the block, and<<: *appcopies it into each service. Compose's docs call these fragments. Compose skips anything starting withx-, 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: 60sgives 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 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) } - 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 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:
$ 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:
$ ssh root@203.0.113.10 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 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 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:
$ 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:
$ 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" -
testruns the tests against real Postgres, Redis and Mailpit, started as services on the ports.env.testingexpects. Mailpit gets the same two settings as indocker-compose.yml, so it accepts the mailer's login. -
imageruns 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, andlatest.GITHUB_TOKENcan push because the job asks forpackages: write. -
deployruns only when theDEPLOY_ENABLEDvariable istrue. It logs in to the server asdeploy, checking the server's host key first, and runsdeploy.shwith the commit's image.environment: productionrecords each run as a deployment on the repository page, andconcurrency: deploymakes 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_IMAGEis set for this run only..envgets the new image after the containers are healthy, so if the new web fails its check,.envstill names the last good image anddocker compose up -dbrings it back. Migrations the release already ran stay applied. -
|| truekeepsset -efrom ending the script when the migrate container can't even start, so the script still prints why before it stops. -
docker image prune -afremoves 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 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_HOSTis the server's IP. -
DEPLOY_SSH_KEYis 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_HOSTSis 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 Source:https://dev.to/krishankumar01/goravel-deploy-docker-caddy-and-github-actions-5h84 Related posts

I Put My Country's Entire Legal Code in Git: 300,000+ Documents Since 1810

Gemini CLI's mkdir Failed, the Agent Kept Going, and Each move Overwrote the Last File

I built a gesture app for macOS 26. Here's how it works and how launch week went
