So far in this series, we have run everything on our own machine. But a real application has to be deployed somewhere, so that others can reach it from the internet. So we are going to deploy the full setup on a production server. That means the API, the queue worker and the scheduler, with Postgres and Redis behind them and HTTPS in front. It also means a way to ship new versions without losing the jobs in flight.
CodeStart from the Scheduling & Commands code
git clone https://github.com/krishankumar01/goravel-taskflowcd goravel-taskflowgit checkout part-18cp .env.example .envgo run . artisan key:generatego run . artisan jwt:secretdocker compose up -dgo run . artisan migrate:fresh --seeddocker 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:
FROM golang:alpine AS builder
ENV GO111MODULE=on \ CGO_ENABLED=0FROM golang:1.25-alpine AS builder
WORKDIR /buildCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN go mod tidyRUN go build --ldflags "-s -w -extldflags -static" -o main .RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o main .
FROM alpine:latestFROM alpine:3.22
WORKDIR /www
COPY --from=builder /build/main /www/COPY --from=builder /build/.env /www/.envCOPY --from=builder /build/public/ /www/public/COPY --from=builder /build/resources/ /www/resources/LABEL org.opencontainers.image.source=https://github.com/krishankumar01/goravel-taskflowRUN adduser -D -u 10001 appCOPY --from=builder /build/main ./mainCOPY --from=builder /build/public ./publicCOPY --from=builder /build/resources ./resourcesRUN mkdir -p storage/app storage/logs && chown -R app:app storageUSER app
ENTRYPOINT ["/www/main"]ENV APP_ENV=production APP_HOST=0.0.0.0 APP_PORT=3000EXPOSE 3000ENTRYPOINT ["./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:
.git.env.env.*storagetestsmainNow 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...mainpublicresourcesstorageThere 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:
29 collapsed lines
package config
import ( "taskflow/app/facades")
func init() { config := facades.Config() config.Add("logging", map[string]any{ // Default Log Channel // // This option defines the default log channel that gets used when writing // messages to the logs. The name specified in this option should match // one of the channels defined in the "channels" configuration array. "default": config.Env("LOG_CHANNEL", "stack"),
// Log Channels // // Here you may configure the log channels for your application. // Available Drivers: "single", "daily", "custom", "stack" // Available Level: "debug", "info", "warning", "error", "fatal", "panic" // Available Formatter: "text", "json" "channels": map[string]any{ "stack": map[string]any{ "driver": "stack", "channels": []string{"daily"}, }, "single": map[string]any{ "driver": "single", "path": "storage/logs/goravel.log", "level": config.Env("LOG_LEVEL", "debug"), "print": false, "print": config.Env("LOG_PRINT", false), "formatter": "text", }, "daily": map[string]any{ "driver": "daily", "path": "storage/logs/goravel.log", "level": config.Env("LOG_LEVEL", "debug"), "days": 7, "print": false, "print": config.Env("LOG_PRINT", false), "formatter": "text", }, }, })}The second is which runners a process starts, so one image can run as the API, the worker or the scheduler.
Web, worker and scheduler containers
go run . starts three runners in one process: goravel:http for the API, goravel:queue for the worker and goravel:schedule for the scheduler from Scheduling & Commands. That’s fine on a laptop, but on a server it causes two problems. A panic in one queue job stops the whole process, so the API goes down with it. And each runner needs a different number of containers: the scheduler must run in exactly one, while the worker is what we add more of when jobs pile up.
So we are going to run the same image as three containers, each with one runner on. Goravel skips every runner listed in app.disabled_runners, and we read that list from the environment, so each container can turn off the other two:
29 collapsed lines
package config
import ( "github.com/goravel/framework/support/carbon"
"taskflow/app/facades")
// Boot Start all init methods of the current folder to bootstrap all config.func Boot() {}
func init() { config := facades.Config() config.Add("app", map[string]any{ // Application Name // // This value is the name of your application. This value is used when the // framework needs to place the application's name in a notification or // any other location as required by the application or its packages. "name": config.Env("APP_NAME", "Goravel"),
// Application Environment // // This value determines the "environment" your application is currently // running in. This may determine how you prefer to configure various // services the application utilizes. Set this in your ".env" file. "env": config.Env("APP_ENV", "production"),
// Application Debug Mode "debug": config.Env("APP_DEBUG", false),
// Disabled Runners // // The runners this process should not start, as a space-separated list // of signatures, for example "goravel:queue goravel:schedule". "disabled_runners": config.Env("APP_DISABLED_RUNNERS", ""),
// Application Timezone //41 collapsed lines
// Here you may specify the default timezone for your application. // Example: UTC, Asia/Shanghai // More: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones "timezone": carbon.UTC,
// Application Locale Configuration // // The application locale determines the default locale that will be used // by the translation service provider. You are free to set this value // to any of the locales which will be supported by the application. "locale": "en",
// Application Fallback Locale // // The fallback locale determines the locale to use when the current one // is not available. You may change the value to correspond to any of // the language folders that are provided through your application. "fallback_locale": "en",
// Encryption Key // // 32 character string, otherwise these encrypted strings // will not be safe. Please do this before deploying an application! "key": config.Env("APP_KEY", ""), "maintenance": map[string]any{ // Maintenance Mode Driver // // These configuration options determine the driver used to determine and // manage Goravel's "maintenance mode" status. The "cache" driver will // allow maintenance mode to be controlled across multiple machines. // // Supported drivers: "file", "cache" "driver": config.Env("APP_MAINTENANCE_DRIVER", "file"), // Maintenance Mode Store // // This option controls the cache store used by the "cache" maintenance // driver. Leave it empty to use the default cache store. "store": config.Env("APP_MAINTENANCE_STORE", ""), }, })}Now we need Docker to start those three containers, along with Postgres and Redis. In front of them goes Caddy, a web server we use as a reverse proxy. Every request from the internet reaches Caddy first, and Caddy passes it on to web. It also sets up HTTPS by itself, getting a free certificate for our domain and renewing it before it expires.
A Compose file describes all six containers. The server needs only this file and a couple of others, not TaskFlow’s code, so we keep them in a deploy/ folder and copy just that folder over. Here’s deploy/compose.yml:
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:
12 collapsed lines
services: postgres: image: postgres:15-alpine restart: always environment: POSTGRES_DB: taskflow POSTGRES_USER: goravel POSTGRES_PASSWORD: secret ports: - "5433:5432" volumes: - postgres_data:/var/lib/postgresql/data
redis: image: redis:alpine image: redis:8.10-alpine restart: always ports:17 collapsed lines
- "6380:6379" volumes: - redis_data:/data
mailpit: image: axllent/mailpit:v1.27 restart: always environment: MP_SMTP_AUTH_ACCEPT_ANY: 1 MP_SMTP_AUTH_ALLOW_INSECURE: 1 ports: - "1025:1025" - "8025:8025"
volumes: postgres_data: redis_data:Health checks
Caddy should only start sending traffic once web is up and healthy, but TaskFlow has no route that says whether it is. So let’s add one. It checks the two things the app can’t run without, Postgres and Redis:
11 collapsed lines
package controllers
import ( "context" "time"
"github.com/goravel/framework/contracts/http" redisfacades "github.com/goravel/redis/facades"
"taskflow/app/facades")
type HealthController struct { // Dependent services}
func NewHealthController() *HealthController { return &HealthController{ // Inject services }}
func (r *HealthController) Show(ctx http.Context) http.Response { c, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel()
checks := map[string]string{"database": "ok", "redis": "ok"} status := http.StatusOK
db, err := facades.Orm().DB() if err == nil { err = db.PingContext(c) } if err != nil { checks["database"] = err.Error() status = http.StatusServiceUnavailable }
client, err := redisfacades.Instance("default") if err == nil { err = client.Ping(c).Err() } if err != nil { checks["redis"] = err.Error() status = http.StatusServiceUnavailable }
return ctx.Response().Json(status, checks)}- Both checks share a two-second timeout, so a database that hangs can’t hang the health check too.
- Each check writes its error into the response, and either failure turns the status into 503.
The route sits outside api/v1, because it isn’t part of the API:
16 collapsed lines
package routes
import ( "taskflow/app/facades" "taskflow/app/http/controllers" "taskflow/app/http/middleware"
"github.com/goravel/framework/contracts/route" httpmiddleware "github.com/goravel/framework/http/middleware")
func Api() { authController := controllers.NewAuthController() taskController := controllers.NewTaskController() userController := controllers.NewUserController() attachmentController := controllers.NewAttachmentController() notificationController := controllers.NewNotificationController() exportController := controllers.NewExportController() healthController := controllers.NewHealthController()
facades.Route().Get("healthz", healthController.Show)
facades.Route().Prefix("api/v1").Group(func(router route.Router) { router.Post("register", authController.Register)29 collapsed lines
router.Middleware(httpmiddleware.Throttle("login")).Post("login", authController.Login) router.Post("refresh", authController.Refresh)
router.Middleware(&middleware.Auth{}).Group(func(router route.Router) { router.Get("me", authController.Me) router.Post("logout", authController.Logout)
router.Post("tasks", taskController.Store).Name("tasks.create") router.Get("tasks/{id}", taskController.Show) router.Get("tasks", taskController.Index) router.Patch("tasks/{id}", taskController.Update) router.Delete("tasks/{id}", taskController.Destroy)
router.Post("tasks/{id}/attachments", attachmentController.Store) router.Get("tasks/{id}/attachments/{attachment}", attachmentController.Show) router.Delete("tasks/{id}/attachments/{attachment}", attachmentController.Destroy)
router.Get("notifications", notificationController.Index) router.Post("notifications/{id}/read", notificationController.Read)
router.Post("exports", exportController.Store) router.Get("exports/{id}", exportController.Show) router.Get("exports/{id}/download", exportController.Download)
router.Get("users", userController.Index) router.Get("users/{id}", userController.Show) }) })}When Postgres or Redis is down, web stays up, but the check answers 503 with the error, and Docker marks the container unhealthy through its healthcheck. Docker doesn’t restart it for that. The check is what docker compose up --wait and Caddy’s depends_on wait for before they go on.
Only Docker needs this route, so we keep it off the internet. A failed check names our database user and internal hostnames, and every request runs two pings, so it shouldn’t be open to anyone. Caddy answers 404 for it, while Docker still reaches it from inside the network:
{$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 successA 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.10On the server, as root:
curl -fsSL https://get.docker.com | shadduser --disabled-password --gecos "" deployusermod -aG docker deployinstall -d -m 700 -o deploy -g deploy /home/deploy/.sshinstall -m 600 -o deploy -g deploy /root/.ssh/authorized_keys /home/deploy/.ssh/install -d -o deploy -g deploy /opt/taskflowufw allow OpenSSHufw allow 80/tcpufw allow 443/tcpufw --force enableufw 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.10cd /opt/taskflowcp .env.example .env && chmod 600 .envDOMAIN=taskflow.example.comTASKFLOW_IMAGE=ghcr.io/krishankumar01/goravel-taskflow:latest
APP_NAME=TaskFlowAPP_ENV=productionAPP_KEY=APP_DEBUG=falseAPP_URL=https://taskflow.example.comLOG_LEVEL=infoLOG_PRINT=true
JWT_SECRET=
DB_CONNECTION=postgresDB_HOST=postgresDB_PORT=5432DB_DATABASE=taskflowDB_USERNAME=taskflowDB_PASSWORD=
QUEUE_CONNECTION=redisREDIS_HOST=redisREDIS_PORT=6379REDIS_PASSWORD=
FILESYSTEM_DISK=local
MAIL_HOST=MAIL_PORT=587MAIL_USERNAME=MAIL_PASSWORD=MAIL_FROM_ADDRESS=taskflow@example.comMAIL_FROM_NAME=TaskFlowWe 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)#" .envsed -i "s#^JWT_SECRET=.*#JWT_SECRET=$(openssl rand -hex 32)#" .envsed -i "s#^DB_PASSWORD=.*#DB_PASSWORD=$(openssl rand -hex 16)#" .envgrep shows that all three are now filled in:
grep -E "^(APP_KEY|JWT_SECRET|DB_PASSWORD)=" .envAPP_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:
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:
#!/bin/shset -eucd "$(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) || trueecho "$out"if ! echo "$out" | grep -q "Migration success"; then echo "migrate failed, keeping the running version" exit 1fi
docker compose up -d --waitsed -i "s#^TASKFLOW_IMAGE=.*#TASKFLOW_IMAGE=$image#" .envdocker image prune -afTASKFLOW_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.gitThe 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 -f ci_deploy -N "" -C "github actions"ssh-copy-id -i ci_deploy.pub deploy@203.0.113.10ssh-keyscan -t ed25519 203.0.113.10 > known_hostsssh-keygen -lf known_hostsssh-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:mainThat 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 28sOn 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 STATUScaddy caddy:2.10-alpine Up 12 hourspostgres 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 minuteweb ghcr.io/krishankumar01/goravel-taskflow:64af961d3cd87192f6f5d97d9f43c4072caceeb0 Up About a minute (healthy)worker ghcr.io/krishankumar01/goravel-taskflow:64af961d3cd87192f6f5d97d9f43c4072caceeb0 Up About a minuteCaddy 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/healthzHTTP/2 404Everyone 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",…}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 1MoreA 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.ownerchmod 600 .env.ownerThen 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#" .envThe 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:
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:
out=$(docker compose run --rm web artisan migrate 2>&1) || trueout=$(docker compose run --rm migrate 2>&1) || trueThe 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 tasksMoreBackups
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-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_restoredocker compose exec -T postgres pg_restore -U taskflow -d taskflow_restore < taskflow.dumpdocker 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_restoreTo 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 -deleteThese 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 100000Then 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 StartedThe 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"}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.ymldocker compose up -dThis 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:reserved1The 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.
git checkout part-19 See the changesBrowse the code ↗Download zip