How to Back Up PostgreSQL in Docker (Local, S3, and Plakar)

We'll cover doing backups and restores with and without Plakar and show how you can save money along the way.
Prefer video? Here’s a walkthrough on YouTube.
The data you store in your database is really important. For most businesses it’s everything and losing it could be a company ending event. Needless to say, backups are good.
In this post we’re going to focus on creating reliable daily backups for your self-hosted web applications using PostgreSQL where everything is running in Docker. We’ll be covering restoring too since that’s just as important!
- Not using Postgres? No worries, switching the commands for MySQL is easy
- Not using Docker? No problem, you can adjust those commands in a few seconds
This tutorial is really like 7 tutorials in 1:
- How to perform daily backups with just Postgres and a cron job
- How to back your database up to your file system or S3 bucket
- How to restore your database from a backup with only Postgres
- Understanding how the free and open source version of Plakar can save you money
- A crash course in how to use Plakar for both local and S3 storage
- Plakar supports other storage engines besides S3 too, more on that later!
- How to perform daily backups using Plakar
- How to restore your database using Plakar
I’m extremely skeptical of using 3rd party tools, especially for something as important as backups but Plakar has a number of interesting features to help reduce complexity and save you time and money.
# How I Backed Up My Databases for 10+ Years
Maybe your story is similar? It’s a fairly common set up. Dump and gzip the database every day to either a volume mounted disk or S3 bucket.
On the server where PostgreSQL is running in Docker I’d wire up a few basic things.
Script to Perform the Backup
Throughout this post I’m going to use this example Dockerized web app https://github.com/nickjj/docker-flask-example since it includes running a web server, PostgreSQL and more. I’ve extended it slightly to log web requests to a database table.
Feel free to use any project you want since the only thing that applies here is having Postgres running in a container with a means to add rows to a table. None of the scripts are going to be coupled to this specific project.
An expectation is the database container is already running. We’re going to
exec into it and run a backup command. Normally that would mean you have
everything up and running with docker compose up somewhere. If it’s not
already running you can replace exec with run in the scripts.
This script would be saved to /usr/local/bin/pgbackup on the server, or
anywhere you want on your dev box if you want to test all of this locally.
Don’t forget to chmod +x /usr/local/bin/pgbackup it to make it executable:
#!/usr/bin/env bash
# Create a full gzipped database backup.
#
# Usage:
# pgbackup myapp
# pgbackup myapp --today
set -o errexit
set -o pipefail
set -o nounset
PROJECT="${1}"
FLAG="${2:-}"
DATE=""
[ "${FLAG}" = "--today" ] && DATE="-$(date +%F)"
BACKUP_PATH="${HOME}/db-backups"
BACKUP_FILE="${PROJECT}${DATE}.sql.gz"
BACKUP_TMP_FILE="${BACKUP_PATH}/${BACKUP_FILE}.tmp"
# Ensure the temp file is deleted when this script exits.
trap 'rm -f "${BACKUP_TMP_FILE}"' EXIT
mkdir -p "${BACKUP_PATH}"
docker compose exec --no-tty postgres pg_dumpall --username "${PROJECT}" | gzip >"${BACKUP_TMP_FILE}"
mv "${BACKUP_TMP_FILE}" "${BACKUP_PATH}/${BACKUP_FILE}"
The above isn’t too crazy. It creates a complete dump of your database with
pg_dumpall and to keep the script general purpose it supports passing in the
project name since you might be backing up multiple projects.
Compression is used to reduce the size by a substantial amount. The exact savings depend on your data, but cutting the size in half is common.
Backup strategy:
The date is optional because if you only want 1 day’s worth of backups, you would be overwriting the file which has no date.
Otherwise you can pass in --today and -YYYY-MM-DD will be added to the file
name in case you wanted to keep a week’s worth of backups, etc..
Resilient backups:
Given the above, an important step is writing it to a temp file and then moving
it to the final destination. That’s because redirecting with the shell > will
immediately truncate the file before the pipeline starts! If the backup failed
half way through, we wouldn’t want a partial backup to overwrite the last
working backup. In case you’re curious, tee has the same issue. I’ve written
about this pattern in the past.
The above backs your data up to disk on the same machine running the command.
The backup path could be a block storage volume that exists on a different
machine or a network mount, it’s up to you. That’s why the temp file is written
to the backup path. It’s to ensure the mv command does an atomic rename by
keeping the temp file on the same filesystem as the real file.
Using S3 instead:
You can stream it directly to your bucket by piping it to the AWS CLI.
There’s no need for the temporary file and mv operation because a successful
object is only created if the upload completes. If any part of the pipeline
fails, set -o errexit and set -o pipefail ensure the script exits with an
error:
... | gzip | aws s3 cp - "s3://your_bucket_goes_here/${BACKUP_FILE}"
An expectation here is you’ve created a bucket named your_bucket_goes_here
and the user running this command has ~/.aws/crendentials configured with
enough access writes to create objects in the bucket.
We’ll cover setting up a bucket and AWS secret keys for a user later on in this post when we use Plakar, but these same credentials can be used here too.
Automating the Backup
With the script in place we can run a daily cron job. This is set to run as the
deploy user which is the same user who would be running your Docker commands,
such as docker compose up --detach or whatever you might be doing to start
your project.
It runs at 6:00 for whatever timezone your server is configured for. I keep mine set to UTC.
This would be saved to /etc/cron.d/pgbackup-hello:
0 6 * * * deploy /usr/local/bin/pgbackup hello
In this case “hello” is the project being backed up.
Not bad so far. We have daily backups running that overwrite each other every day.
Automated Pruning
If you use --today to produce -YYYY-MM-DD as part of the file name you’ll
have a new file being created every day. If you only want to keep the last 7
days then you can delete files older than 7 days in your backup directory for
the deploy user.
Another cron job would be saved to /etc/cron.d/delete-old-db-backups:
0 8 * * * deploy /usr/bin/find /home/deploy/db-backups -type f -name "*.sql.gz" -mtime +7 -delete
The above takes extra precautions to only delete gzipped SQL dumps. You could
further restrict it to a specific project name with -name "hello*.sql.gz" but
the provided original approach allows cleaning up all backups with 1 cron job.
If you’re using S3 instead of local files you can set an S3 life cycle policy to delete objects over than 7 days.
When creating this life cycle rule (under management for the bucket). Under the name, you can scope it to all objects in the bucket or filter it. If you have a dedicated bucket just for backups, feel free to pick all objects.
Then make sure to check these 2 life cycle rule actions a little further down:
- “Expire current versions of objects”
- Set it to 7 days or whatever amount of backups you want to keep
- “Delete expired object delete markers or incomplete multipart uploads”
- Check “Delete incomplete multipart uploads” in 1 day
The above ensures older files get deleted as well as partial / failed uploads.
Restoring Your Data
Being able to restore from your backup is the second piece of the puzzle. If
you have a fresh database, such as after running docker compose down -v to
wipe your volumes or you’re settings things up on a fresh machine then you’re
ready to restore.
This script would be saved to /usr/local/bin/pgrestore on the server:
#!/usr/bin/env bash
# Restore a gzipped database backup.
#
# Usage:
# pgrestore myapp
# pgrestore myapp YYYY-MM-DD
set -o errexit
set -o pipefail
set -o nounset
PROJECT="${1}"
DATE="${2:-}"
[ -n "${DATE}" ] && DATE="-${DATE}"
BACKUP_PATH="${HOME}/db-backups"
BACKUP_FILE="${PROJECT}${DATE}.sql.gz"
gunzip -c "${BACKUP_PATH}/${BACKUP_FILE}" |
docker compose exec --no-tty postgres psql --username "${PROJECT}"
If a date is passed in we need to adjust it to include - since there will be
YYYY-MM-DD passed in. That’s because in our backup script we outputted files
that way.
Since it’s a gzipped file we can’t send it into psql with < ${BACKUP_FILE},
instead we use gunzip to decompress and pipe it into psql.
That will restore your database back to whatever state it was in for the backup file. At this point you’re good to go and are hopefully back in business!
Inefficiencies with This Approach
Every day does a full SQL dump. While it does use compression to save costs, you are transferring the entire gzipped SQL file to S3 every day even if only 1% of your data changed between each day.
Depending on what you’re saving in your DB and how much data you have, that could be a few hundred megs or a couple of gigs, but there’s no limit right? Maybe it’s 2 TB because you have a lot of data.
What About Incremental Backups / Restores?
PostgreSQL 17+ supports incremental backups and pg_combinebackup for doing the restores. This goes beyond the scope of this post but if you want more granular backups such as being able to do point in time recovery to a specific point in time then you can check it out.
It’s a bit more complicated to set up, especially around restoring. However there are tools like https://github.com/pgbackrest/pgbackrest which make this process easier to set up.
Maybe I’ll cover this in more detail another time, let me know in the comments below if you want to see that. Personally daily backups with a means to restore has gone a LONG ways for me in many different projects for both myself and clients with millions upon millions of rows of data.
In any case, knowing how to do the basics which we’ve covered above is a good stepping stone to introducing more complexity around incremental backups and restores if you decide you want it.
# How Does Plakar Help?
For daily backups it’s quite useful!
In my opinion the biggest win is how it de-dupes data between backups. You only have to send what changes each day and Plakar handles internally managing everything so you can say “give me the latest snapshot” or “give me the snapshot from 3 days ago”. It also produces a file that you can restore your database from.
Concrete Example
We need something to work with so we can compare both solutions.
Let’s say you wanted to keep a week’s worth of backups (7 days) in S3. When estimating costs, we’ll do this without and with Plakar.
Let’s say your gzipped database dump is 10 GB and every day you add roughly 100 MB of new gzipped compressed data. That’s ~1% growth per day.
P.S., even if you only wanted to keep 1 day’s worth of backups, Plakar still adds value because you’ll soon seen how Plakar lets you perform incremental backups without needing to transfer the entire dump every day. Keep that in mind when it comes to data transfer costs.
Without Plakar
Here’s what would be dumped and sent to S3:
- Day 1:
10.00 GB - Day 2:
10.10 GB - Day 3:
10.20 GB - Day 4:
10.30 GB - Day 5:
10.40 GB - Day 6:
10.50 GB - Day 7:
10.61 GB
At the end of the 7 days, 72.11 GB would have been sent and stored.
Now let’s say this continues on for a year and over the year your DB growth has gone from 1% to 2% linearly because your business is growing.
Here’s a couple of numbers from that:
- Day 90:
20.00 GB - Day 180:
32.33 GB - Day 270:
46.88 GB - Day 365:
64.65 GB
That’s climbing really fast. If you summed up all of the data sent over the course of a year to S3, it would be in the neighborhood of ~12.5 TB.
Thankfully sending data into S3 is free so we don’t have to pay for that, but if you’re sending your data from a cloud provider, they might have outgoing data transfer fees to S3 or more generally out to the internet.
If you’re on AWS and your EC2 instance is in the same region it will be free but be careful if you have a NAT gateway attached to the instance. You’ll certainly want to set up an S3 Gateway Endpoint to avoid NAT gateway fees which would be a lot! It’s $0.045 per GB. This 1 year of alone would be ~$563 for the ~12.5 TB. Don’t skip that.
By the end of year 1, it would be around $10 / month for storage alone. That’s based on roughly ~455 GB (65 x 7) being stored in a rolling 7 day fashion. At S3 Standard rates ($0.023 (us-east-2)) that’s a little over $10 bucks.
S3 Standard is used in this calculation because most Infrequent Access or Glacier options require a minimum number of days to pay storage on, such as 30-90 which conflicts with our 7 day amount. Also, sometimes you have to wait a long retrieval time but if you need to restore your production data, it’s usually “right now”.
By the way, as an aside if you were saving your backups on disk instead of S3, you’d need ~455 GB of available space just to store these 7 days of backups after 1 year.
Long story short, year 1 is only the beginning. It doesn’t seem too bad to begin with but over time sending everything over the wire and saving full copies is going to add up. Our database isn’t even huge either, if you have 10x the data then your costs will be 10x!
With Plakar
Its de-dupe feature will save us a lot here.
Plakar is built on top of an immutable data storage engine called Kloset.
The TL;DR for now is your data will get split into chunks which is internally managed by Plakar. This could be database dumps, binary files or whatever it happens to be.
It has a deduplication feature that will scan for chunks that already exist. Behind the scenes it knows to only add new chunks that don’t already exist.
IMPORTANT NOTE: We’ve been manually gzipping our dump without Plakar but when we use Plakar we won’t gzip it, instead Plakar will handle de-duping and compressing the chunks internally. With that said, the numbers below still assume it’s compressed in size to compare costs equally since it’s compressed in the end in both cases.
Every time you take a backup (such as daily) you take a Plakar snapshot. These snapshots end up being standalone backups that contain de-duped chunks. They are not incremental that need to be replayed in order like a traditional incremental backup. You can even perform a diff between the snapshots to see what changed.
We’ll still take daily database backups but instead of them being gzipped and sent to S3 independently we’ll create a Plakar snapshot and tell Plakar to use S3 as its back-end repository (there’s other options too).
In our example we determined 1% growth that will linearly go to 2% over a year.
Here’s the first week of backups:
- Day 1:
10.00 GB(the initial has the full database) - Day 2:
0.10 GB - Day 3:
0.10 GB - Day 4:
0.10 GB - Day 5:
0.10 GB - Day 6:
0.10 GB - Day 7:
0.11 GB
We only had to transfer a total of 10.61 GB over the wire, compared to the
72.11 GB required by the original backup strategy without Plakar.
Here’s a couple of numbers from the course of 1 year:
- Day 90:
0.12 GB - Day 180:
0.15 GB - Day 270:
0.17 GB - Day 365:
0.20 GB
If you sum up all of the data Plakar transfers over the course of the full year, it’s ~64.55 GB. Compare that to the ~12.5 TB (12,500 GB) you would have sent without Plakar.
If your hosting provider charges outgoing data transfer fees, it’s only a tiny fraction.
With the EC2 / NAT gateway example it would be ~$3 for the year instead of ~$563 but honestly I would classify this as $0 in both cases because you can set things up to avoid this fee. I wanted to include it for the sake of completeness.
At the end of the year our DB is still 64.65 GB in size but with the same S3 strategy as above, our cost is only ~$1.50 per month instead of $10 because of the deduplication. We only need to store those ~65 GB instead of 7 full dumps (~455 GB). The same goes for available disk space if we weren’t using S3.
As more time goes on, you save more and more. It’s a snowball effect of savings. Think about retirement investing. Compound interest is hard to visualize, even with linear growth. For example if you have $100,000 and put in $100 / month for 40 years you will end up with over $5 million dollars at 10% interest. Deduplication is similar except it applies to disk space and it’s in reverse (in a good way)!
What About Using Your Hosting Provider’s Backups?
So far we’ve focused on self hosting PostgreSQL running in Docker, but what if you decided to use a managed database through your cloud provider instead?
For example, if you’re on AWS you might be using RDS which is their hosted SQL database solution. It has a “snapshot” feature to handle backups and restoring. Other cloud hosting providers have similar solutions.
The AWS model for calculating exact costs is complicated for automated snapshots, but it will be more expensive than S3. While they are incremental, they are not de-duped which means if you have a staging and production DB with similar data, you’ll be paying for both.
Automated RDS snapshots are not portable too, however unless you plan on moving across cloud providers maybe that’s not an issue. With that said, automated backups do provide point in time recovery, so you are getting a benefit at this higher cost. These are trade offs you’ll want to consider.
Ok, going back to self-hosted databases…
# Installing Plakar
There’s a bunch of different ways to install Plakar depending on which OS you have. There’s amd64 and arm64 binaries for macOS as well as Linux and Windows.
If you’re on Windows, I suggest using WSL 2 since we’re running shell scripts.
Package managers?
- On macOS you can
brew install plakar - On Arch you can
yay -S plakar(depends on the AUR) - On distros that use
aptordnfyou can find the steps here.
Targeting v1.1.4 for this post:
At the time of this post, v1.1.4 is the latest version. You’re more than welcome to use a newer version if one exists. If you run into issues you can either downgrade this version or make minor updates to the commands to use the latest.
I’ll try to keep this post up to date if anything breaks in the future.
What did I do?
I like to use Debian in production so I’ve installed Plakar’s deb package on my server.
With that said, on my dev box which uses Arch Linux I downloaded the Linux
binary directly with curl. This would work on Windows with WSL 2 too. Feel
free to adjust amd64 with arm64 if you have an arm based CPU and update
the version if something newer exists.
In the command below, sudo is only being used to write to /usr/local/bin:
curl -sSfL https://plakar.io/dl/plakar_1.1.4_linux_amd64.tar.gz \
| sudo tar -xzf - -C /usr/local/bin/ plakar && chmod +x /usr/local/bin/plakar
If you know ~/.local/bin is on your system path you can do this instead
without sudo:
curl -sSfL https://plakar.io/dl/plakar_1.1.4_linux_amd64.tar.gz \
| tar -xzf - -C ~/.local/bin/ plakar && && chmod +x ~/.local/bin/plakar
Sanity check:
# Your output might be a little different if you have a newer version.
$ plakar version
plakar/v1.1.4
No matter how you installed Plakar, you should be able to run the above successfully.
# Daily Database Backups with Plakar
Earlier we covered how to do daily backups without Plakar and honestly our strategy is not going to change too much. Of course we’ll be tweaking a few things and running different commands but it’s the same idea of:
- Create database dump on a daily cron job
- Plakar “stuff” happens
- Backup gets saved to a local or network mounted file system or S3
- It supports file system, S3, Google Cloud Storage, Azure Blob Storage, SFTP, Dropbox, Google Drive, OneDrive and many more destinations.
Configuring Plakar (Local Backup)
Before we use S3, let’s experiment with local file storage first. Technically we’ll be creating a Plakar repository.
Along the way we’ll end up doing a mini-crash course in using Plakar.
We only need to run this once:
# Delete our previous local backup directory so it's empty:
rm -rf ~/db-backups
# Modify the path to whatever makes sense for your server:
$ plakar at ~/db-backups create
repository passphrase:
repository passphrase (confirm):
- Input a good passphrase because Plakar will encrypt your data with it
- Carefully save this passphrase because anyone with it can decrypt your data, it cannot be recovered if you lose it, if you lose it, your data is gone forever!
For the sake of this tutorial I used Hello@world12345! as the password which
is not very good but it does include enough special characters and length to
avoid Plakar warning us that our passphrase weak.
That command should finish successfully, you can verify things worked by running:
$ ls -la ~/db-backups
total 24
drwx------ 5 nick nick 4096 Jul 3 09:41 .
drwx------ 19 nick nick 4096 Jul 3 09:41 ..
-rw------- 1 nick nick 663 Jul 3 09:41 CONFIG
drwx------ 2 nick nick 4096 Jul 3 09:41 locks
drwx------ 258 nick nick 4096 Jul 3 09:41 packfiles
drwx------ 258 nick nick 4096 Jul 3 09:41 states
At this point we have a place to back our database up to, but nothing has been backed up.
Aliases:
We’re going to be passing in at ~/db-backups to a lot of Plakar commands and
also reference it in scrips and cron jobs. To make things more portable we can
create an alias for our local storage to avoid hard coding the path.
For example, we can run this. We’ll use local as the name but it can be named
anything:
# Keep in mind to run this as the deploy user, or instead of using $HOME put
# in the user's home directory path of your choosing.
plakar store add local location=${HOME}/db-backups
I used ${HOME} instead of ~ because tilde will get literally put into
the config file and Plakar doesn’t expand it, where as ${HOME} will expand
in your shell before Plakar processes it.
You can verify it with:
$ cat ~/.config/plakar/stores.yml
version: v1.0.0
stores:
local:
location: /home/deploy/db-backups
You should see your user in the above path.
Now instead of running plakar at ~/db-backups <...> we can use plakar at @local <...>.
Storing your passphrase:
If you’re following along on your dev box it would be helpful to run this in
your terminal, this way it won’t keep prompting us when we run a bunch of
plakar commands:
export PLAKAR_PASSPHRASE='Hello@world12345!'
Depending on how you have your shell configured, you might want to put a space before running that command to skip it from being added to your shell history.
If you’re doing this on your real server, you’ll want to run this (adjust it with your passphrase):
echo "export PLAKAR_PASSPHRASE='Hello@world12345!'" > ~/.config/plakar/.env-passphrase
chmod 0600 ~/.config/plakar/.env-passphrase
Since we’ll be running this in a cron job, it’s important that your passphrase is accessible in a way that doesn’t require you to input it every time it runs.
The above is just a personal opinion on how to store your passphrase. We
already have ~/.config/plakar available and I like the idea of putting the
Plakar passphrase alongside the other config files. This way everything is in 1
spot.
Since it has a sensitive value, setting the file’s permissions to 0600 makes
it only readable by your user which is helpful in case you have multiple users
on your server.
It’s important you export it in the echo statement because we’ll be sourcing this file in from the new backup script and this env var needs to be made accessible to the Plakar CLI (a child process within the script).
Script to Perform the Backup
We’re going to create a new pgbackup-plakar script. Realistically you can
keep the original pgbackup name if you want. I chose to use a different name
so both can exist together for the sake of this tutorial.
#!/usr/bin/env bash
# Create a full database backup managed by Plakar
#
# Usage:
# PLAKAR_REPOSITORY=@local pgbackup-plakar hello
set -o errexit
set -o pipefail
set -o nounset
PROJECT="${1}"
BACKUP_FILE="stdin://${PROJECT}.sql"
PLAKAR_PASSPHRASE_PATH="${HOME}/.config/plakar/.env-passphrase"
# shellcheck disable=SC1090
[ -z "${PLAKAR_PASSPHRASE:-}" ] && . "${PLAKAR_PASSPHRASE_PATH}"
docker compose exec --no-tty postgres pg_dumpall --username "${PROJECT}" |
plakar at "${PLAKAR_REPOSITORY}" backup "${BACKUP_FILE}"
Quite a few things were changed:
- The
--todayflag was removed since we want to use the same file for each backup, Plakar will handle timestamping it internally and it lets Plakar know we’re backing up the same content so it can be incrementally updated - We’re handling reading in your passphrase from either an env variable or env file
- It’s set up to fail early if neither are found, to ensure it’s set in some way
- The backup path was removed since we can reference the
@localalias - The temp file was removed because Plakar will handle ensuring only complete and successful backups are saved
- The pipe to
gzipwas removed because Plakar will handle de-duping and compression from the raw dump, it’s important not to compress your data before it reaches Plakar
The last part is plakar at "${PLAKAR_REPOSITORY}" backup "${BACKUP_FILE}":
- We’re future proofing ourselves here to support passing in the storage repository with
PLAKAR_REPOSITORY, this can belocal,s3or any other aliases we create in the future - The backup file instructs Plakar the data is coming from STDIN, the project name is used here so we can better identify it later
As a side note, PLAKAR_REPOSITORY is an official environment variable that
Plakar supports. If you export it on your shell you won’t have to include at @local in most commands. I chose the same name in the backup script to be
compatible in case you have it exported in your shell.
Multiple Databases?
If you have multiple databases, such as staging and production or maybe 2
separate databases it’s worth it to use 1 local repository (~/db-backups in
our script which is associated to @local).
This lets Plakar do cross-database deduplication which is a huge win, especially with staging and production where you might be seeding your staging database with a subset of your production DB.
It also reduces operational burdens because you can use the same passphrase for each database. It also lets us only need to run a Plakar specific maintenance command once (more on this later).
If you had a 2nd database you wanted to backup, you can pass in a different
project name when you run pgbackup-plakar. If it’s different environments you
can do something like hello-production or whatever makes sense for you.
Create, Verify and Diff Backups
Let’s do our first backup:
$ PLAKAR_REPOSITORY=@local ./pgbackup-plakar hello
repository passphrase:
[00:00] 9dd4abc1 completed completed 7.4 KiB
*************************************** 100%
import: nodes=1/1, objects=1/1
store: read=0 B, write=0 B
Verify it was created:
$ plakar at @local ls
2026-07-03T14:01:53Z 9dd4abc1 7.4 KiB 0s /
At this point, try doing something with your database to add more data.
Then run another backup and ls, you should notice a 2nd entry which is
larger:
$ PLAKAR_REPOSITORY=@local ./pgbackup-plakar hello
[00:00] 5b870dd2 completed 8.0 KiB
*************************************** 100%
import: nodes=1/1, objects=1/1
store: read=0 B, write=0 B
$ plakar at @local ls
2026-07-03T14:02:44Z 5b870dd2 8.0 KiB 0s /
2026-07-03T14:01:53Z 9dd4abc1 7.4 KiB 0s /
Notice when you perform the backup, there’s a value “5b870dd2” next to the timestamp. Each entry has its own. This is the short form of the snapshot ID (unique fingerprint). A lot of commands will be referencing this, yours will be different.
Verify the latest backup:
# Adjust my ID with yours:
$ plakar at @local check 5b870dd2
5b870dd2: OK ✓ /
5b870dd2: OK ✓ /hello.sql
5b870dd2: check completed without errors in 9.256877ms (in: 1.9 KiB, out: 0 B)
You can do a deeper investigation by running info instead of check:
# Adjust my ID with yours:
$ plakar at @local info 5b870dd2
You’ll see the long snapshot ID, timestamp, how long the backup took and more.
You can even diff them:
# Adjust your dump file name accordingly:
plakar at @local diff 9dd4abc1:hello.sql 5b870dd2:hello.sql
In my case I reloaded a local website a few times since I had an example web app running which logs all requests. I’ve redacted some of the content to keep things short but kept the interesting lines:
--- 9dd4abc1:/hello.sql
+++ 5b870dd2:/hello.sql
@@ -2,7 +2,7 @@
-- PostgreSQL database cluster dump
--
@@ -178,6 +178,9 @@
--
COPY public.request_logs (id, request_id, ip_address, method, endpoint, status_code, user_agent, created_on, updated_on) FROM stdin;
+1 e12f58b4-2bb3-451c-8695-4ef2e0bedc61 172.19.0.1 GET / 200 Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0 2026-07-03 14:02:38.120025+00 2026-07-03 14:02:38.120039+00
+2 2cc507f3-5ad1-4054-8b71-ccbd3ad079e2 172.19.0.1 GET / 200 Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0 2026-07-03 14:02:38.593499+00 2026-07-03 14:02:38.593509+00
+3 1cab5c73-1e17-4d89-a65d-cbf532776e85 172.19.0.1 GET / 200 Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0 2026-07-03 14:02:39.130707+00 2026-07-03 14:02:39.130718+00
\.
@@ -185,7 +188,7 @@
-- Name: request_logs_id_seq; Type: SEQUENCE SET; Schema: public; Owner: hello
--
-SELECT pg_catalog.setval('public.request_logs_id_seq', 1, false);
+SELECT pg_catalog.setval('public.request_logs_id_seq', 3, true);
--
-- PostgreSQL database cluster dump complete
The above is super interesting, you can see IDs 1-3 were added. I reloaded the page 3 times in between backups. When I first backed things up I had no rows. It’s pretty neat to see the diffs.
Even if you have daily backups overwriting each other, Plakar can still show you the exact differences between each one. Not only that but each one is standalone, that means if you had 3 snapshots you could delete the one in the middle and still not have data loss.
Understanding Deletes, Chunks and Maintenance
With a traditional incremental backup solution if you had:
- Initial backup
- New data A
- New data B
You’d restore from the beginning, it’s important that all 3 steps are replayed in order. If step 2 were deleted, you’d have data corruption since none of that data would exist anymore.
Plakar doesn’t work like this. The data itself is decoupled from the snapshots
that you see when you run the ls command. We need to handle deletes manually
to keep things tidy based on what makes sense for our backup strategy.
Without going too deep into how the Kloset engine works, you can think of snapshot IDs as a shopping list. It’s a list of items to obtain where the underlying Kloset data storage engine is the store to obtain chunks from.
This isn’t exactly how it works, but conceptually imagine this snapshot:
2026-07-03T14:01:53Z 9dd4abc1 7.4 KiB 0s /
It might contain chunks A, B and C.
Then this snapshot which was taken afterwards:
2026-07-03T14:02:44Z 5b870dd2 8.0 KiB 0s /
It might contain chunks B, C and D.
Since chunks B and C exist in both snapshots, Kloset will de-dupe them so it’s only stored on disk once. It’s also compressed for extra savings.
In addition to that, think about Kloset holding onto your data forever by default. In other words, let’s say you add 100 MB of data today and then delete 20 MB of data tomorrow.
If we never removed or pruned snapshots, it would grow forever because Plakar cannot go back and delete data from previous snapshots but it can reorganize things and reclaim disk space when we perform maintenance actions to do the deletes.
This also applies to database updates not just deletes. For example if on day 1 a user fills out their profile with some data but then changes it 3 days from now then Plakar will have both the old and new copies of that data.
That’s why it’s important to still think about pruning data based on a time frame that makes sense to you, such as keeping backups for 7 days. We’ll handle this soon.
Deleting a snapshot:
You can run this to delete the earliest snapshot:
# If you don't include -apply, it performs a dry run:
$ plakar at @local rm -apply 9dd4abc1
info: rm: removal of 9dd4abc1 completed successfully
This did not delete the data associated with any of the chunks. It’s more like this shopping list has been shredded. At this point no space has been reclaimed.
Performing maintenance:
$ plakar at @local maintenance
maintenance: Coloured 1 packfiles (0 orphaned) for deletion
Coloured packfiles are scheduled to be removed in 7d
maintenance: 0 blobs and 0 packfiles were removed
Technically nothing was deleted yet due to that 7 day grace period that Plakar has for deleted snapshots. This is independent of our strategy to keep 7 days of backups btw, it’s something Plakar manages for us. If you were to re-run the same command a week from now, it would be cleaned up on disk.
With that said plakar at @local ls won’t show 9dd4abc1 anymore right
now. You could say it’s been marked for deletion.
If you’re curious to learn more, there’s extensive documentation.
Creating a 7 day prune policy:
If we know we want to keep a rolling 7 day’s worth of backups we can configure Plakar to handle things for us in a better way.
First, we can create the policy, it’s global so we don’t have to add at @local:
plakar policy add delete-backups-older-than-7-days days=7 per-day=1
The above tells Plakar to keep up to 1 snapshot per day for the last 7 days. Anything outside of this 7 day window will be marked for deletion.
You can do a dry run to test it:
$ plakar at @local prune -policy delete-backups-older-than-7-days
prune: would keep 1 and delete 0 snapshot(s), run with -apply to proceed
keep 2026-07-03T14:02:44Z 5b870dd2 8.0 KiB / match=day:2026-07-03 rank=1 cap=1
There’s nothing to delete, but let’s make things a little more interesting.
Run another backup so we have (2) backups from today:
$ ./pgbackup-plakar hello
[00:00] bdd4b2c7 completed
$ plakar at @local prune -policy delete-backups-older-than-7-days
prune: would keep 1 and delete 1 snapshot(s), run with -apply to proceed
keep 2026-07-03T14:11:38Z bdd4b2c7 8.0 KiB / match=day:2026-07-03 rank=1 cap=1
delete 2026-07-03T14:02:44Z 5b870dd2 8.0 KiB / match=day:2026-07-03 rank=2 cap=1
The takeaway here is the per-day=1 part of the policy is marking the older
snapshots from today to be deleted. This way it keeps the latest one from
today. That’s made clear by the above output in the match=day:2026-07-03
section, it’s showing us we’re keeping the latest one from today.
If you wanted to back up twice per day such as every 12 hours and save up to 7
days of backups then you can set per-day=2 instead of per-day=1.
Let’s stick with 1 and apply the prune:
$ plakar at @local prune -policy delete-backups-older-than-7-days -apply
info: prune: removal of 5b870dd2 completed successfully
Now we’re back to having the latest snapshot from today:
$ plakar at @local ls
2026-07-03T14:11:38Z bdd4b2c7 8.0 KiB 0s /
Like the rm command we ran before, nothing has been deleted from disk yet.
Automate pruning and maintenance:
We can set up a cron job that runs every day at 8:00 UTC, 2 hours after our backup.
It would be expected that you create the policy once on your server and then we
can apply it every day in this cron job in
/etc/cron.d/delete-backups-older-than-7-days:
0 8 * * * deploy /usr/local/bin/plakar at @local prune -policy delete-backups-older-than-7-days -apply && /usr/local/bin/plakar at @local maintenance
The above handles applying the policy which will mark old snapshots to be deleted as well as performing the maintenance that will perform the delete. If that’s a bit too much for a cron job entry you can always wrap it up into a script and call that instead.
Technically Plakar can run a scheduler agent instead of using a cron job. The benefit there is you can configure everything together in a YAML file. We’re going to stick with cron jobs but you can check the docs for more details.
The reason I prefer a cron job (or systemd timer) is because if this is a system I’m in control of, I like having scheduled tasks defined in 1 spot. If I list all systemd timers or cron jobs, it’s nice to know that is the source of truth.
On the other hand, if you have a dedicated Plakar server, it might be cleaner to use their schedule agent. It’s up to you and Plakar gives you this flexibility.
Revisiting the daily backup cron job:
Originally, in this file /etc/cron.d/pgbackup-hello we had:
0 6 * * * deploy /usr/local/bin/pgbackup hello
We’ll want to change it to support our Plakar storage repository:
0 6 * * * deploy PLAKAR_REPOSITORY=@local /usr/local/bin/pgbackup hello
Not bad!
Restoring Your Database
Before wiping and restoring your database, try adding some data to it so we can confirm that data doesn’t exist after we restore.
Like without Plakar, you can docker compose down -v to wipe your current
database (careful).
This script would be saved to /usr/local/bin/pgrestore-plakar (or keep the
original pgrestore name if you always plan to use Plakar) on the server:
#!/usr/bin/env bash
# Restore a Plakar database backup.
#
# Usage:
# PLAKAR_REPOSITORY=@local pgrestore-plakar myapp SNAPSHOT_ID
set -o errexit
set -o pipefail
set -o nounset
PROJECT="${1}"
SNAPSHOT_ID="${2}"
PLAKAR_PASSPHRASE_PATH="${HOME}/.config/plakar/.env-passphrase"
# shellcheck disable=SC1090
[ -z "${PLAKAR_PASSPHRASE:-}" ] && . "${PLAKAR_PASSPHRASE_PATH}"
plakar at "${PLAKAR_REPOSITORY}" cat "${SNAPSHOT_ID}:${PROJECT}.sql" |
docker compose exec --no-tty postgres psql --username "${PROJECT}"
We removed a number of things that existed without Plakar for restoring such as
the backup paths. We also swapped passing in an optional date to a mandatory
snapshot ID. You can always find the date associated with a snapshot by running
ls or info against the snapshot ID.
The overall pattern of the file is similar. Instead of gunzipping the file we
cat it out straight from Plakar’s storage. Plakar handles decompressing it for
us. We added in the same PLAKAR_REPOSITORY as we did for backing up.
You can restore your database by running:
# Replace my snapshot ID with yours, you can run ls to find it:
$ PLAKAR_REPOSITORY=@local pgrestore-plakar hello bdd4b2c7
You should see a bunch of SQL get output.
If you investigate your database you should see the new data you added previously isn’t there (because we didn’t back it up). Instead you should see the latest data you backed up originally with Plakar.
You can also view the dump without restoring it by directly running:
# Replace my snapshot ID and file name with yours:
$ plakar at @local cat bdd4b2c7:hello.sql
That should return the plain text SQL dump that you can look at. This could be something you might want to do to get an idea of the contents before you restore it. Perhaps you might grep for a specific piece of data to confirm it’s there.
# Getting Ready with AWS and S3
Before we move forward with S3 it’s expected you have an AWS account and a user with at least the permissions to CRUD (create, read, update and delete) objects in an S3 bucket.
IMPORTANT: If you set up your bucket before when testing local backups, we created an S3 life cycle policy to auto-delete files older than 7 days. You’ll want to delete that policy since Plakar’s pruning and maintenance cron job we run will manage the S3 objects created by Plakar!
With that said, before we go further, it’s worth pointing out Plakar’s S3 integration works with any S3 compatible API which means DigitalOcean Spaces, Backblaze and others will work too. We’re going to focus on S3 for this post.
You won’t need the AWS CLI installed since Plakar uses the S3 API directly.
Create S3 bucket:
Under S3, create a bucket. It can be general purpose. Use a bucket name and ensure block all public access is checked. That will keep your bucket private to the world which is almost certainly what you’d want for your database backups.
By default buckets are encrypted at rest. You can leave everything else as the defaults unless you have certain requirements you want to customize.
AWS credentials:
You’ll want to have your AWS credentials handy, this would be both your public
key and secret access key. This is typically stored in ~/.aws/credentials if
you’ve ever used the AWS CLI and have it configured it on your server.
If not, under IAM users you can find your user, click it, go to “security credentials” and then under “access keys” you can create one for “Other” access.
You’ll get both a public and secret key. Save these somewhere safe. Make sure to copy your secret key because AWS won’t show it to you other than on that key creation page once.
# Using S3 Storage with Plakar
It’s expected you’ve been through the local storage example in this post and understand the basics of Plakar. If you’re skipping around that’s ok, but only new information or differences will be covered in detail.
Add the Plakar S3 plugin:
Before we create this store back-end we need to install the S3 plugin /
package. Keep in mind this name is not related to the name of our store. It
happens to both be named s3 out of coincidence.
There’s 2 main ways to do this. One of them requires authenticating with Plakar and the other one does not:
- Login to Plakar to get access to their pre-compiled plugins so you can run
plakar pkg add s3on the server running Plakar - Compile the S3 plugin and make sure it’s available on your server
- You can either do it this once on your server or do it on your dev box as long as the platform matches your server and then copy it to your server
Option 1 requires either creating a Plakar account or using GitHub OAuth to sign in. If you want to download the plugin on your server, you can create a token with Plakar that lets you sign in to download the binary. Plakar has documentation on how to set that up.
We’re going to go with option 2 and leverage Docker so you don’t need to set up a Go build environment. We’ll also do it with a copy / paste’able command while using the official Go image from the Docker Hub.
Since your server has Docker installed already (most likely), you can run these commands on your server once to build and install the S3 plugin. If you’re following along to practice on your dev box that’s fine too!
This is going to grab the version of Plakar we’ve been using in this post (update it as needed in the curl command below) and then compile the S3 package with a version that’s compatible with the version of Plakar being used:
# If your dev box is on an arm64 device and you are building this for your server with
# intent to copy the file over then you can add this flag: --platform linux/amd64
docker container run \
--rm \
--volume "${PWD}:/output" \
--workdir "/output" \
golang:alpine sh -c "apk add --no-cache curl git make \
&& curl -sSfL https://plakar.io/dl/plakar_1.1.4_linux_amd64.tar.gz \
| tar -xzf - -C /usr/local/bin/ plakar \
&& plakar pkg build s3"
After a minute or 2 the above will produce the S3 plugin package in the same directory you ran this Docker command from.
If you’re not using Docker Desktop (such as native Linux) the file will
be owned as root. You can change it to your user with sudo chown "${USER}:${USER}" s3*.ptar before continuing.
Now you can add the S3 package from this locally compiled package by running:
$ plakar pkg add s3*.ptar
[00:00] 6b792b7d /storage/schema.json 80 MiB
********************************************* 100%
export: nodes=4/4, objects=6/7
6b792b7d: OK ✓ /storage/schema.json
Verify it’s been added by running:
# Your version might be slightly different:
$ plakar pkg list
s3@v1.1.2
Lastly you can remove the ptar file from where you ran these commands from:
rm -f s3*.ptar
If you ever want to remove the package for Plakar you can run plakar pkg rm s3. You could do this to update as well, by removing and adding the package.
Create a new S3 store alias:
We’ll name the store s3 but it can be changed to something else if you want:
# Replace the region (us-east-2) and bucket name (plakar-backup-data) with yours
# as well as your AWS credentials.
$ plakar store add s3 \
location=s3://s3.us-east-2.amazonaws.com/plakar-backup-data \
access_key="YOUR_AWS_ACCESS_KEY_ID" \
secret_access_key="YOUR_AWS_SECRET_ACCESS_KEY"
Your keys will be saved in ~/.config/plakar/stores.yml in plain text. Keep
this file secure! By default it will be created with 0600 permissions to keep
it only readable by your user.
You can verify it with this:
$ plakar store show s3
s3:
access_key: '********'
location: s3://s3.us-east-2.amazonaws.com/plakar-backup-data
secret_access_key: '********'
You can also ping it to verify your credentials work with:
$ plakar store ping s3
configuration OK
Configuring Plakar (S3 backup):
We can create our back-end just like we did with local, except now we’ll use S3.
Up until now, creating an AWS user, credentials and an empty S3 bucket cost nothing. Keep in mind running the command below is now a billable event since files will be created in S3:
plakar at @s3 create
If it all worked correctly you won’t see any output.
If you check out your S3 bucket you’ll see a single CONFIG object.
Sync Local to S3 or Delete Local?
I wanted to call this out because Plakar can sync your backups from one
repository to another, such as local to s3 and vice versa. It’s useful to
know this can be done in case you decide to start with local files and change
over to using S3 later.
We’ve been playing around with local storage and the intent was to delete it for the sake of this tutorial but we can migrate our local backup to S3.
Even with your Plakar passphrase env var exported it will prompt you for your passphrase:
$ plakar at @local sync to @s3
destination store passphrase:
info: Synchronizing snapshot bdd4b2c72c291d3c1faac915be845c4fe9ad8e24c6085515ffdff2271d141a47 from localhost to s3.us-east-2.amazonaws.com
info: Synchronization of bdd4b2c72c291d3c1faac915be845c4fe9ad8e24c6085515ffdff2271d141a47 finished
info: sync: synchronization from localhost to s3.us-east-2.amazonaws.com completed: 1 snapshots synchronized
Now if you check your S3 bucket you’ll see a few folders, similar to your local storage.
You can compare both if you want, notice we’re using s3 and then local:
$ plakar at @s3 ls
2026-07-03T14:11:38Z bdd4b2c7 8.0 KiB 1s /
$ plakar at @local ls
2026-07-03T14:11:38Z bdd4b2c7 8.0 KiB 0s /
It’s the same snapshot ID and time stamp, awesome!
Deleting the local repository:
You can rm -rf ~/db-backups to fully and permanently delete your local
backups for all databases or anything managed by Plakar in this repository. Be
careful with running that command, there’s no going back.
Since we want to use S3, that makes sense. We’ve used local storage as a primer on getting used to working with Plakar.
Technically we could delete the alias too with plakar store rm local if we
don’t plan on using it anymore.
Let’s keep the policy because when we use S3, we’ll use the same one.
Backup and Restore with Plakar over S3
We’re going to blaze through these commands since we went over them before.
Feel free to add more data to your running application so we can back it up.
Backup:
Since our backup script is general purpose for Plakar, we can pass in the @s3
repo instead of @local by running:
$ PLAKAR_REPOSITORY=@s3 pgbackup-plakar hello
[00:02] 1c26bccb completed 8.5 KiB
******************************************* 100%
import: nodes=1/1, objects=1/1
store: read=1.4 KiB, write=1.1 MiB
This should take slightly longer to complete because it’s uploading files to S3.
Like before, it should be there:
$ plakar at @s3 ls
2026-07-03T19:48:02Z 1c26bccb 8.5 KiB 1s /
2026-07-03T14:11:38Z bdd4b2c7 8.0 KiB 1s /
Restore:
You can clear your database by running docker compose down -v and up your
project.
We can restore from that latest backup with:
# Don't forget to swap in your snapshot ID below:
$ PLAKAR_REPOSITORY=@s3 pgrestore-plakar hello 1c26bccb
It should output SQL information just like it did before. If you go back to your application, you should see your data!
Just like we did before you can run plakar at @s3 cat 1c26bccb:hello.sql to
see the SQL dump that was saved.
Cron jobs:
The pruning and maintenance job cron is an easy change, update @local to
@s3 in 2 spots in /etc/cron.d/delete-backups-older-than-7-days:
0 8 * * * deploy /usr/local/bin/plakar at @s3 prune -policy delete-backups-older-than-7-days -apply && /usr/local/bin/plakar at @s3 maintenance
Also, if you stick to using S3 you don’t need to keep
/etc/cron.d/delete-old-db-backups running on your server because the backup
files get saved onto S3. In fact, it will throw an error if that path doesn’t
exist:
0 8 * * * deploy /usr/bin/find /home/deploy/db-backups -type f -name "*.sql.gz" -mtime +7 -delete
You can delete the above cron job if you’re using S3 backups.
Power of Abstractions
This really starts to demonstrate the power of abstractions. Look how little had to change when using Plakar to switch between local and S3. Once we had the store configured everything else was the same.
With our prune and maintenance cron job running from the previous section (local backups) our bucket will be kept under control with how much data we want to keep around.
Clearing Your S3 Bucket
Keep in mind, this will delete your backups!
If you ever want to get rid of your bucket or wipe everything, you can go to AWS, select all of the objects in the bucket and delete them.
That’s about it, we covered a lot of ground around backing up and restoring your database.
P.S., Plakar also has an optional control plane you can run with a graphical interface. Check out their docs for hosting that on premises.
The video below covers what’s in this post and shows running the commands.
# Demo Video
Timestamps
- 1:40 – Daily gzipped database dumps
- 2:33 – Setting up an example project
- 3:16 – Local backup script
- 11:18 – Running the backup script
- 12:16 – Briefly looking into using S3
- 13:05 – Daily cron jobs
- 14:36 – Pruning older backups in a cron job
- 16:13 – Pruning old backups in S3
- 19:02 – Restoring from a backup
- 22:14 – Checking out the restore script
- 23:30 – Inefficiencies and incremental backups
- 25:27 – How Plakar helps (concrete example)
- 36:52 – Installing Plakar
- 39:53 – Configuring Plakar for local backups
- 42:29 – Plakar repository aliases
- 44:37 – Storing your Plakar passphrase
- 46:59 – Scripting local backups with Plakar
- 51:22 – Running the Plakar local backup script
- 53:19 – What if you have multiple databases?
- 54:31 – Verify and check your backups
- 57:29 – Diffing 2 backups
- 59:32 – Understanding deletes, chunks and maintenance
- 1:04:19 – Creating a Plakar 7 day prune policy
- 1:07:24 – Cron job for the prune policy and maintenance
- 1:09:42 – Restoring your database with Plakar
- 1:10:33 – The Plakar restore script
- 1:13:23 – Using Plakar to do a backup restore
- 1:14:38 – Getting ready to use S3 with Plakar
- 1:19:26 – Installing the Plakar S3 plugin
- 1:25:48 – Adding a Plakar S3 alias
- 1:28:07 – Creating a Plakar S3 repository
- 1:29:02 – Syncing between repositories
- 1:30:56 – Deleting the local repository
- 1:31:55 – Backing up and restoring with Plakar over S3
- 1:34:33 – Tiny cron job adjustments
- 1:35:37 – Clearing out your S3 bucket
How do you backup and restore your SQL databases?